UNPKG

maplair

Version:

JavaScript library for mobile-friendly interactive maps

1,831 lines • 59.1 kB
import * as Util from '../core/Util.js';
import {Evented} from '../core/Events.js';
import {EPSG3857} from '../geo/crs/EPSG3857.js';
import {Point} from '../geometry/Point.js';
import {Bounds} from '../geometry/Bounds.js';
import {LatLng} from '../geo/LatLng.js';
import {LatLngBounds} from '../geo/LatLngBounds.js';
import Browser from '../core/Browser.js';
import * as DomEvent from '../dom/DomEvent.js';
import * as DomUtil from '../dom/DomUtil.js';
import {PosAnimation} from '../dom/PosAnimation.js';
import * as PointerEvents from '../dom/DomEvent.PointerEvents.js';
import {Control} from '../control/Control.js';
import {AttributionControl} from '../control/AttributionControl.js';
import {ZoomControl} from '../control/ZoomControl.js';
import {Layer} from '../layer/Layer.js';
import {DivOverlay} from '../layer/DivOverlay.js';
import {Popup} from '../layer/Popup.js';
import {Tooltip} from '../layer/Tooltip.js';
import * as RendererSupport from '../layer/vector/Renderer.getRenderer.js';
import {BoxZoomHandler} from './handler/BoxZoomHandler.js';
import {DoubleClickZoomHandler} from './handler/DoubleClickZoomHandler.js';
import {DragHandler} from './handler/DragHandler.js';
import {KeyboardHandler} from './handler/KeyboardHandler.js';
import {PinchZoomHandler} from './handler/PinchZoomHandler.js';
import {ScrollWheelZoomHandler} from './handler/ScrollWheelZoomHandler.js';
import {TapHoldHandler} from './handler/TapHoldHandler.js';

/*
 * @class MaplairMap
 * @inherits Evented
 *
 * The central class of the API — it is used to create a map on a page and manipulate it.
 *
 * @example
 *
 * ```js
 * // initialize the map on the "map" div with a given center and zoom
 * const map = new MaplairMap('map', {
 * 	center: [51.505, -0.09],
 * 	zoom: 13
 * });
 * ```
 *
 */

// @section
// @constructor MaplairMap(id: String, options?: MaplairMap options)
// Instantiates a map object given the DOM ID of a `<div>` element
// and optionally an object literal with `MaplairMap options`.
//
// @alternative
// @constructor MaplairMap(el: HTMLElement, options?: MaplairMap options)
// Instantiates a map object given an instance of a `<div>` HTML element
// and optionally an object literal with `MaplairMap options`.
//
// @alternative
// @constructor Map(id: String, options?: Map options)
// Instantiates a map object given the DOM ID of a `<div>` element
// and optionally an object literal with `Map options`.
//
// @alternative
// @constructor Map(el: HTMLElement, options?: Map options)
// Instantiates a map object given an instance of a `<div>` HTML element
// and optionally an object literal with `Map options`.
export class MaplairMap extends Evented {

	static {
		this.setDefaultOptions({
			// @section Map State Options
			// @option crs: CRS = EPSG3857
			// The [Coordinate Reference System](#crs) to use. Don't change this if you're not
			// sure what it means.
			crs: EPSG3857,

			// @option center: LatLng = undefined
			// Initial geographic center of the map
			center: undefined,

			// @option zoom: Number = undefined
			// Initial map zoom level
			zoom: undefined,

			// @option minZoom: Number = *
			// Minimum zoom level of the map.
			// If not specified and at least one `GridLayer` or `TileLayer` is in the map,
			// the lowest of their `minZoom` options will be used instead.
			minZoom: undefined,

			// @option maxZoom: Number = *
			// Maximum zoom level of the map.
			// If not specified and at least one `GridLayer` or `TileLayer` is in the map,
			// the highest of their `maxZoom` options will be used instead.
			maxZoom: undefined,

			// @option layers: Layer[] = []
			// Array of layers that will be added to the map initially
			layers: [],

			// @option maxBounds: LatLngBounds = null
			// When this option is set, the map restricts the view to the given
			// geographical bounds, bouncing the user back if the user tries to pan
			// outside the view. To set the restriction dynamically, use
			// [`setMaxBounds`](#map-setmaxbounds) method.
			maxBounds: undefined,

			// @option renderer: Renderer = *
			// The default method for drawing vector layers on the map. `SVG`
			// or `Canvas` by default depending on browser support.
			renderer: undefined,


			// @section Animation Options
			// @option zoomAnimation: Boolean = *
			// Whether the map zoom animation is enabled.
			// Animations are enabled by default unless the user has configured
			// their browser for reduced [motion](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion#user_preferences).
			zoomAnimation: !Browser.reducedMotion,

			// @option zoomAnimationThreshold: Number = 4
			// Won't animate zoom if the zoom difference exceeds this value.
			zoomAnimationThreshold: 4,

			// @option fadeAnimation: Boolean = *
			// Whether the tile fade animation is enabled.
			// Animations are enabled by default unless the user has configured
			// their browser for reduced [motion](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion#user_preferences).
			fadeAnimation: !Browser.reducedMotion,

			// @option markerZoomAnimation: Boolean = true
			// Whether markers animate their zoom with the zoom animation, if disabled
			// they will disappear for the length of the animation.
			markerZoomAnimation: true,

			// @option markerCulling: Boolean = false
			// Whether markers far outside of the map view (more than half a view
			// away) are taken off the page until the map is moved closer to them.
			// This reduces the browser's rendering work, and the work of
			// repositioning markers while zooming, when a map has many markers
			// (e.g. several thousands) of which only some are visible at a time.
			markerCulling: false,

			// @option transform3DLimit: Number = 2^23
			// Defines the maximum size of a CSS translation transform. The default
			// value should not be changed unless a web browser positions layers in
			// the wrong place after doing a large `panBy`.
			transform3DLimit: 8388608, // Precision limit of a 32-bit float

			// @section Interaction Options
			// @option zoomSnap: Number = 1
			// Forces the map's zoom level to always be a multiple of this, particularly
			// right after a [`fitBounds()`](#map-fitbounds) or a pinch-zoom.
			// By default, the zoom level snaps to the nearest integer; lower values
			// (e.g. `0.5` or `0.1`) allow for greater granularity. A value of `0`
			// means the zoom level will not be snapped after `fitBounds` or a pinch-zoom.
			zoomSnap: 1,

			// @option zoomDelta: Number = 1
			// Controls how much the map's zoom level will change after a
			// [`zoomIn()`](#map-zoomin), [`zoomOut()`](#map-zoomout), pressing `+`
			// or `-` on the keyboard, or using the [zoom controls](#control-zoom).
			// Values smaller than `1` (e.g. `0.5`) allow for greater granularity.
			zoomDelta: 1,

			// @option trackResize: Boolean = true
			// Whether the map automatically handles browser window resize to update itself.
			trackResize: true
		});
	}

	constructor(id, options) { // (HTMLElement or String, Object)
		super();
		options = Util.setOptions(this, options);

		// Make sure to assign internal flags at the beginning,
		// to avoid inconsistent state in some edge cases.
		this._handlers = [];
		this._layers = {};
		this._zoomBoundLayers = {};
		this._sizeChanged = true;

		this._initContainer(id);
		this._initLayout();

		this._initEvents();

		if (options.maxBounds) {
			this.setMaxBounds(options.maxBounds);
		}

		if (options.zoom !== undefined) {
			this._zoom = this._limitZoom(options.zoom);
		}

		if (options.center && options.zoom !== undefined) {
			this.setView(new LatLng(options.center), options.zoom, {reset: true});
		}

		this.callInitHooks(MaplairMap);

		this._zoomAnimated = this.options.zoomAnimation;

		// zoom transitions run with the same duration for all layers, so if one of transitionend events
		// happens after starting zoom animation (propagating to the map pane), we know that it ended globally
		if (this._zoomAnimated) {
			this._createAnimProxy();
		}

		this._addLayers(this.options.layers);
	}

	// @section Methods for modifying map state

	// @method setView(center: LatLng, zoom?: Number, options?: Zoom/pan options): this
	// Sets the view of the map (geographical center and zoom) with the given
	// animation options.
	setView(center, zoom, options) {

		zoom = zoom === undefined ? this._zoom : this._limitZoom(zoom);
		center = this._limitCenter(new LatLng(center), zoom, this.options.maxBounds);
		options ??= {};

		this._stop();

		if (this._loaded && !options.reset && options !== true) {

			if (options.animate !== undefined) {
				options.zoom = {animate: options.animate, ...options.zoom};
				options.pan = {animate: options.animate, duration: options.duration, ...options.pan};
			}

			// try animating pan or zoom
			const moved = (this._zoom !== zoom) ?
				this._tryAnimatedZoom && this._tryAnimatedZoom(center, zoom, options.zoom) :
				this._tryAnimatedPan(center, options.pan);

			if (moved) {
				// prevent resize handler call, the view will refresh after animation anyway
				clearTimeout(this._sizeTimer);
				return this;
			}
		}

		// animation didn't start, just reset the map view
		this._resetView(center, zoom, options.pan?.noMoveStart);

		return this;
	}

	// @method setZoom(zoom: Number, options?: Zoom/pan options): this
	// Sets the zoom of the map.
	setZoom(zoom, options) {
		if (!this._loaded) {
			this._zoom = zoom;
			return this;
		}
		return this.setView(this.getCenter(), zoom, {zoom: options});
	}

	// @method zoomIn(delta?: Number, options?: Zoom options): this
	// Increases the zoom of the map by `delta` ([`zoomDelta`](#map-zoomdelta) by default).
	zoomIn(delta, options) {
		delta ??= this.options.zoomDelta;
		return this.setZoom(this._zoom + delta, options);
	}

	// @method zoomOut(delta?: Number, options?: Zoom options): this
	// Decreases the zoom of the map by `delta` ([`zoomDelta`](#map-zoomdelta) by default).
	zoomOut(delta, options) {
		delta ??= this.options.zoomDelta;
		return this.setZoom(this._zoom - delta, options);
	}

	// @method setZoomAround(latlng: LatLng, zoom: Number, options: Zoom options): this
	// Zooms the map while keeping a specified geographical point on the map
	// stationary (e.g. used internally for scroll zoom and double-click zoom).
	// @alternative
	// @method setZoomAround(offset: Point, zoom: Number, options: Zoom options): this
	// Zooms the map while keeping a specified pixel on the map (relative to the top-left corner) stationary.
	setZoomAround(latlng, zoom, options) {
		const scale = this.getZoomScale(zoom),
		viewHalf = this.getSize().divideBy(2),
		containerPoint = latlng instanceof Point ? latlng : this.latLngToContainerPoint(latlng),

		centerOffset = containerPoint.subtract(viewHalf).multiplyBy(1 - 1 / scale),
		newCenter = this.containerPointToLatLng(viewHalf.add(centerOffset));

		return this.setView(newCenter, zoom, {zoom: options});
	}

	_getBoundsCenterZoom(bounds, options) {

		options ??= {};
		bounds = bounds.getBounds ? bounds.getBounds() : new LatLngBounds(bounds);

		const paddingTL = new Point(options.paddingTopLeft || options.padding || [0, 0]),
		paddingBR = new Point(options.paddingBottomRight || options.padding || [0, 0]);

		let zoom = this.getBoundsZoom(bounds, false, paddingTL.add(paddingBR));

		zoom = (typeof options.maxZoom === 'number') ? Math.min(options.maxZoom, zoom) : zoom;

		if (zoom === Infinity) {
			return {
				center: bounds.getCenter(),
				zoom
			};
		}

		const paddingOffset = paddingBR.subtract(paddingTL).divideBy(2),

		swPoint = this.project(bounds.getSouthWest(), zoom),
		nePoint = this.project(bounds.getNorthEast(), zoom),
		center = this.unproject(swPoint.add(nePoint).divideBy(2).add(paddingOffset), zoom);

		return {
			center,
			zoom
		};
	}

	// @method fitBounds(bounds: LatLngBounds, options?: fitBounds options): this
	// Sets a map view that contains the given geographical bounds with the
	// maximum zoom level possible.
	fitBounds(bounds, options) {

		bounds = new LatLngBounds(bounds);

		if (!bounds.isValid()) {
			throw new Error('Bounds are not valid.');
		}

		const target = this._getBoundsCenterZoom(bounds, options);
		return this.setView(target.center, target.zoom, options);
	}

	// @method fitWorld(options?: fitBounds options): this
	// Sets a map view that mostly contains the whole world with the maximum
	// zoom level possible.
	fitWorld(options) {
		return this.fitBounds([[-90, -180], [90, 180]], options);
	}

	// @method panTo(latlng: LatLng, options?: Pan options): this
	// Pans the map to a given center.
	panTo(center, options) {
		return this.setView(center, this._zoom, {pan: options});
	}

	// @method panBy(offset: Point, options?: Pan options): this
	// Pans the map by a given number of pixels (animated).
	panBy(offset, options) {
		offset = new Point(offset).round();
		options ??= {};

		if (!offset.x && !offset.y) {
			return this.fire('moveend');
		}
		// If we pan too far, Chrome gets issues with tiles
		// and makes them disappear or appear in the wrong place (slightly offset) #2602
		if (options.animate !== true && !this.getSize().contains(offset)) {
			this._resetView(this.unproject(this.project(this.getCenter()).add(offset)), this.getZoom());
			return this;
		}

		if (!this._panAnim) {
			this._panAnim = new PosAnimation();

			this._panAnim.on({
				'step': this._onPanTransitionStep,
				'end': this._onPanTransitionEnd
			}, this);
		}

		// don't fire movestart if animating inertia
		if (!options.noMoveStart) {
			this.fire('movestart');
		}

		// animate pan unless browser prefers reduced motion or animate: false specified
		if (!Browser.reducedMotion && options.animate !== false) {
			this._mapPane.classList.add('maplair-pan-anim');

			const newPos = this._getMapPanePos().subtract(offset).round();
			this._panAnim.run(this._mapPane, newPos, options.duration || 0.25, options.easeLinearity);
		} else {
			this._rawPanBy(offset);
			this.fire('move').fire('moveend');
		}

		return this;
	}

	// @method flyTo(latlng: LatLng, zoom?: Number, options?: Zoom/pan options): this
	// Sets the view of the map (geographical center and zoom) performing a smooth
	// pan-zoom animation.
	flyTo(targetCenter, targetZoom, options) {

		options ??= {};
		if (Browser.reducedMotion || options.animate === false) {
			return this.setView(targetCenter, targetZoom, options);
		}

		this._stop();

		const from = this.project(this.getCenter()),
		to = this.project(targetCenter),
		size = this.getSize(),
		startZoom = this._zoom;

		targetCenter = new LatLng(targetCenter);
		targetZoom = targetZoom === undefined ? startZoom : this._limitZoom(targetZoom);

		const w0 = Math.max(size.x, size.y),
		w1 = w0 * this.getZoomScale(startZoom, targetZoom),
		u1 = (to.distanceTo(from)) || 1,
		rho = 1.42,
		rho2 = rho * rho;

		function r(i) {
			const s1 = i ? -1 : 1,
			s2 = i ? w1 : w0,
			t1 = w1 * w1 - w0 * w0 + s1 * rho2 * rho2 * u1 * u1,
			b1 = 2 * s2 * rho2 * u1,
			b = t1 / b1,
			sq = Math.sqrt(b * b + 1) - b;

			// workaround for floating point precision bug when sq = 0, log = -Infinite,
			// thus triggering an infinite loop in flyTo
			const log = sq < 0.000000015 ? -18 : Math.log(sq);

			return log;
		}

		function sinh(n) { return (Math.exp(n) - Math.exp(-n)) / 2; }
		function cosh(n) { return (Math.exp(n) + Math.exp(-n)) / 2; }
		function tanh(n) { return sinh(n) / cosh(n); }

		const r0 = r(0);

		function w(s) { return w0 * (cosh(r0) / cosh(r0 + rho * s)); }
		function u(s) { return w0 * (cosh(r0) * tanh(r0 + rho * s) - sinh(r0)) / rho2; }

		function easeOut(t) { return 1 - (1 - t) ** 1.5; }

		const start = Date.now(),
		S = (r(1) - r0) / rho,
		duration = options.duration ? 1000 * options.duration : 1000 * S * 0.8;

		const frame = () => {
			const t = (Date.now() - start) / duration,
			s = easeOut(t) * S;

			if (t <= 1) {
				this._flyToFrame = requestAnimationFrame(frame);

				this._move(
					this.unproject(from.add(to.subtract(from).multiplyBy(u(s) / u1)), startZoom),
					this.getScaleZoom(w0 / w(s), startZoom),
					{flyTo: true});

			} else {
				this
					._move(targetCenter, targetZoom)
					._moveEnd(true);
			}
		};

		this._moveStart(true, options.noMoveStart);

		frame(this);
		return this;
	}

	// @method flyToBounds(bounds: LatLngBounds, options?: fitBounds options): this
	// Sets the view of the map with a smooth animation like [`flyTo`](#map-flyto),
	// but takes a bounds parameter like [`fitBounds`](#map-fitbounds).
	flyToBounds(bounds, options) {
		const target = this._getBoundsCenterZoom(bounds, options);
		return this.flyTo(target.center, target.zoom, options);
	}

	// @method setMaxBounds(bounds: LatLngBounds): this
	// Restricts the map view to the given bounds (see the [maxBounds](#map-maxbounds) option).
	setMaxBounds(bounds) {
		bounds = new LatLngBounds(bounds);

		if (this.listens('moveend', this._panInsideMaxBounds)) {
			this.off('moveend', this._panInsideMaxBounds);
		}

		if (!bounds.isValid()) {
			this.options.maxBounds = null;
			return this;
		}

		this.options.maxBounds = bounds;

		if (this._loaded) {
			this._panInsideMaxBounds();
		}

		return this.on('moveend', this._panInsideMaxBounds);
	}

	// @method setMinZoom(zoom: Number): this
	// Sets the lower limit for the available zoom levels (see the [minZoom](#map-minzoom) option).
	setMinZoom(zoom) {
		const oldZoom = this.options.minZoom;
		this.options.minZoom = zoom;

		if (this._loaded && oldZoom !== zoom) {
			this.fire('zoomlevelschange');

			if (this.getZoom() < this.options.minZoom) {
				return this.setZoom(zoom);
			}
		}

		return this;
	}

	// @method setMaxZoom(zoom: Number): this
	// Sets the upper limit for the available zoom levels (see the [maxZoom](#map-maxzoom) option).
	setMaxZoom(zoom) {
		const oldZoom = this.options.maxZoom;
		this.options.maxZoom = zoom;

		if (this._loaded && oldZoom !== zoom) {
			this.fire('zoomlevelschange');

			if (this.getZoom() > this.options.maxZoom) {
				return this.setZoom(zoom);
			}
		}

		return this;
	}

	// @method panInsideBounds(bounds: LatLngBounds, options?: Pan options): this
	// Pans the map to the closest view that would lie inside the given bounds (if it's not already), controlling the animation using the options specific, if any.
	panInsideBounds(bounds, options) {
		this._enforcingBounds = true;
		const center = this.getCenter(),
		newCenter = this._limitCenter(center, this._zoom, new LatLngBounds(bounds));

		if (!center.equals(newCenter)) {
			this.panTo(newCenter, options);
		}

		this._enforcingBounds = false;
		return this;
	}

	// @method panInside(latlng: LatLng, options?: padding options): this
	// Pans the map the minimum amount to make the `latlng` visible. Use
	// padding options to fit the display to more restricted bounds.
	// If `latlng` is already within the (optionally padded) display bounds,
	// the map will not be panned.
	panInside(latlng, options) {
		options ??= {};

		const paddingTL = new Point(options.paddingTopLeft || options.padding || [0, 0]),
		paddingBR = new Point(options.paddingBottomRight || options.padding || [0, 0]),
		pixelCenter = this.project(this.getCenter()),
		pixelPoint = this.project(latlng),
		pixelBounds = this.getPixelBounds(),
		paddedBounds = new Bounds([pixelBounds.min.add(paddingTL), pixelBounds.max.subtract(paddingBR)]),
		paddedSize = paddedBounds.getSize();

		if (!paddedBounds.contains(pixelPoint)) {
			this._enforcingBounds = true;
			const centerOffset = pixelPoint.subtract(paddedBounds.getCenter());
			const offset = paddedBounds.extend(pixelPoint).getSize().subtract(paddedSize);
			pixelCenter.x += centerOffset.x < 0 ? -offset.x : offset.x;
			pixelCenter.y += centerOffset.y < 0 ? -offset.y : offset.y;
			this.panTo(this.unproject(pixelCenter), options);
			this._enforcingBounds = false;
		}
		return this;
	}

	// @method invalidateSize(options: invalidateSize options): this
	// Checks if the map container size changed and updates the map if so —
	// call it after you've changed the map size dynamically, also animating
	// pan by default. If `options.pan` is `false`, panning will not occur.
	// If `options.debounceMoveend` is `true`, it will delay `moveend` event so
	// that it doesn't happen often even if the method is called many
	// times in a row.

	// @alternative
	// @method invalidateSize(animate: Boolean): this
	// Checks if the map container size changed and updates the map if so —
	// call it after you've changed the map size dynamically, also animating
	// pan by default.
	invalidateSize(options) {
		if (!this._loaded) { return this; }

		options = {
			animate: false,
			pan: true,
			...(options === true ? {animate: true} : options)
		};

		const oldSize = this.getSize();
		this._sizeChanged = true;
		this._lastCenter = null;

		const newSize = this.getSize(),
		oldCenter = oldSize.divideBy(2).round(),
		newCenter = newSize.divideBy(2).round(),
		offset = oldCenter.subtract(newCenter);

		if (!offset.x && !offset.y) { return this; }

		if (options.animate && options.pan) {
			this.panBy(offset);

		} else {
			if (options.pan) {
				this._rawPanBy(offset);
			}

			this.fire('move');

			if (options.debounceMoveend) {
				clearTimeout(this._sizeTimer);
				this._sizeTimer = setTimeout(this.fire.bind(this, 'moveend'), 200);
			} else {
				this.fire('moveend');
			}
		}

		// @section Map state change events
		// @event resize: ResizeEvent
		// Fired when the map is resized.
		return this.fire('resize', {
			oldSize,
			newSize
		});
	}

	// @section Methods for modifying map state
	// @method stop(): this
	// Stops the currently running `panTo` or `flyTo` animation, if any.
	stop() {
		this.setZoom(this._limitZoom(this._zoom));
		if (!this.options.zoomSnap) {
			this.fire('viewreset');
		}
		return this._stop();
	}

	// @section Geolocation methods
	// @method locate(options?: Locate options): this
	// Tries to locate the user using the Geolocation API, firing a [`locationfound`](#map-locationfound)
	// event with location data on success or a [`locationerror`](#map-locationerror) event on failure,
	// and optionally sets the map view to the user's location with respect to
	// detection accuracy (or to the world view if geolocation failed).
	// Note that, if your page doesn't use HTTPS, this method will fail in
	// modern browsers ([Chrome 50 and newer](https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-powerful-features-on-insecure-origins))
	// See `Locate options` for more details.
	locate(options) {

		options = this._locateOptions = {
			timeout: 10000,
			watch: false,
			// setView: false
			// maxZoom: <Number>
			// maximumAge: 0
			// enableHighAccuracy: false
			...options
		};

		if (!('geolocation' in navigator)) {
			this._handleGeolocationError({
				code: 0,
				message: 'Geolocation not supported.'
			});
			return this;
		}

		const onResponse = this._handleGeolocationResponse.bind(this),
		onError = this._handleGeolocationError.bind(this);

		if (options.watch) {
			if (this._locationWatchId !== undefined) {
				navigator.geolocation.clearWatch(this._locationWatchId);
			}
			this._locationWatchId = navigator.geolocation.watchPosition(onResponse, onError, options);
		} else {
			navigator.geolocation.getCurrentPosition(onResponse, onError, options);
		}
		return this;
	}

	// @method stopLocate(): this
	// Stops watching location previously initiated by `map.locate({watch: true})`
	// and aborts resetting the map view if map.locate was called with
	// `{setView: true}`.
	stopLocate() {
		navigator.geolocation?.clearWatch?.(this._locationWatchId);
		if (this._locateOptions) {
			this._locateOptions.setView = false;
		}
		return this;
	}

	_handleGeolocationError(error) {
		if (!this._container._maplair_id) { return; }

		const c = error.code,
		message = error.message ||
		            (c === 1 ? 'permission denied' :
		            (c === 2 ? 'position unavailable' : 'timeout'));

		if (this._locateOptions.setView && !this._loaded) {
			this.fitWorld();
		}

		// @section Location events
		// @event locationerror: ErrorEvent
		// Fired when geolocation (using the [`locate`](#map-locate) method) failed.
		this.fire('locationerror', {
			code: c,
			message: `Geolocation error: ${message}.`
		});
	}

	_handleGeolocationResponse(pos) {
		if (!this._container._maplair_id) { return; }

		const lat = pos.coords.latitude,
		lng = pos.coords.longitude,
		latlng = new LatLng(lat, lng),
		bounds = latlng.toBounds(pos.coords.accuracy * 2),
		options = this._locateOptions;

		if (options.setView) {
			const zoom = this.getBoundsZoom(bounds);
			this.setView(latlng, options.maxZoom ? Math.min(zoom, options.maxZoom) : zoom);
		}

		const data = {
			latlng,
			bounds,
			timestamp: pos.timestamp
		};

		for (const i in pos.coords) { // do not use Object.keys here to access getters of GeolocationCoordinates
			if (typeof pos.coords[i] === 'number') {
				data[i] = pos.coords[i];
			}
		}

		// @event locationfound: LocationEvent
		// Fired when geolocation (using the [`locate`](#map-locate) method)
		// went successfully.
		this.fire('locationfound', data);
	}

	// TODO Appropriate docs section?
	// @section Other Methods
	// @method addHandler(name: String, HandlerClass: Function): this
	// Adds a new `Handler` to the map, given its name and constructor function.
	addHandler(name, HandlerClass) {
		if (!HandlerClass) { return this; }

		const handler = this[name] = new HandlerClass(this);

		this._handlers.push(handler);

		if (this.options[name]) {
			handler.enable();
		}

		return this;
	}

	// @method remove(): this
	// Destroys the map and clears all related event listeners.
	remove() {

		this._initEvents(true);
		if (this.options.maxBounds) { this.off('moveend', this._panInsideMaxBounds); }

		if (this._containerId !== this._container._maplair_id) {
			throw new Error('Map container is being reused by another instance');
		}

		delete this._container._maplair_id;
		delete this._containerId;

		if (this._locationWatchId !== undefined) {
			this.stopLocate();
		}

		this._stop();

		PointerEvents.disablePointerDetection(this._container);
		this._mapPane.remove();

		if (this._clearControlPos) {
			this._clearControlPos();
		}
		if (this._resizeRequest) {
			cancelAnimationFrame(this._resizeRequest);
			this._resizeRequest = null;
		}

		this._clearHandlers();

		clearTimeout(this._transitionEndTimer);
		clearTimeout(this._sizeTimer);

		if (this._loaded) {
			// @section Map state change events
			// @event unload: Event
			// Fired when the map is destroyed with [remove](#map-remove) method.
			this.fire('unload');
		}

		this._destroyAnimProxy();

		for (const layer of Object.values(this._layers)) {
			layer.remove();
		}
		for (const pane of Object.values(this._panes)) {
			pane.remove();
		}


		this._layers = {};
		this._panes = {};
		delete this._mapPane;
		delete this._renderer;

		return this;
	}

	// @section Other Methods
	// @method createPane(name: String, container?: HTMLElement): HTMLElement
	// Creates a new [map pane](#map-pane) with the given name if it doesn't exist already,
	// then returns it. The pane is created as a child of `container`, or
	// as a child of the main map pane if not set.
	createPane(name, container) {
		const className = `maplair-pane${name ? ` maplair-${name.replace('Pane', '')}-pane` : ''}`,
		pane = DomUtil.create('div', className, container || this._mapPane);

		if (name) {
			this._panes[name] = pane;
		}
		return pane;
	}

	// @section Methods for Getting Map State

	// @method getCenter(): LatLng
	// Returns the geographical center of the map view
	getCenter() {
		this._checkIfLoaded();

		if (this._lastCenter && !this._moved()) {
			return new LatLng(this._lastCenter);
		}
		return this.layerPointToLatLng(this._getCenterLayerPoint());
	}

	// @method getZoom(): Number
	// Returns the current zoom level of the map view
	getZoom() {
		return this._zoom;
	}

	// @method getBounds(): LatLngBounds
	// Returns the geographical bounds visible in the current map view
	getBounds() {
		const bounds = this.getPixelBounds(),
		sw = this.unproject(bounds.getBottomLeft()),
		ne = this.unproject(bounds.getTopRight());

		return new LatLngBounds(sw, ne);
	}

	// @method getMinZoom(): Number
	// Returns the minimum zoom level of the map (if set in the `minZoom` option of the map or of any layers), or `0` by default.
	getMinZoom() {
		return this.options.minZoom ?? this._layersMinZoom ?? 0;
	}

	// @method getMaxZoom(): Number
	// Returns the maximum zoom level of the map (if set in the `maxZoom` option of the map or of any layers).
	getMaxZoom() {
		return this.options.maxZoom ?? this._layersMaxZoom ?? Infinity;
	}

	// @method getBoundsZoom(bounds: LatLngBounds, inside?: Boolean, padding?: Point): Number
	// Returns the maximum zoom level on which the given bounds fit to the map
	// view in its entirety. If `inside` (optional) is set to `true`, the method
	// instead returns the minimum zoom level on which the map view fits into
	// the given bounds in its entirety.
	getBoundsZoom(bounds, inside, padding) { // (LatLngBounds[, Boolean, Point]) -> Number
		bounds = new LatLngBounds(bounds);
		padding = new Point(padding ?? [0, 0]);

		let zoom = this.getZoom() ?? 0;
		const min = this.getMinZoom(),
		max = this.getMaxZoom(),
		nw = bounds.getNorthWest(),
		se = bounds.getSouthEast(),
		size = this.getSize().subtract(padding),
		boundsSize = new Bounds(this.project(se, zoom), this.project(nw, zoom)).getSize(),
		snap = this.options.zoomSnap,
		scalex = size.x / boundsSize.x,
		scaley = size.y / boundsSize.y,
		scale = inside ? Math.max(scalex, scaley) : Math.min(scalex, scaley);

		zoom = this.getScaleZoom(scale, zoom);

		if (snap) {
			zoom = Math.round(zoom / (snap / 100)) * (snap / 100); // don't jump if within 1% of a snap level
			zoom = inside ? Math.ceil(zoom / snap) * snap : Math.floor(zoom / snap) * snap;
		}

		return Math.max(min, Math.min(max, zoom));
	}

	// @method getSize(): Point
	// Returns the current size of the map container (in pixels).
	getSize() {
		if (!this._size || this._sizeChanged) {
			this._size = new Point(
				this._container.clientWidth || 0,
				this._container.clientHeight || 0);

			this._sizeChanged = false;
		}
		return new Point(this._size);
	}

	// @method getPixelBounds(center?: LatLng, zoom?: Number): Bounds
	// Returns the bounds of the current map view in projected pixel
	// coordinates (sometimes useful in layer and overlay implementations).
	// If `center` and `zoom` is omitted, the map's current zoom level and center is used.
	getPixelBounds(center, zoom) {
		const topLeftPoint = this._getTopLeftPoint(center, zoom);
		return new Bounds(topLeftPoint, topLeftPoint.add(this.getSize()));
	}

	// TODO: Check semantics - isn't the pixel origin the 0,0 coord relative to
	// the map pane? "left point of the map layer" can be confusing, specially
	// since there can be negative offsets.
	// @method getPixelOrigin(): Point
	// Returns the projected pixel coordinates of the top left point of
	// the map layer (useful in custom layer and overlay implementations).
	getPixelOrigin() {
		this._checkIfLoaded();
		return this._pixelOrigin;
	}

	// @method getPixelWorldBounds(zoom?: Number): Bounds
	// Returns the world's bounds in pixel coordinates for zoom level `zoom`.
	// If `zoom` is omitted, the map's current zoom level is used.
	getPixelWorldBounds(zoom) {
		return this.options.crs.getProjectedBounds(zoom ?? this.getZoom());
	}

	// @section Other Methods

	// @method getPane(pane: String|HTMLElement): HTMLElement
	// Returns a [map pane](#map-pane), given its name or its HTML element (its identity).
	getPane(pane) {
		return typeof pane === 'string' ? this._panes[pane] : pane;
	}

	// @method getPanes(): Object
	// Returns a plain object containing the names of all [panes](#map-pane) as keys and
	// the panes as values.
	getPanes() {
		return this._panes;
	}

	// @method getContainer: HTMLElement
	// Returns the HTML element that contains the map.
	getContainer() {
		return this._container;
	}


	// @section Conversion Methods

	// @method getZoomScale(toZoom: Number, fromZoom?: Number): Number
	// Returns the scale factor to be applied to a map transition from zoom level
	// `fromZoom` to `toZoom`. Used internally to help with zoom animations.
	getZoomScale(toZoom, fromZoom) {
		// TODO replace with universal implementation after refactoring projections
		const crs = this.options.crs;
		fromZoom ??= this._zoom;
		return crs.scale(toZoom) / crs.scale(fromZoom);
	}

	// @method getScaleZoom(scale: Number, fromZoom?: Number): Number
	// Returns the zoom level that the map would end up at, if it is at `fromZoom`
	// level and everything is scaled by a factor of `scale`. Inverse of
	// [`getZoomScale`](#map-getZoomScale).
	getScaleZoom(scale, fromZoom) {
		const crs = this.options.crs;
		fromZoom ??= this._zoom;
		const zoom = crs.zoom(scale * crs.scale(fromZoom));
		return isNaN(zoom) ? Infinity : zoom;
	}

	// @method project(latlng: LatLng, zoom?: Number): Point
	// Projects a geographical coordinate `LatLng` according to the projection
	// of the map's CRS, then scales it according to `zoom` and the CRS's
	// `Transformation`. The result is pixel coordinate relative to
	// the CRS origin.
	project(latlng, zoom) {
		zoom ??= this._zoom;
		return this.options.crs.latLngToPoint(new LatLng(latlng), zoom);
	}

	// @method unproject(point: Point, zoom?: Number): LatLng
	// Inverse of [`project`](#map-project).
	unproject(point, zoom) {
		zoom ??= this._zoom;
		return this.options.crs.pointToLatLng(new Point(point), zoom);
	}

	// @method layerPointToLatLng(point: Point): LatLng
	// Given a pixel coordinate relative to the [origin pixel](#map-getpixelorigin),
	// returns the corresponding geographical coordinate (for the current zoom level).
	layerPointToLatLng(point) {
		const projectedPoint = new Point(point).add(this.getPixelOrigin());
		return this.unproject(projectedPoint);
	}

	// @method latLngToLayerPoint(latlng: LatLng): Point
	// Given a geographical coordinate, returns the corresponding pixel coordinate
	// relative to the [origin pixel](#map-getpixelorigin).
	latLngToLayerPoint(latlng) {
		const projectedPoint = this.project(new LatLng(latlng))._round();
		return projectedPoint._subtract(this.getPixelOrigin());
	}

	// @method wrapLatLng(latlng: LatLng): LatLng
	// Returns a `LatLng` where `lat` and `lng` has been wrapped according to the
	// map's CRS's `wrapLat` and `wrapLng` properties, if they are outside the
	// CRS's bounds.
	// By default this means longitude is wrapped around the dateline so its
	// value is between -180 and +180 degrees.
	wrapLatLng(latlng) {
		return this.options.crs.wrapLatLng(new LatLng(latlng));
	}

	// @method wrapLatLngBounds(bounds: LatLngBounds): LatLngBounds
	// Returns a `LatLngBounds` with the same size as the given one, ensuring that
	// its center is within the CRS's bounds.
	// By default this means the center longitude is wrapped around the dateline so its
	// value is between -180 and +180 degrees, and the majority of the bounds
	// overlaps the CRS's bounds.
	wrapLatLngBounds(bounds) {
		return this.options.crs.wrapLatLngBounds(new LatLngBounds(bounds));
	}

	// @method distance(latlng1: LatLng, latlng2: LatLng): Number
	// Returns the distance between two geographical coordinates according to
	// the map's CRS. By default this measures distance in meters.
	distance(latlng1, latlng2) {
		return this.options.crs.distance(new LatLng(latlng1), new LatLng(latlng2));
	}

	// @method containerPointToLayerPoint(point: Point): Point
	// Given a pixel coordinate relative to the map container, returns the corresponding
	// pixel coordinate relative to the [origin pixel](#map-getpixelorigin).
	containerPointToLayerPoint(point) { // (Point)
		return new Point(point).subtract(this._getMapPanePos());
	}

	// @method layerPointToContainerPoint(point: Point): Point
	// Given a pixel coordinate relative to the [origin pixel](#map-getpixelorigin),
	// returns the corresponding pixel coordinate relative to the map container.
	layerPointToContainerPoint(point) { // (Point)
		return new Point(point).add(this._getMapPanePos());
	}

	// @method containerPointToLatLng(point: Point): LatLng
	// Given a pixel coordinate relative to the map container, returns
	// the corresponding geographical coordinate (for the current zoom level).
	containerPointToLatLng(point) {
		const layerPoint = this.containerPointToLayerPoint(new Point(point));
		return this.layerPointToLatLng(layerPoint);
	}

	// @method latLngToContainerPoint(latlng: LatLng): Point
	// Given a geographical coordinate, returns the corresponding pixel coordinate
	// relative to the map container.
	latLngToContainerPoint(latlng) {
		return this.layerPointToContainerPoint(this.latLngToLayerPoint(new LatLng(latlng)));
	}

	// @method pointerEventToContainerPoint(ev: PointerEvent): Point
	// Given a PointerEvent object, returns the pixel coordinate relative to the
	// map container where the event took place.
	pointerEventToContainerPoint(e) {
		return DomEvent.getPointerPosition(e, this._container);
	}

	// @method pointerEventToLayerPoint(ev: PointerEvent): Point
	// Given a PointerEvent object, returns the pixel coordinate relative to
	// the [origin pixel](#map-getpixelorigin) where the event took place.
	pointerEventToLayerPoint(e) {
		return this.containerPointToLayerPoint(this.pointerEventToContainerPoint(e));
	}

	// @method pointerEventToLatLng(ev: PointerEvent): LatLng
	// Given a PointerEvent object, returns geographical coordinate where the
	// event took place.
	pointerEventToLatLng(e) { // (PointerEvent)
		return this.layerPointToLatLng(this.pointerEventToLayerPoint(e));
	}


	// map initialization methods

	_initContainer(id) {
		const container = this._container = DomUtil.get(id);

		if (!container) {
			throw new Error('Map container not found.');
		} else if (container._maplair_id) {
			throw new Error('Map container is already initialized.');
		}

		PointerEvents.enablePointerDetection(container);
		DomEvent.on(container, 'scroll', this._onScroll, this);
		this._containerId = Util.stamp(container);

	}

	_initLayout() {
		const container = this._container;

		this._fadeAnimated = this.options.fadeAnimation;

		const classes = ['maplair-container', 'maplair-touch'];

		if (Browser.retina) { classes.push('maplair-retina'); }
		if (Browser.safari) { classes.push('maplair-safari'); }
		if (this._fadeAnimated) { classes.push('maplair-fade-anim'); }

		container.classList.add(...classes);

		const {position} = getComputedStyle(container);

		if (position !== 'absolute' && position !== 'relative' && position !== 'fixed' && position !== 'sticky') {
			container.style.position = 'relative';
		}

		this._initPanes();

		if (this._initControlPos) {
			this._initControlPos();
		}
	}

	_initPanes() {
		const panes = this._panes = {};
		this._paneRenderers = {};

		// @section
		//
		// Panes are DOM elements used to control the ordering of layers on the map. You
		// can access panes with [`map.getPane`](#map-getpane) or
		// [`map.getPanes`](#map-getpanes) methods. New panes can be created with the
		// [`map.createPane`](#map-createpane) method.
		//
		// Every map has the following default panes that differ only in zIndex.
		//
		// @pane mapPane: HTMLElement = 'auto'
		// Pane that contains all other map panes

		this._mapPane = this.createPane('mapPane', this._container);
		DomUtil.setPosition(this._mapPane, new Point(0, 0));

		// @pane tilePane: HTMLElement = 200
		// Pane for `GridLayer`s and `TileLayer`s
		this.createPane('tilePane');
		// @pane overlayPane: HTMLElement = 400
		// Pane for vectors (`Path`s, like `Polyline`s and `Polygon`s), `ImageOverlay`s and `VideoOverlay`s
		this.createPane('overlayPane');
		// @pane shadowPane: HTMLElement = 500
		// Pane for overlay shadows (e.g. `Marker` shadows)
		this.createPane('shadowPane');
		// @pane markerPane: HTMLElement = 600
		// Pane for `Icon`s of `Marker`s
		this.createPane('markerPane');
		// @pane tooltipPane: HTMLElement = 650
		// Pane for `Tooltip`s.
		this.createPane('tooltipPane');
		// @pane popupPane: HTMLElement = 700
		// Pane for `Popup`s.
		this.createPane('popupPane');

		if (!this.options.markerZoomAnimation) {
			panes.markerPane.classList.add('maplair-zoom-hide');
			panes.shadowPane.classList.add('maplair-zoom-hide');
		}
	}


	// private methods that modify map state

	// @section Map state change events
	_resetView(center, zoom, noMoveStart) {
		DomUtil.setPosition(this._mapPane, new Point(0, 0));

		const loading = !this._loaded;
		this._loaded = true;
		zoom = this._limitZoom(zoom);

		this.fire('viewprereset');

		const zoomChanged = this._zoom !== zoom;
		this
			._moveStart(zoomChanged, noMoveStart)
			._move(center, zoom)
			._moveEnd(zoomChanged);

		// @event viewreset: Event
		// Fired when the map needs to redraw its content (this usually happens
		// on map zoom or load). Very useful for creating custom overlays.
		this.fire('viewreset');

		// @event load: Event
		// Fired when the map is initialized (when its center and zoom are set
		// for the first time).
		if (loading) {
			this.fire('load');
		}
	}

	_moveStart(zoomChanged, noMoveStart) {
		// @event zoomstart: Event
		// Fired when the map zoom is about to change (e.g. before zoom animation).
		// @event movestart: Event
		// Fired when the view of the map starts changing (e.g. user starts dragging the map).
		if (zoomChanged) {
			this.fire('zoomstart');
		}
		if (!noMoveStart) {
			this.fire('movestart');
		}
		return this;
	}

	_move(center, zoom, data, suppressEvent) {
		if (zoom === undefined) {
			zoom = this._zoom;
		}
		const zoomChanged = this._zoom !== zoom;

		this._zoom = zoom;
		this._lastCenter = center;
		this._pixelOrigin = this._getNewPixelOrigin(center);

		if (!suppressEvent) {
			// @event zoom: Event
			// Fired repeatedly during any change in zoom level,
			// including zoom and fly animations.
			if (zoomChanged || (data?.pinch)) {	// Always fire 'zoom' if pinching because #3530
				this.fire('zoom', data);
			}

			// @event move: Event
			// Fired repeatedly during any movement of the map,
			// including pan and fly animations.
			this.fire('move', data);
		} else if (data?.pinch) {	// Always fire 'zoom' if pinching because #3530
			this.fire('zoom', data);
		}
		return this;
	}

	_moveEnd(zoomChanged) {
		// @event zoomend: Event
		// Fired when the map zoom changed, after any animations.
		if (zoomChanged) {
			this.fire('zoomend');
		}

		// @event moveend: Event
		// Fired when the center of the map stops changing
		// (e.g. user stopped dragging the map or after non-centered zoom).
		return this.fire('moveend');
	}

	_stop() {
		cancelAnimationFrame(this._flyToFrame);
		cancelAnimationFrame(this._zoomAnimFrame);
		this._panAnim?.stop();
		return this;
	}

	_rawPanBy(offset) {
		DomUtil.setPosition(this._mapPane, this._getMapPanePos().subtract(offset));
	}

	_getZoomSpan() {
		return this.getMaxZoom() - this.getMinZoom();
	}

	_panInsideMaxBounds() {
		if (!this._enforcingBounds) {
			this.panInsideBounds(this.options.maxBounds);
		}
	}

	_checkIfLoaded() {
		if (!this._loaded) {
			throw new Error('Set map center and zoom first.');
		}
	}

	// DOM event handling

	// @section Interaction events
	_initEvents(remove) {
		this._targets = {};
		this._targets[Util.stamp(this._container)] = this;

		const onOff = remove ? DomEvent.off : DomEvent.on;

		// @event click: PointerEvent
		// Fired when the user clicks (or taps) the map.
		// @event dblclick: PointerEvent
		// Fired when the user double-clicks (or double-taps) the map.
		// @event pointerdown: PointerEvent
		// Fired when the user pushes the pointer on the map.
		// @event pointerup: PointerEvent
		// Fired when the user releases the pointer on the map.
		// @event pointerover: PointerEvent
		// Fired when the pointer enters the map.
		// @event pointerout: PointerEvent
		// Fired when the pointer leaves the map.
		// @event pointermove: PointerEvent
		// Fired while the pointer moves over the map.
		// @event contextmenu: PointerEvent
		// Fired when the user pushes the right mouse button on the map, prevents
		// default browser context menu from showing if there are listeners on
		// this event. Also fired on mobile when the user holds a single touch
		// for a second (also called long press).
		// @event keypress: KeyboardEvent
		// Fired when the user presses a key from the keyboard that produces a character value while the map is focused.
		// @event keydown: KeyboardEvent
		// Fired when the user presses a key from the keyboard while the map is focused. Unlike the `keypress` event,
		// the `keydown` event is fired for keys that produce a character value and for keys
		// that do not produce a character value.
		// @event keyup: KeyboardEvent
		// Fired when the user releases a key from the keyboard while the map is focused.
		onOff(this._container, 'click dblclick pointerdown pointerup ' +
			'pointerover pointerout pointermove contextmenu keypress keydown keyup', this._handleDOMEvent, this);

		if (this.options.trackResize) {
			if (!remove) {
				if (!this._resizeObserver) {
					this._resizeObserver = new ResizeObserver(this._onResize.bind(this));
				}
				this._resizeObserver.observe(this._container);
			} else {
				this._resizeObserver.disconnect();
			}
		}

		if (this.options.transform3DLimit) {
			(remove ? this.off : this.on).call(this, 'moveend', this._onMoveEnd);
		}
	}

	_onResize() {
		cancelAnimationFrame(this._resizeRequest);
		this._resizeRequest = requestAnimationFrame(() => { this.invalidateSize({debounceMoveend: true}); });
	}

	_onScroll() {
		this._container.scrollTop  = 0;
		this._container.scrollLeft = 0;
	}

	_onMoveEnd() {
		const pos = this._getMapPanePos();
		if (Math.max(Math.abs(pos.x), Math.abs(pos.y)) >= this.options.transform3DLimit) {
			// https://bugzilla.mozilla.org/show_bug.cgi?id=1203873 but Webkit also have
			// a pixel offset on very high values, see: https://jsfiddle.net/dg6r5hhb/
			this._resetView(this.getCenter(), this.getZoom());
		}
	}

	_findEventTargets(e, type) {
		let targets = [],
		target,
		src = e.target || e.srcElement,
		dragging = false;
		const isHover = type === 'pointerout' || type === 'pointerover';

		while (src) {
			target = this._targets[Util.stamp(src)];
			if (target && (type === 'click' || type === 'preclick') && this._draggableMoved(target)) {
				// Prevent firing click after you just dragged an object.
				dragging = true;
				break;
			}
			if (target && target.listens(type, true)) {
				if (isHover && !DomEvent.isExternalTarget(src, e)) { break; }
				targets.push(target);
				if (isHover) { break; }
			}
			if (src === this._container) { break; }
			src = src.parentNode;
		}
		if (!targets.length && !dragging && !isHover && this.listens(type, true)) {
			targets = [this];
		}
		return targets;
	}

	_isClickDisabled(el) {
		while (el && el !== this._container) {
			if (el['_maplair_disable_click'] || !el.parentNode) { return true; }
			el = el.parentNode;
		}
	}

	_handleDOMEvent(e) {
		const el = e.target ?? e.srcElement;
		if (!this._loaded || el['_maplair_disable_events'] || e.type === 'click' && this._isClickDisabled(el)) {
			return;
		}

		const type = e.type;

		if (type === 'pointerdown') {
			// prevents outline when clicking on keyboard-focusable element
			DomUtil.preventOutline(el);
		}

		this._fireDOMEvent(e, type);
	};

	static _pointerEvents = ['click', 'dblclick', 'pointerover', 'pointerout', 'contextmenu'];

	_fireDOMEvent(e, type, canvasTargets) {

		if (type === 'click') {
			// Fire a synthetic 'preclick' event which propagates up (mainly for closing popups).
			// @event preclick: PointerEvent
			// Fired before pointer click on the map (sometimes useful when you
			// want something to happen on click before any existing click
			// handlers start running).
			this._fireDOMEvent(e, 'preclick', canvasTargets);
		}

		// Find the layer the event is propagating from and its parents.
		let targets = this._findEventTargets(e, type);

		if (canvasTargets) {
			// pick only targets with listeners
			const filtered = canvasTargets.filter(t => t.listens(type, true));
			targets = [...filtered, ...targets];
		}

		if (!targets.length) { return; }

		if (type === 'contextmenu') {
			e.preventDefault();
		}

		const target = targets[0];
		const data = {
			originalEvent: e
		};

		if (e.type !== 'keypress' && e.type !== 'keydown' && e.type !== 'keyup') {
			const isMarker = target.getLatLng && (!target._pxRadius || target._pxRadius <= 10);
			data.containerPoint = isMarker ?
				this.latLngToContainerPoint(target.getLatLng()) : this.pointerEventToContainerPoint(e);
			data.layerPoint = this.containerPointToLayerPoint(data.containerPoint);
			data.latlng = isMarker ? target.getLatLng() : this.layerPointToLatLng(data.layerPoint);
		}

		for (const t of targets) {
			t.fire(type, data, true);
			if (data.originalEvent._stopped ||
				(t.options.bubblingPointerEvents === false && MaplairMap._pointerEvents.includes(type))) { return; }
		}
	}

	_draggableMoved(obj) {
		obj = obj.dragging?.enabled() ? obj : this;
		return obj.dragging?.moved() || this.boxZoom?.moved();
	}

	_clearHandlers() {
		for (const handler of this._handlers) {
			handler.disable();
		}
	}

	// @section Other Methods

	// @method whenReady(fn: Function, context?: Object): this
	// Runs the given function `fn` when the map gets initialized with
	// a view (center and zoom) and at least one layer, or immediately
	// if it's already initialized, optionally passing a function context.
	whenReady(callback, context) {
		if (this._loaded) {
			callback.call(context || this, {target: this});
		} else {
			this.on('load', callback, context);
		}
		return this;
	}


	// private methods for getting map state

	_getMapPanePos() {
		return DomUtil.getPosition(this._mapPane);
	}

	_moved() {
		const pos = this._getMapPanePos();
		return pos && !pos.equals([0, 0]);
	}

	_getTopLeftPoint(center, zoom) {
		const pixelOrigin = center && zoom !== undefined ?
			this._getNewPixelOrigin(center, zoom) :
			this.getPixelOrigin();
		return pixelOrigin.subtract(this._getMapPanePos());
	}

	_getNewPixelOrigin(center, zoom) {
		const viewHalf = this.getSize()._divideBy(2);
		return this.project(center, zoom)._subtract(viewHalf)._add(this._getMapPanePos())._round();
	}

	_latLngToNewLayerPoint(latlng, zoom, center) {
		const topLeft = this._getNewPixelOrigin(center, zoom);
		return this.project(latlng, zoom)._subtract(topLeft);
	}

	_latLngBoundsToNewLayerBounds(latLngBounds, zoom, center) {
		const topLeft = this._getNewPixelOrigin(center, zoom);
		return new Bounds([
			this.project(latLngBounds.getSouthWest(), zoom)._subtract(topLeft),
			this.project(latLngBounds.getNorthWest(), zoom)._subtract(topLeft),
			this.project(latLngBounds.getSouthEast(), zoom)._subtract(topLeft),
			this.project(latLngBounds.getNorthEast(), zoom)._subtract(topLeft)
		]);
	}

	// layer point of the current center
	_getCenterLayerPoint() {
		return this.containerPointToLayerPoint(this.getSize()._divideBy(2));
	}

	// offset of the specified place to the current center in pixels
	_getCenterOffset(latlng) {
		return this.latLngToLayerPoint(latlng).subtract(this._getCenterLayerPoint());
	}

	// adjust center for view to get inside bounds
	_limitCenter(center, zoom, bounds) {

		if (!bounds) { return center; }

		const centerPoint = this.project(center, zoom),
		viewHalf = this.getSize().divideBy(2),
		viewBounds = new Bounds(centerPoint.subtract(viewHalf), centerPoint.add(viewHalf)),
		offset = this._getBoundsOffset(viewBounds, bounds, zoom);

		// If offset is less than a pixel, ignore.
		// This prevents unstable projections from getting into
		// an infinite loop of tiny offsets.
		if (Math.abs(offset.x) <= 1 && Math.abs(offset.y) <= 1) {
			return center;
		}

		return this.unproject(centerPoint.add(offset), zoom);
	}

	// adjust offset for view to get inside bounds
	_limitOffset(offset, bounds) {
		if (!bounds) { return offset; }

		const viewBounds = this.getPixelBounds(),
		newBounds = new Bounds(viewBounds.min.add(offset), viewBounds.max.add(offset));

		return offset.add(this._getBoundsOffset(newBounds, bounds));
	}

	// returns offset needed for pxBounds to get inside maxBounds at a specified zoom
	_getBoundsOffset(pxBounds, maxBounds, zoom) {
		const projectedMaxBounds = new Bounds(
			this.project(maxBounds.getNorthEast(), zoom),
			this.project(maxBounds.getSouthWest(), zoom)
		),
		minOffset = projectedMaxBounds.min.subtract(pxBounds.min),
		maxOffset = projectedMaxBounds.max.subtract(pxBounds.max),

		dx = this._rebound(minOffset.x, -maxOffset.x),
		dy = this._rebound(minOffset.y, -maxOffset.y);

		return new Point(dx, dy);
	}

	_rebound(left, right) {
		return left + right > 0 ?
			Math.round(left - right) / 2 :
			Math.max(0, Math.ceil(left)) - Math.max(0, Math.floor(right));
	}

	_limitZoom(zoom) {
		const min = this.getMinZoom(),
		max = this.getMaxZoom(),
		snap = this.options.zoomSnap;
		if (snap) {
			zoom = Math.round(zoom / snap) * snap;
		}
		return Math.max(min, Math.min(max, zoom));
	}

	_onPanTransitionStep() {
		this.fire('move');
	}

	_onPanTransitionEnd() {
		this._mapPane.classList.remove('maplair-pan-anim');
		this.fire('moveend');
	}

	_tryAnimatedPan(center, options) {
		// difference between the new and current centers in pixels
		const offset = this._getCenterOffset(center)._trunc();

		// don't animate too far unless animate: true specified in options
		if (options?.animate !== true && !this.getSize().contains(offset)) { return false; }

		this.panBy(offset, options);

		return true;
	}

	_createAnimProxy() {
		this._proxy = DomUtil.create('div', 'maplair-proxy maplair-zoom-animated');
		this._panes.mapPane.appendChild(this._proxy);

		this.on('zoomanim', this._animateProxyZoom, this);
		this.on('load moveend', this._animMoveEnd, this);

		DomEvent.on(this._proxy, 'transitionend', this._catchTransitionEnd, this);
	}

	_animateProxyZoom(e) {
		const transform = this._proxy.style.transform;

		DomUtil.setTransform(
			this._proxy,
			this.project(e.center, e.zoom),
			this.getZoomScale(e.zoom, 1),
		);

		// workaround for case when transform is the same and so transitionend event is not fired
		if (transform === this._proxy.style.transform && this._animatingZoom) {
			this._onZoomTransitionEnd();
		}
	}

	_animMoveEnd() {
		const c = this.getCenter();
		const z = this.getZoom();



		DomUtil.setTransform(
			this._proxy,
			this.project(c, z),
			this.getZoomScale(z, 1),
		);
	}

	_destroyAnimProxy() {
		// Just make sure this method is safe to call from anywhere, without knowledge
		// of whether the animation proxy was created in the first place.
		if (this._proxy) {
			DomEvent.off(this._proxy, 'transitionend', this._catchTransitionEnd, this);

			this._proxy.remove();
			this.off('zoomanim', this._animateProxyZoom, this);
			this.off('load moveend', this._animMoveEnd, this);

			delete this._proxy;
		}
	}

	_catchTransitionEnd(e) {
		if (this._animatingZoom && e.propertyName.includes('transform')) {
			this._onZoomTransitionEnd();
		}
	}

	_nothingToAnimate() {
		return !this._container.getElementsByClassName('maplair-zoom-animated').length;
	}

	_tryAnimatedZoom(center, zoom, options) {

		if (this._animatingZoom) { return true; }

		options ??= {};

		// don't animate if disabled, not supported or zoom difference is too large
		if (!this._zoomAnimated || options.animate === false || this._nothingToAnimate() ||
		        Math.abs(zoom - this._zoom) > this.options.zoomAnimationThreshold) { return false; }

		// offset is the pixel coords of the zoom origin relative to the current center
		const scale = this.getZoomScale(zoom),
		offset = this._getCenterOffset(center)._divideBy(1 - 1 / scale);

		// don't animate if the zoom origin isn't within one screen from the current center, unless forced
		if (options.animate !== true && !this.getSize().contains(offset)) { return false; }

		this._zoomAnimFrame = requestAnimationFrame(() => {
			this._zoomAnimFrame = null;
			this
				._moveStart(true, options.noMoveStart ?? false)
				._animateZoom(center, zoom, true);
		});

		return true;
	}

	_animateZoom(center, zoom, startAnim, noUpdate) {
		if (!this._mapPane) { return; }

		if (startAnim) {
			this._animatingZoom = true;

			// remember what center/zoom to set after animation
			this._animateToCenter = center;
			this._animateToZoom = zoom;

			this._mapPane.classList.add('maplair-zoom-anim');
		}

		// @section Other Events
		// @event zoomanim: ZoomAnimEvent
		// Fired at least once per zoom animation. For continuous zoom, like pinch zooming, fired once per frame during zoom.
		this.fire('zoomanim', {
			center,
			zoom,
			noUpdate
		});

		if (!this._tempFireZoomEvent) {
			this._tempFireZoomEvent = this._zoom !== this._animateToZoom;
		}

		this._move(this._animateToCenter, this._animateToZoom, undefined, true);

		// Work around webkit not firing 'transitionend', see https://github.com/Leaflet/Leaflet/issues/3689, 2693
		this._transitionEndTimer = setTimeout(this._onZoomTransitionEnd.bind(this), 250);
	}

	_onZoomTransitionEnd() {
		if (!this._animatingZoom) { return; }

		this._mapPane?.classList.remove('maplair-zoom-anim');

		this._animatingZoom = false;

		this._move(this._animateToCenter, this._animateToZoom, undefined, true);

		if (this._tempFireZoomEvent) {
			this.fire('zoom');
		}
		delete this._tempFireZoomEvent;

		this.fire('move');

		this._moveEnd(true);
	}
}

// The default features (layers, popups, tooltips, vector renderers, controls
// and interaction handlers) come with every map: they are registered here
// rather than by the entry module, so that they are included whenever
// `MaplairMap` is, while bundlers can still drop other features (e.g. GeoJSON,
// the layers and scale controls) when an application doesn't use them.
//
// Possible tree-shaking optimization: popups, tooltips and the vector renderers
// (DivOverlay, Popup, Tooltip, RendererSupport; about 25 kB of a minimal map's
// 100 kB minified) could become optional by registering themselves when their
// module is imported (without importing this module, to avoid circular imports)
// instead of here. But then `layer.bindPopup()`/`bindTooltip()` and the map's
// popup/tooltip methods would only exist in production bundles of apps that
// actually use the `Popup`/`Tooltip` classes (bundlers drop an unused
// `import {Tooltip}`), while they would always work in development builds.
for (const feature of [
	AttributionControl,
	Control,
	DivOverlay,
	Layer,
	BoxZoomHandler,
	DoubleClickZoomHandler,
	DragHandler,
	KeyboardHandler,
	PinchZoomHandler,
	ScrollWheelZoomHandler,
	TapHoldHandler,
	Popup,
	Tooltip,
	RendererSupport,
	ZoomControl
]) {
	feature.register(MaplairMap);
}

export const Map = MaplairMap;