UNPKG

leaflet

Version:

JavaScript library for mobile-friendly interactive maps

1,507 lines (1,233 loc) 57.8 kB
import * as Util from '../core/Util.js'; import {Evented} from '../core/Events.js'; import {EPSG3857} from '../geo/crs/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'; /* * @class Map * @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 Map('map', { * center: [51.505, -0.09], * zoom: 13 * }); * ``` * */ // @section // @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`. // // @alternative // @constructor LeafletMap(id: String, options?: LeafletMap options) // Instantiates a map object given the DOM ID of a `<div>` element // and optionally an object literal with `LeafletMap options`. // // @alternative // @constructor LeafletMap(el: HTMLElement, options?: LeafletMap options) // Instantiates a map object given an instance of a `<div>` HTML element // and optionally an object literal with `LeafletMap options`. export class Map extends Evented { static { this.setDefaultOptions({ // @section Map State Options // @option crs: 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 = true // Whether the map zoom animation is enabled. By default it's enabled // in all browsers that support CSS Transitions except Android. zoomAnimation: true, // @option zoomAnimationThreshold: Number = 4 // Won't animate zoom if the zoom difference exceeds this value. zoomAnimationThreshold: 4, // @option fadeAnimation: Boolean = true // Whether the tile fade animation is enabled. By default it's enabled // in all browsers that support CSS Transitions except Android. fadeAnimation: true, // @option markerZoomAnimation: Boolean = true // Whether markers animate their zoom with the zoom animation, if disabled // they will disappear for the length of the animation. By default it's // enabled in all browsers that support CSS Transitions except Android. markerZoomAnimation: true, // @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 }); } initialize(id, options) { // (HTMLElement or String, Object) 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(); // don't animate on browsers without hardware-accelerated transitions or old Android 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 animate: false specified if (options.animate !== false) { this._mapPane.classList.add('leaflet-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 (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.000000001 ? -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._leaflet_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._leaflet_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._leaflet_id) { throw new Error('Map container is being reused by another instance'); } delete this._container._leaflet_id; delete this._containerId; if (this._locationWatchId !== undefined) { this.stopLocate(); } this._stop(); 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 = `leaflet-pane${name ? ` leaflet-${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 this._lastCenter.clone(); } 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 this._size.clone(); } // @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 pointerEventToLayerPoint(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._leaflet_id) { throw new Error('Map container is already initialized.'); } DomEvent.on(container, 'scroll', this._onScroll, this); this._containerId = Util.stamp(container); PointerEvents.enablePointerDetection(); } _initLayout() { const container = this._container; this._fadeAnimated = this.options.fadeAnimation; const classes = ['leaflet-container', 'leaflet-touch']; if (Browser.retina) { classes.push('leaflet-retina'); } if (Browser.safari) { classes.push('leaflet-safari'); } if (this._fadeAnimated) { classes.push('leaflet-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('leaflet-zoom-hide'); panes.shadowPane.classList.add('leaflet-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, supressEvent) { if (zoom === undefined) { zoom = this._zoom; } const zoomChanged = this._zoom !== zoom; this._zoom = zoom; this._lastCenter = center; this._pixelOrigin = this._getNewPixelOrigin(center); if (!supressEvent) { // @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); 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['_leaflet_disable_click'] || !el.parentNode) { return true; } el = el.parentNode; } } _handleDOMEvent(e) { const el = e.target ?? e.srcElement; if (!this._loaded || el['_leaflet_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.concat(targets); } if (!targets.length) { return; } if (type === 'contextmenu') { DomEvent.preventDefault(e); } const target = targets[0]; const data = { originalEvent: e }; if (e.type !== 'keypress' && e.type !== 'keydown' && e.type !== 'keyup') { const isMarker = target.getLatLng && (!target._radius || target._radius <= 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 && Map._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 &&