UNPKG

leaflet

Version:

JavaScript library for mobile-friendly interactive maps

990 lines (821 loc) • 28.6 kB
import {Layer} from '../Layer.js'; import * as Util from '../../core/Util.js'; import * as DomUtil from '../../dom/DomUtil.js'; import {Point} from '../../geometry/Point.js'; import {Bounds} from '../../geometry/Bounds.js'; import {LatLngBounds} from '../../geo/LatLngBounds.js'; /** @import {Defaults, GridLayerDefaults, GridLayerEventMap, GridLayerOptions, LatLngBoundsExpression, LeafletEvent, * MapListeners, TileErrorEvent, TileEvent, ViewPreResetEvent, WorldCopyJumpEvent, ZoomAnimEvent} * from '../../types.js' */ /** @import {LatLng} from '../../geo/LatLng.js' */ /** @import {LeafletMap} from '../../map/Map.js' */ const defaultOptions = { /** * Width and height of tiles in the grid. Use a number if width * and height are equal, or `Point(width, height)` otherwise. * @type {number | Point} */ tileSize: 256, /** Opacity of the tiles. Can be used in the `createTile()` function. */ opacity: 1, /** Load new tiles only when panning ends. */ updateWhenIdle: false, /** * By default, a smooth zoom animation (during a [pinch zoom](#map-pinchzoom) or a [`flyTo()`](#map-flyto)) * will update grid layers every integer zoom level. Setting this option to `false` will * update the grid layer only when the smooth animation ends. */ updateWhenZooming: true, /** Tiles will not update more than once every `updateInterval` milliseconds when panning. */ updateInterval: 200, /** The explicit zIndex of the tile layer. */ zIndex: 1, /** * If set, tiles will only be loaded inside the set `LatLngBounds`. * @type {LatLngBoundsExpression | null} */ bounds: null, /** The minimum zoom level down to which this layer will be displayed (inclusive). */ minZoom: 0, /** * The maximum zoom level up to which this layer will be displayed (inclusive). * @type {number | undefined} */ maxZoom: undefined, /** * Maximum zoom number the tile source has available. If it is specified, the tiles on all zoom levels * higher than `maxNativeZoom` will be loaded from `maxNativeZoom` level and auto-scaled. * @type {number | undefined} */ maxNativeZoom: undefined, /** * Minimum zoom number the tile source has available. If it is specified, the tiles on all zoom levels * lower than `minNativeZoom` will be loaded from `minNativeZoom` level and auto-scaled. * @type {number | undefined} */ minNativeZoom: undefined, /** * Whether the layer is wrapped around the antimeridian. If `true`, the GridLayer will only be displayed once * at low zoom levels. Has no effect when the [map CRS](#map-crs) doesn't wrap around. Can be used in combination * with [`bounds`](#gridlayer-bounds) to prevent requesting tiles outside the CRS limits. */ noWrap: false, /** `Map pane` where the grid layer will be added. */ pane: 'tilePane', /** A custom class name to assign to the tile layer. Empty by default. */ className: '', /** When panning the map, keep this many rows and columns of tiles before unloading them. */ keepBuffer: 2 }; /** @typedef {typeof defaultOptions} GridLayerDefaultsValues */ /** * @typedef {Object} GridLayerEvents * @property {LeafletEvent} loading Fired when the grid layer starts loading tiles. * @property {TileEvent} tileunload Fired when a tile is removed (e.g. when a tile goes off the screen). * @property {TileEvent} tileloadstart Fired when a tile is requested and starts loading. * @property {TileErrorEvent} tileerror Fired when there is an error loading a tile. * @property {TileEvent} tileload Fired when a tile loads. * @property {LeafletEvent} load Fired when the grid layer loaded all visible tiles. */ /** @typedef {{el: HTMLElement, coords: Coords, current: boolean, retain?: boolean, active?: boolean, loaded?: boolean}} GridTile */ /** @typedef {{el: HTMLElement, origin: Point, zoom: number}} GridLevel */ /** * A `Point` with the tile's zoom level as `z`, as `createTile` and `getTileUrl` get it. * @typedef {Point & {z: number}} Coords */ /** * The callback `createTile` gets: call it once the tile is ready, with an error if it failed to load. * @typedef {(error?: unknown, tile?: HTMLElement) => void} DoneCallback */ /** * @typedef {Object} TileEventProps * @property {HTMLElement} tile The tile element (image). * @property {Coords} coords Point object with the tile's `x`, `y`, and `z` (zoom level) coordinates. */ /** * @typedef {Object} TileErrorEventProps * @property {unknown} error Error passed to the tile's `done()` callback. */ /** * Generic class for handling a tiled grid of HTML elements. This is the base class for all tile layers and * replaces `TileLayer.Canvas`. GridLayer can be extended to create a tiled grid of HTML elements like * `<canvas>`, `<img>` or `<div>`. GridLayer will handle creating and animating these DOM elements for you. * @example <caption>Synchronous usage</caption> * To create a custom layer, extend GridLayer and implement the `createTile()` method, which will be passed a `Point` object with the `x`, `y`, and `z` (zoom level) coordinates to draw your tile. * * ```js * class CanvasLayer extends GridLayer { * createTile(coords) { * // create a <canvas> element for drawing * const tile = DomUtil.create('canvas', 'leaflet-tile'); * * // setup tile width and height according to the options * const size = this.getTileSize(); * tile.width = size.x; * tile.height = size.y; * * // get a canvas context and draw something on it using coords.x, coords.y and coords.z * const ctx = tile.getContext('2d'); * * // return the tile so it can be rendered on screen * return tile; * } * } * ``` * @example <caption>Asynchronous usage</caption> * Tile creation can also be asynchronous, this is useful when using a third-party drawing library. Once the tile is finished drawing it can be passed to the `done()` callback. * * ```js * class CanvasLayer extends GridLayer { * createTile(coords, done) { * const error; * * // create a <canvas> element for drawing * const tile = DomUtil.create('canvas', 'leaflet-tile'); * * // setup tile width and height according to the options * const size = this.getTileSize(); * tile.width = size.x; * tile.height = size.y; * * // draw something asynchronously and pass the tile to the done() callback * setTimeout(function() { * done(error, tile); * }, 1000); * * return tile; * } * } * ``` * @categoryDescription Extension methods * Layers extending `GridLayer` shall reimplement the following method. * @template {GridLayerOptions} [O=GridLayerOptions] * @template {GridLayerEventMap} [E=GridLayerEventMap] * @extends {Layer<O & GridLayerDefaults, E>} */ export class GridLayer extends Layer { static initHooks = true; /** @type {Defaults<GridLayerOptions>} */ static defaultOptions = defaultOptions; /** * Creates a new instance of GridLayer with the supplied options. * @param {NoInfer<Partial<O>>} [options] */ constructor(options) { super(); Util.setOptions(this, options); this.callInitHooks(); } /** @param {LeafletMap} map */ onAdd(map) { this._initContainer(); /** @type {{[zoom: string]: GridLevel}} */ this._levels = {}; /** @type {{[key: string]: GridTile}} */ this._tiles = {}; this._resetView(); // implicit _update() call } /** @param {LeafletMap} map */ beforeAdd(map) { map._addZoomLimit(this); } /** @param {LeafletMap} map */ onRemove(map) { this._removeAllTiles(); this._container.remove(); map._removeZoomLimit(this); this._container = /** @type {any} */ (null); this._tileZoom = undefined; clearTimeout(this._pruneTimeout); cancelAnimationFrame(this._fadePruneFrame); /** @type {number} */ this._fadePruneFrame = 0; } /** Brings the tile layer to the top of all tile layers. */ bringToFront() { if (this._map) { DomUtil.toFront(this._container); this._setAutoZIndex(Math.max); } return this; } /** Brings the tile layer to the bottom of all tile layers. */ bringToBack() { if (this._map) { DomUtil.toBack(this._container); this._setAutoZIndex(Math.min); } return this; } /** * Returns the HTML element that contains the tiles for this layer, or `null` once the layer is removed. * @returns {HTMLElement | null} */ getContainer() { return this._container; } /** * Changes the [opacity](#gridlayer-opacity) of the grid layer. * @param {number} opacity */ setOpacity(opacity) { this.options.opacity = opacity; this._updateOpacity(); return this; } /** * Changes the [zIndex](#gridlayer-zindex) of the grid layer. * @param {number} zIndex */ setZIndex(zIndex) { this.options.zIndex = zIndex; this._updateZIndex(); return this; } /** Returns `true` if any tile in the grid layer has not finished loading. */ isLoading() { return !!this._loading; } /** Causes the layer to clear all the tiles and request them again. */ redraw() { if (this._map) { this._removeAllTiles(); const tileZoom = this._clampZoom(Math.round(this._map.getZoom())); if (tileZoom !== this._tileZoom) { this._tileZoom = tileZoom; this._updateLevels(); } this._update(); } return this; } getEvents() { /** @type {MapListeners} */ const events = { viewprereset: this._invalidateAll, viewreset: this._resetView, zoom: this._resetView, moveend: this._onMoveEnd, worldcopyjump: this._onWorldCopyJump, zoomanim: this._animateZoom }; if (!this.options.updateWhenIdle) { // update tiles on move, but not more often than once per given interval if (!this._onMove) { this._onMove = Util.throttle(this._onMoveEnd, this.options.updateInterval, this); } events.move = this._onMove; } return events; } /** * Called only internally, must be overridden by classes extending `GridLayer`. Returns * the `HTMLElement` corresponding to the given `coords`. If the `done` callback is specified, * it must be called when the tile has finished loading and drawing. * @category Extension methods * @overload * @param {Coords} coords * @param {DoneCallback} done * @returns {HTMLElement} */ // no params here: `_addTile` treats a `createTile` that declares `done` as asynchronous createTile() { return document.createElement('div'); } /** Normalizes the [tileSize option](#gridlayer-tilesize) into a point. Used by the `createTile()` method. */ getTileSize() { const s = this.options.tileSize; return s instanceof Point ? s : new Point(s, s); } _updateZIndex() { if (this._container && this.options.zIndex !== undefined && this.options.zIndex !== null) { this._container.style.zIndex = `${this.options.zIndex}`; } } /** @param {(a: number, b: number) => number} compare */ _setAutoZIndex(compare) { // go through all other layers of the same pane, set zIndex to max + 1 (front) or min - 1 (back) const layers = /** @type {HTMLCollectionOf<HTMLElement>} */ (this.getPane().children); let edgeZIndex = -compare(-Infinity, Infinity); // -Infinity for max, Infinity for min for (const layer of layers) { const zIndex = layer.style.zIndex; if (layer !== this._container && zIndex) { edgeZIndex = compare(edgeZIndex, +zIndex); } } if (isFinite(edgeZIndex)) { this.options.zIndex = edgeZIndex + compare(-1, 1); this._updateZIndex(); } } _updateOpacity() { if (!this._map) { return; } this._container.style.opacity = `${this.options.opacity}`; } /** @param {GridTile} tile */ _onOpaqueTile(tile) {} _initContainer() { if (this._container) { return; } /** @type {HTMLElement} */ this._container = DomUtil.create('div', `leaflet-layer ${this.options.className ?? ''}`); this._updateZIndex(); if (this.options.opacity < 1) { this._updateOpacity(); } this.getPane().appendChild(this._container); } _updateLevels() { const zoom = this._tileZoom, maxZoom = /** @type {number} */ (this.options.maxZoom); if (zoom === undefined) { return undefined; } for (const z of Object.keys(this._levels).map(Number)) { if (this._levels[z].el.children.length || z === zoom) { this._levels[z].el.style.zIndex = `${maxZoom - Math.abs(zoom - z)}`; this._onUpdateLevel(z); } else { this._levels[z].el.remove(); this._removeTilesAtZoom(z); this._onRemoveLevel(z); delete this._levels[z]; } } let level = this._levels[zoom]; const map = this._map; if (!level) { level = this._levels[zoom] = /** @type {GridLevel} */ ({}); level.el = DomUtil.create('div', 'leaflet-tile-container leaflet-zoom-animated', this._container); level.el.style.zIndex = `${maxZoom}`; level.origin = map.project(map.unproject(map.getPixelOrigin()), zoom).round(); level.zoom = zoom; this._setZoomTransform(level, map.getCenter(), map.getZoom()); // force reading offsetWidth so the browser considers the newly added element for transition /** @type {(...args: unknown[]) => false} */ (Util.falseFn)(level.el.offsetWidth); this._onCreateLevel(level); } /** @type {GridLevel} */ this._level = level; return level; } /** @param {number} z */ _onUpdateLevel(z) {} /** @param {number} z */ _onRemoveLevel(z) {} /** @param {GridLevel} level */ _onCreateLevel(level) {} _pruneTiles() { if (!this._map) { return; } const zoom = this._map.getZoom(); if (zoom > /** @type {number} */ (this.options.maxZoom) || zoom < this.options.minZoom) { this._removeAllTiles(); return; } for (const tile of Object.values(this._tiles)) { tile.retain = tile.current; } for (const tile of Object.values(this._tiles)) { if (tile.current && !tile.active) { const coords = tile.coords; if (!this._retainParent(coords.x, coords.y, coords.z, coords.z - 5)) { this._retainChildren(coords.x, coords.y, coords.z, coords.z + 2); } } } for (const [key, tile] of Object.entries(this._tiles)) { if (!tile.retain) { this._removeTile(key); } } } /** @param {number} zoom */ _removeTilesAtZoom(zoom) { for (const [key, tile] of Object.entries(this._tiles)) { if (tile.coords.z === zoom) { this._removeTile(key); } } } _removeAllTiles() { for (const key of Object.keys(this._tiles)) { this._removeTile(key); } } /** @param {ViewPreResetEvent} [e] */ _invalidateAll(e) { // a zoom without animation keeps the old levels until new tiles load, like the end of an animated one, // unless the view jumps somewhere else const map = this._map; if (e?.zoom !== undefined && e.zoom !== map.getZoom() && map.getBounds().contains(e.center)) { return; } for (const z of Object.keys(this._levels)) { this._levels[z].el.remove(); this._onRemoveLevel(Number(z)); delete this._levels[z]; } this._removeAllTiles(); this._tileZoom = undefined; } /** * @param {number} x * @param {number} y * @param {number} z * @param {number} minZoom */ _retainParent(x, y, z, minZoom) { for (z--; z >= minZoom; z--) { x = Math.floor(x / 2); y = Math.floor(y / 2); if (this._retain(x, y, z)) { return true; } } return false; } /** * @param {number} x * @param {number} y * @param {number} z * @param {number} maxZoom */ _retainChildren(x, y, z, maxZoom) { for (let i = 2 * x; i < 2 * x + 2; i++) { for (let j = 2 * y; j < 2 * y + 2; j++) { if (!this._retain(i, j, z + 1) && z + 1 < maxZoom) { this._retainChildren(i, j, z + 1, maxZoom); } } } } // retains the tile if it's loaded; returns true if it's active, so there's no need to look further /** * @param {number} x * @param {number} y * @param {number} z */ _retain(x, y, z) { const tile = this._tiles[this._tileCoordsToKey({x, y, z})]; if (tile?.loaded) { tile.retain = true; } return tile?.active; } /** @param {LeafletEvent & {pinch?: boolean, flyTo?: boolean}} [e] */ _resetView(e) { const animating = e && (e.pinch || e.flyTo); this._setView(this._map.getCenter(), this._map.getZoom(), animating, animating); } /** @param {ZoomAnimEvent} e */ _animateZoom(e) { this._setView(e.center, e.zoom, true, e.noUpdate); } /** @param {number} zoom */ _clampZoom(zoom) { const options = this.options; if (undefined !== options.minNativeZoom && zoom < options.minNativeZoom) { return options.minNativeZoom; } if (undefined !== options.maxNativeZoom && options.maxNativeZoom < zoom) { return options.maxNativeZoom; } return zoom; } /** * @param {LatLng} center * @param {number} zoom * @param {boolean} [noPrune] * @param {boolean} [noUpdate] */ _setView(center, zoom, noPrune, noUpdate) { /** @type {number | undefined} */ let tileZoom = Math.round(zoom); if ((this.options.maxZoom !== undefined && tileZoom > this.options.maxZoom) || (this.options.minZoom !== undefined && tileZoom < this.options.minZoom)) { tileZoom = undefined; } else { tileZoom = this._clampZoom(tileZoom); } const tileZoomChanged = this.options.updateWhenZooming && (tileZoom !== this._tileZoom); if (!noUpdate || tileZoomChanged) { /** @type {number | undefined} */ this._tileZoom = tileZoom; if (this._abortLoading) { this._abortLoading(); } this._updateLevels(); this._resetGrid(); if (tileZoom !== undefined) { this._update(center); } if (!noPrune) { this._pruneTiles(); } // Flag to prevent finished tile fades from pruning tiles during // a zoom anim or a pinch gesture /** @type {boolean} */ this._noPrune = !!noPrune; } this._setZoomTransforms(center, zoom); } /** * @param {LatLng} center * @param {number} zoom */ _setZoomTransforms(center, zoom) { for (const level of Object.values(this._levels)) { this._setZoomTransform(level, center, zoom); } } /** * @param {GridLevel} level * @param {LatLng} center * @param {number} zoom */ _setZoomTransform(level, center, zoom) { const scale = this._map.getZoomScale(zoom, level.zoom), translate = level.origin.multiplyBy(scale) .subtract(this._map._getNewPixelOrigin(center, zoom)).round(); DomUtil.setTransform(level.el, translate, scale); } _resetGrid() { const map = this._map, crs = map.options.crs, tileSize = this._tileSize = this.getTileSize(), tileZoom = this._tileZoom; const bounds = this._map.getPixelWorldBounds(this._tileZoom); if (bounds) { /** @type {Bounds} */ this._globalTileRange = this._pxBoundsToTileRange(bounds); } this._wrapX = crs.wrapLng && !this.options.noWrap && [ Math.floor(map.project([0, crs.wrapLng[0]], tileZoom).x / tileSize.x), Math.ceil(map.project([0, crs.wrapLng[1]], tileZoom).x / tileSize.x) ]; this._wrapY = crs.wrapLat && !this.options.noWrap && [ Math.floor(map.project([crs.wrapLat[0], 0], tileZoom).y / tileSize.y), Math.ceil(map.project([crs.wrapLat[1], 0], tileZoom).y / tileSize.y) ]; } _onMoveEnd() { if (!this._map || this._map._animatingZoom) { return; } this._update(); } /** @param {WorldCopyJumpEvent} e */ _onWorldCopyJump({worlds}) { // noWrap tiles cannot be reused in another world copy. if (!this._wrapX) { return; } const tileSize = this.getTileSize(), /** @type {{[key: string]: GridTile}} */ tiles = {}; for (const tile of Object.values(this._tiles)) { const {coords, el} = tile, dx = worlds * /** @type {Bounds} */ (this._map.getPixelWorldBounds(coords.z)).getSize().x; // tiles can't be shifted by a fraction of a column, so leave those to be reloaded if (dx % tileSize.x === 0) { coords.x -= dx / tileSize.x; DomUtil.setPosition(el, DomUtil.getPosition(el).subtract([dx, 0]), true); } tiles[this._tileCoordsToKey(coords)] = tile; } this._tiles = tiles; } /** @param {LatLng} center */ _getTiledPixelBounds(center) { const map = this._map, mapZoom = map._animatingZoom ? Math.max(map._animateToZoom, map.getZoom()) : map.getZoom(), scale = map.getZoomScale(mapZoom, this._tileZoom), pixelCenter = map.project(center, this._tileZoom).floor(), halfSize = map.getSize().divideBy(scale * 2); return new Bounds(pixelCenter.subtract(halfSize), pixelCenter.add(halfSize)); } // Private method to load tiles in the grid's active zoom level according to map bounds /** @param {LatLng} [center] */ _update(center) { const map = this._map; if (!map) { return; } const zoom = this._clampZoom(map.getZoom()); if (center === undefined) { center = map.getCenter(); } if (this._tileZoom === undefined) { return; } // if out of minzoom/maxzoom const pixelBounds = this._getTiledPixelBounds(center), tileRange = this._pxBoundsToTileRange(pixelBounds), tileCenter = tileRange.getCenter(), /** @type {Coords[]} */ queue = [], margin = this.options.keepBuffer, noPruneRange = new Bounds(tileRange.getBottomLeft().subtract([margin, -margin]), tileRange.getTopRight().add([margin, -margin])); // Sanity check: panic if the tile range contains Infinity somewhere. if (!(isFinite(tileRange.min.x) && isFinite(tileRange.min.y) && isFinite(tileRange.max.x) && isFinite(tileRange.max.y))) { throw new Error('Attempted to load an infinite number of tiles'); } for (const tile of Object.values(this._tiles)) { const c = tile.coords; if (c.z !== this._tileZoom || !noPruneRange.contains(new Point(c.x, c.y))) { tile.current = false; } } // _update just loads more tiles. If the tile zoom level differs too much // from the map's, let _setView reset levels and prune old tiles. if (Math.abs(zoom - this._tileZoom) > 1) { this._setView(center, zoom); return; } // create a queue of coordinates to load tiles from for (let j = tileRange.min.y; j <= tileRange.max.y; j++) { for (let i = tileRange.min.x; i <= tileRange.max.x; i++) { const coords = /** @type {Coords} */ (new Point(i, j)); coords.z = this._tileZoom; if (!this._isValidTile(coords)) { continue; } const tile = this._tiles[this._tileCoordsToKey(coords)]; if (tile) { tile.current = true; } else { queue.push(coords); } } } // sort tile queue to load tiles in order of their distance to center queue.sort((a, b) => a.distanceTo(tileCenter) - b.distanceTo(tileCenter)); if (queue.length !== 0) { // if it's the first batch of tiles to load if (!this._loading) { /** @type {boolean} */ this._loading = true; this.fire('loading'); } // create DOM fragment to append tiles in one batch const fragment = document.createDocumentFragment(); for (const q of queue) { this._addTile(q, fragment); } this._level.el.appendChild(fragment); } } /** @param {Coords} coords */ _isValidTile(coords) { const crs = this._map.options.crs; if (!crs.infinite) { // don't load tile if it's out of bounds and not wrapped const bounds = this._globalTileRange; if ((!crs.wrapLng && (coords.x < bounds.min.x || coords.x > bounds.max.x)) || (!crs.wrapLat && (coords.y < bounds.min.y || coords.y > bounds.max.y))) { return false; } } if (!this.options.bounds) { return true; } // don't load tile if it doesn't intersect the bounds in options const tileBounds = this._tileCoordsToBounds(coords); return new LatLngBounds(this.options.bounds).overlaps(tileBounds); } /** * @param {Coords} coords * @returns {[LatLng, LatLng]} */ _tileCoordsToNwSe(coords) { const map = this._map, tileSize = this.getTileSize(), nwPoint = coords.scaleBy(tileSize), sePoint = nwPoint.add(tileSize), nw = map.unproject(nwPoint, coords.z), se = map.unproject(sePoint, coords.z); return [nw, se]; } // converts tile coordinates to its geographical bounds /** @param {Coords} coords */ _tileCoordsToBounds(coords) { const bp = this._tileCoordsToNwSe(coords); let bounds = new LatLngBounds(bp[0], bp[1]); if (!this.options.noWrap) { bounds = this._map.wrapLatLngBounds(bounds); } return bounds; } // converts tile coordinates to key for the tile cache /** @param {{x: number, y: number, z: number}} coords */ _tileCoordsToKey(coords) { return `${coords.x}:${coords.y}:${coords.z}`; } /** @param {string} key */ _removeTile(key) { const tile = this._tiles[key]; if (!tile) { return; } tile.el.remove(); delete this._tiles[key]; this.fire('tileunload', { tile: tile.el, coords: tile.coords }); } /** @param {HTMLElement} tile */ _initTile(tile) { tile.classList.add('leaflet-tile'); const tileSize = this.getTileSize(); tile.style.width = `${tileSize.x}px`; tile.style.height = `${tileSize.y}px`; } /** * @param {Coords} coords * @param {DocumentFragment} container */ _addTile(coords, container) { const tilePos = this._getTilePos(coords), key = this._tileCoordsToKey(coords); const tile = this.createTile(this._wrapCoords(coords), this._tileReady.bind(this, coords)); this._initTile(tile); // if createTile is defined with a second argument ("done" callback), // we know that tile is async and will be ready later; otherwise if (this.createTile.length < 2) { // mark tile as ready, but delay one frame for opacity animation to happen requestAnimationFrame(this._tileReady.bind(this, coords, null, tile)); } // 2D, so tiles raster into the level's layer: as separate layers they get compositor edge antialiasing, // and the overlapping edge ramps show as light seams under plus-lighter blending DomUtil.setPosition(tile, tilePos, true); // save tile in cache this._tiles[key] = { el: tile, coords, current: true }; container.appendChild(tile); this.fire('tileloadstart', { tile, coords }); } /** * @param {Coords} coords * @param {unknown} [err] * @param {HTMLElement} [el] */ _tileReady(coords, err, el) { if (err) { this.fire('tileerror', { error: err, tile: el, coords }); } const tile = this._tiles[this._tileCoordsToKey(coords)]; if (!tile) { return; } if (!this._map._fadeAnimated) { tile.active = true; this._pruneTiles(); } else if (!tile.loaded) { // with errorTileUrl, a tile is ready twice (on error and on fallback load) // while fading, the tile is a compositor layer of its own where plus-lighter would light up its // antialiased edges, so it's blended normally until the fade ends tile.el.classList.add('leaflet-tile-fading'); tile.el.animate({opacity: [0, 1]}, 200).onfinish = this._onTileFaded.bind(this, tile); } tile.loaded = true; if (!err) { tile.el.classList.add('leaflet-tile-loaded'); this.fire('tileload', { tile: tile.el, coords }); } // a tileload listener may have removed the layer, emptying the tile list if (this._map && this._noTilesToLoad()) { this._loading = false; this.fire('load'); if (!this._map) { return; } // removed by a load listener if (!this._map._fadeAnimated) { requestAnimationFrame(this._pruneTiles.bind(this)); } else { // Wait a bit more than 0.2 secs (the duration of the tile fade-in) // to trigger a pruning. /** @type {number | undefined} */ this._pruneTimeout = setTimeout(this._pruneTiles.bind(this), 250); } } } /** @param {GridTile} tile */ _onTileFaded(tile) { tile.el.classList.remove('leaflet-tile-fading'); // the tile's key may have changed after a world copy jump, so look it up again if (this._tiles[this._tileCoordsToKey(tile.coords)] !== tile) { return; } tile.active = true; this._onOpaqueTile(tile); // one prune for all the tiles that finish fading in the same frame if (this._noPrune || this._fadePruneFrame) { return; } this._fadePruneFrame = requestAnimationFrame(() => { this._fadePruneFrame = 0; if (!this._noPrune) { this._pruneTiles(); } }); } /** @param {Point} coords */ _getTilePos(coords) { return coords.scaleBy(this.getTileSize()).subtract(this._level.origin); } /** @param {Coords} coords */ _wrapCoords(coords) { const newCoords = /** @type {Coords} */ (new Point( this._wrapX ? Util.wrapNum(coords.x, this._wrapX) : coords.x, this._wrapY ? Util.wrapNum(coords.y, this._wrapY) : coords.y)); newCoords.z = coords.z; return newCoords; } /** @param {Bounds} bounds */ _pxBoundsToTileRange(bounds) { const tileSize = this.getTileSize(); return new Bounds( bounds.min.unscaleBy(tileSize).floor(), bounds.max.unscaleBy(tileSize).ceil().subtract([1, 1])); } _noTilesToLoad() { return Object.values(this._tiles).every(t => t.loaded); } }