import {Layer} from '../Layer'; import * as Browser from '../../core/Browser'; import * as Util from '../../core/Util'; import * as DomUtil from '../../dom/DomUtil'; import {Point} from '../../geometry/Point'; import {Bounds} from '../../geometry/Bounds'; import {LatLngBounds, toLatLngBounds as latLngBounds} from '../../geo/LatLngBounds'; /* * @class GridLayer * @inherits Layer * @aka L.GridLayer * * 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 ``, `` or `
`. GridLayer will handle creating and animating these DOM elements for you. * * * @section Synchronous usage * @example * * 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 * var CanvasLayer = L.GridLayer.extend({ * createTile: function(coords){ * // create a element for drawing * var tile = L.DomUtil.create('canvas', 'leaflet-tile'); * * // setup tile width and height according to the options * var 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 * var ctx = tile.getContext('2d'); * * // return the tile so it can be rendered on screen * return tile; * } * }); * ``` * * @section Asynchronous usage * @example * * 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 * var CanvasLayer = L.GridLayer.extend({ * createTile: function(coords, done){ * var error; * * // create a element for drawing * var tile = L.DomUtil.create('canvas', 'leaflet-tile'); * * // setup tile width and height according to the options * var 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; * } * }); * ``` * * @section */ export var GridLayer = Layer.extend({ // @section // @aka GridLayer options options: { // @option tileSize: Number|Point = 256 // Width and height of tiles in the grid. Use a number if width and height are equal, or `L.point(width, height)` otherwise. tileSize: 256, // @option opacity: Number = 1.0 // Opacity of the tiles. Can be used in the `createTile()` function. opacity: 1, // @option updateWhenIdle: Boolean = (depends) // Load new tiles only when panning ends. // `true` by default on mobile browsers, in order to avoid too many requests and keep smooth navigation. // `false` otherwise in order to display new tiles _during_ panning, since it is easy to pan outside the // [`keepBuffer`](#gridlayer-keepbuffer) option in desktop browsers. updateWhenIdle: Browser.mobile, // @option updateWhenZooming: Boolean = true // By default, a smooth zoom animation (during a [touch zoom](#map-touchzoom) 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, // @option updateInterval: Number = 200 // Tiles will not update more than once every `updateInterval` milliseconds when panning. updateInterval: 200, // @option zIndex: Number = 1 // The explicit zIndex of the tile layer. zIndex: 1, // @option bounds: LatLngBounds = undefined // If set, tiles will only be loaded inside the set `LatLngBounds`. bounds: null, // @option minZoom: Number = 0 // The minimum zoom level down to which this layer will be displayed (inclusive). minZoom: 0, // @option maxZoom: Number = undefined // The maximum zoom level up to which this layer will be displayed (inclusive). maxZoom: undefined, // @option maxNativeZoom: Number = 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. maxNativeZoom: undefined, // @option minNativeZoom: Number = 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. minNativeZoom: undefined, // @option noWrap: Boolean = false // 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, // @option pane: String = 'tilePane' // `Map pane` where the grid layer will be added. pane: 'tilePane', // @option className: String = '' // A custom class name to assign to the tile layer. Empty by default. className: '', // @option keepBuffer: Number = 2 // When panning the map, keep this many rows and columns of tiles before unloading them. keepBuffer: 2 }, initialize: function (options) { Util.setOptions(this, options); }, onAdd: function () { this._initContainer(); this._levels = {}; this._tiles = {}; this._resetView(); this._update(); }, beforeAdd: function (map) { map._addZoomLimit(this); }, onRemove: function (map) { this._removeAllTiles(); DomUtil.remove(this._container); map._removeZoomLimit(this); this._container = null; this._tileZoom = undefined; }, // @method bringToFront: this // Brings the tile layer to the top of all tile layers. bringToFront: function () { if (this._map) { DomUtil.toFront(this._container); this._setAutoZIndex(Math.max); } return this; }, // @method bringToBack: this // Brings the tile layer to the bottom of all tile layers. bringToBack: function () { if (this._map) { DomUtil.toBack(this._container); this._setAutoZIndex(Math.min); } return this; }, // @method getContainer: HTMLElement // Returns the HTML element that contains the tiles for this layer. getContainer: function () { return this._container; }, // @method setOpacity(opacity: Number): this // Changes the [opacity](#gridlayer-opacity) of the grid layer. setOpacity: function (opacity) { this.options.opacity = opacity; this._updateOpacity(); return this; }, // @method setZIndex(zIndex: Number): this // Changes the [zIndex](#gridlayer-zindex) of the grid layer. setZIndex: function (zIndex) { this.options.zIndex = zIndex; this._updateZIndex(); return this; }, // @method isLoading: Boolean // Returns `true` if any tile in the grid layer has not finished loading. isLoading: function () { return this._loading; }, // @method redraw: this // Causes the layer to clear all the tiles and request them again. redraw: function () { if (this._map) { this._removeAllTiles(); this._update(); } return this; }, getEvents: function () { var events = { viewprereset: this._invalidateAll, viewreset: this._resetView, zoom: this._resetView, moveend: this._onMoveEnd }; 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; } if (this._zoomAnimated) { events.zoomanim = this._animateZoom; } return events; }, // @section Extension methods // Layers extending `GridLayer` shall reimplement the following method. // @method createTile(coords: Object, done?: Function): HTMLElement // 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. createTile: function () { return document.createElement('div'); }, // @section // @method getTileSize: Point // Normalizes the [tileSize option](#gridlayer-tilesize) into a point. Used by the `createTile()` method. getTileSize: function () { var s = this.options.tileSize; return s instanceof Point ? s : new Point(s, s); }, _updateZIndex: function () { if (this._container && this.options.zIndex !== undefined && this.options.zIndex !== null) { this._container.style.zIndex = this.options.zIndex; } }, _setAutoZIndex: function (compare) { // go through all other layers of the same pane, set zIndex to max + 1 (front) or min - 1 (back) var layers = this.getPane().children, edgeZIndex = -compare(-Infinity, Infinity); // -Infinity for max, Infinity for min for (var i = 0, len = layers.length, zIndex; i < len; i++) { zIndex = layers[i].style.zIndex; if (layers[i] !== this._container && zIndex) { edgeZIndex = compare(edgeZIndex, +zIndex); } } if (isFinite(edgeZIndex)) { this.options.zIndex = edgeZIndex + compare(-1, 1); this._updateZIndex(); } }, _updateOpacity: function () { if (!this._map) { return; } // IE doesn't inherit filter opacity properly, so we're forced to set it on tiles if (Browser.ielt9) { return; } DomUtil.setOpacity(this._container, this.options.opacity); var now = +new Date(), nextFrame = false, willPrune = false; for (var key in this._tiles) { var tile = this._tiles[key]; if (!tile.current || !tile.loaded) { continue; } var fade = Math.min(1, (now - tile.loaded) / 200); DomUtil.setOpacity(tile.el, fade); if (fade < 1) { nextFrame = true; } else { if (tile.active) { willPrune = true; } else { this._onOpaqueTile(tile); } tile.active = true; } } if (willPrune && !this._noPrune) { this._pruneTiles(); } if (nextFrame) { Util.cancelAnimFrame(this._fadeFrame); this._fadeFrame = Util.requestAnimFrame(this._updateOpacity, this); } }, _onOpaqueTile: Util.falseFn, _initContainer: function () { if (this._container) { return; } 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: function () { var zoom = this._tileZoom, maxZoom = this.options.maxZoom; if (zoom === undefined) { return undefined; } for (var z in this._levels) { if (this._levels[z].el.children.length || z === zoom) { this._levels[z].el.style.zIndex = maxZoom - Math.abs(zoom - z); this._onUpdateLevel(z); } else { DomUtil.remove(this._levels[z].el); this._removeTilesAtZoom(z); this._onRemoveLevel(z); delete this._levels[z]; } } var level = this._levels[zoom], map = this._map; if (!level) { level = this._levels[zoom] = {}; 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 the browser to consider the newly added element for transition Util.falseFn(level.el.offsetWidth); this._onCreateLevel(level); } this._level = level; return level; }, _onUpdateLevel: Util.falseFn, _onRemoveLevel: Util.falseFn, _onCreateLevel: Util.falseFn, _pruneTiles: function () { if (!this._map) { return; } var key, tile; var zoom = this._map.getZoom(); if (zoom > this.options.maxZoom || zoom < this.options.minZoom) { this._removeAllTiles(); return; } for (key in this._tiles) { tile = this._tiles[key]; tile.retain = tile.current; } for (key in this._tiles) { tile = this._tiles[key]; if (tile.current && !tile.active) { var 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 (key in this._tiles) { if (!this._tiles[key].retain) { this._removeTile(key); } } }, _removeTilesAtZoom: function (zoom) { for (var key in this._tiles) { if (this._tiles[key].coords.z !== zoom) { continue; } this._removeTile(key); } }, _removeAllTiles: function () { for (var key in this._tiles) { this._removeTile(key); } }, _invalidateAll: function () { for (var z in this._levels) { DomUtil.remove(this._levels[z].el); this._onRemoveLevel(z); delete this._levels[z]; } this._removeAllTiles(); this._tileZoom = undefined; }, _retainParent: function (x, y, z, minZoom) { var x2 = Math.floor(x / 2), y2 = Math.floor(y / 2), z2 = z - 1, coords2 = new Point(+x2, +y2); coords2.z = +z2; var key = this._tileCoordsToKey(coords2), tile = this._tiles[key]; if (tile && tile.active) { tile.retain = true; return true; } else if (tile && tile.loaded) { tile.retain = true; } if (z2 > minZoom) { return this._retainParent(x2, y2, z2, minZoom); } return false; }, _retainChildren: function (x, y, z, maxZoom) { for (var i = 2 * x; i < 2 * x + 2; i++) { for (var j = 2 * y; j < 2 * y + 2; j++) { var coords = new Point(i, j); coords.z = z + 1; var key = this._tileCoordsToKey(coords), tile = this._tiles[key]; if (tile && tile.active) { tile.retain = true; continue; } else if (tile && tile.loaded) { tile.retain = true; } if (z + 1 < maxZoom) { this._retainChildren(i, j, z + 1, maxZoom); } } } }, _resetView: function (e) { var animating = e && (e.pinch || e.flyTo); this._setView(this._map.getCenter(), this._map.getZoom(), animating, animating); }, _animateZoom: function (e) { this._setView(e.center, e.zoom, true, e.noUpdate); }, _clampZoom: function (zoom) { var 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; }, _setView: function (center, zoom, noPrune, noUpdate) { var tileZoom = this._clampZoom(Math.round(zoom)); if ((this.options.maxZoom !== undefined && tileZoom > this.options.maxZoom) || (this.options.minZoom !== undefined && tileZoom < this.options.minZoom)) { tileZoom = undefined; } var tileZoomChanged = this.options.updateWhenZooming && (tileZoom !== this._tileZoom); if (!noUpdate || tileZoomChanged) { 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 _updateOpacity from pruning tiles during // a zoom anim or a pinch gesture this._noPrune = !!noPrune; } this._setZoomTransforms(center, zoom); }, _setZoomTransforms: function (center, zoom) { for (var i in this._levels) { this._setZoomTransform(this._levels[i], center, zoom); } }, _setZoomTransform: function (level, center, zoom) { var scale = this._map.getZoomScale(zoom, level.zoom), translate = level.origin.multiplyBy(scale) .subtract(this._map._getNewPixelOrigin(center, zoom)).round(); if (Browser.any3d) { DomUtil.setTransform(level.el, translate, scale); } else { DomUtil.setPosition(level.el, translate); } }, _resetGrid: function () { var map = this._map, crs = map.options.crs, tileSize = this._tileSize = this.getTileSize(), tileZoom = this._tileZoom; var bounds = this._map.getPixelWorldBounds(this._tileZoom); if (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.y) ]; this._wrapY = crs.wrapLat && !this.options.noWrap && [ Math.floor(map.project([crs.wrapLat[0], 0], tileZoom).y / tileSize.x), Math.ceil(map.project([crs.wrapLat[1], 0], tileZoom).y / tileSize.y) ]; }, _onMoveEnd: function () { if (!this._map || this._map._animatingZoom) { return; } this._update(); }, _getTiledPixelBounds: function (center) { var 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 _update: function (center) { var map = this._map; if (!map) { return; } var zoom = this._clampZoom(map.getZoom()); if (center === undefined) { center = map.getCenter(); } if (this._tileZoom === undefined) { return; } // if out of minzoom/maxzoom var pixelBounds = this._getTiledPixelBounds(center), tileRange = this._pxBoundsToTileRange(pixelBounds), tileCenter = tileRange.getCenter(), 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 (var key in this._tiles) { var c = this._tiles[key].coords; if (c.z !== this._tileZoom || !noPruneRange.contains(new Point(c.x, c.y))) { this._tiles[key].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 (var j = tileRange.min.y; j <= tileRange.max.y; j++) { for (var i = tileRange.min.x; i <= tileRange.max.x; i++) { var coords = new Point(i, j); coords.z = this._tileZoom; if (!this._isValidTile(coords)) { continue; } var 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(function (a, b) { return a.distanceTo(tileCenter) - b.distanceTo(tileCenter); }); if (queue.length !== 0) { // if it's the first batch of tiles to load if (!this._loading) { this._loading = true; // @event loading: Event // Fired when the grid layer starts loading tiles. this.fire('loading'); } // create DOM fragment to append tiles in one batch var fragment = document.createDocumentFragment(); for (i = 0; i < queue.length; i++) { this._addTile(queue[i], fragment); } this._level.el.appendChild(fragment); } }, _isValidTile: function (coords) { var crs = this._map.options.crs; if (!crs.infinite) { // don't load tile if it's out of bounds and not wrapped var 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 var tileBounds = this._tileCoordsToBounds(coords); return latLngBounds(this.options.bounds).overlaps(tileBounds); }, _keyToBounds: function (key) { return this._tileCoordsToBounds(this._keyToTileCoords(key)); }, _tileCoordsToNwSe: function (coords) { var 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 _tileCoordsToBounds: function (coords) { var bp = this._tileCoordsToNwSe(coords), 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 _tileCoordsToKey: function (coords) { return coords.x + ':' + coords.y + ':' + coords.z; }, // converts tile cache key to coordinates _keyToTileCoords: function (key) { var k = key.split(':'), coords = new Point(+k[0], +k[1]); coords.z = +k[2]; return coords; }, _removeTile: function (key) { var tile = this._tiles[key]; if (!tile) { return; } // Cancels any pending http requests associated with the tile // unless we're on Android's stock browser, // see https://github.com/Leaflet/Leaflet/issues/137 if (!Browser.androidStock) { tile.el.setAttribute('src', Util.emptyImageUrl); } DomUtil.remove(tile.el); delete this._tiles[key]; // @event tileunload: TileEvent // Fired when a tile is removed (e.g. when a tile goes off the screen). this.fire('tileunload', { tile: tile.el, coords: this._keyToTileCoords(key) }); }, _initTile: function (tile) { DomUtil.addClass(tile, 'leaflet-tile'); var tileSize = this.getTileSize(); tile.style.width = tileSize.x + 'px'; tile.style.height = tileSize.y + 'px'; tile.onselectstart = Util.falseFn; tile.onmousemove = Util.falseFn; // update opacity on tiles in IE7-8 because of filter inheritance problems if (Browser.ielt9 && this.options.opacity < 1) { DomUtil.setOpacity(tile, this.options.opacity); } // without this hack, tiles disappear after zoom on Chrome for Android // https://github.com/Leaflet/Leaflet/issues/2078 if (Browser.android && !Browser.android23) { tile.style.WebkitBackfaceVisibility = 'hidden'; } }, _addTile: function (coords, container) { var tilePos = this._getTilePos(coords), key = this._tileCoordsToKey(coords); var tile = this.createTile(this._wrapCoords(coords), Util.bind(this._tileReady, 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 Util.requestAnimFrame(Util.bind(this._tileReady, this, coords, null, tile)); } DomUtil.setPosition(tile, tilePos); // save tile in cache this._tiles[key] = { el: tile, coords: coords, current: true }; container.appendChild(tile); // @event tileloadstart: TileEvent // Fired when a tile is requested and starts loading. this.fire('tileloadstart', { tile: tile, coords: coords }); }, _tileReady: function (coords, err, tile) { if (!this._map) { return; } if (err) { // @event tileerror: TileErrorEvent // Fired when there is an error loading a tile. this.fire('tileerror', { error: err, tile: tile, coords: coords }); } var key = this._tileCoordsToKey(coords); tile = this._tiles[key]; if (!tile) { return; } tile.loaded = +new Date(); if (this._map._fadeAnimated) { DomUtil.setOpacity(tile.el, 0); Util.cancelAnimFrame(this._fadeFrame); this._fadeFrame = Util.requestAnimFrame(this._updateOpacity, this); } else { tile.active = true; this._pruneTiles(); } if (!err) { DomUtil.addClass(tile.el, 'leaflet-tile-loaded'); // @event tileload: TileEvent // Fired when a tile loads. this.fire('tileload', { tile: tile.el, coords: coords }); } if (this._noTilesToLoad()) { this._loading = false; // @event load: Event // Fired when the grid layer loaded all visible tiles. this.fire('load'); if (Browser.ielt9 || !this._map._fadeAnimated) { Util.requestAnimFrame(this._pruneTiles, this); } else { // Wait a bit more than 0.2 secs (the duration of the tile fade-in) // to trigger a pruning. setTimeout(Util.bind(this._pruneTiles, this), 250); } } }, _getTilePos: function (coords) { return coords.scaleBy(this.getTileSize()).subtract(this._level.origin); }, _wrapCoords: function (coords) { var newCoords = 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; }, _pxBoundsToTileRange: function (bounds) { var tileSize = this.getTileSize(); return new Bounds( bounds.min.unscaleBy(tileSize).floor(), bounds.max.unscaleBy(tileSize).ceil().subtract([1, 1])); }, _noTilesToLoad: function () { for (var key in this._tiles) { if (!this._tiles[key].loaded) { return false; } } return true; } }); // @factory L.gridLayer(options?: GridLayer options) // Creates a new instance of GridLayer with the supplied options. export function gridLayer(options) { return new GridLayer(options); }