var inherit = require('./inherit');
var featureLayer = require('./featureLayer');
var util = require('./util');
/**
* Object specification for a tile layer.
*
* @typedef {geo.layer.spec} geo.tileLayer.spec
* @extends {geo.layer.spec}
* @property {number} [minLevel=0] The minimum zoom level available.
* @property {number} [maxLevel=18] The maximum zoom level available.
* @property {object} [tileOverlap] Pixel overlap between tiles.
* @property {number} [tileOverlap.x] Horizontal overlap.
* @property {number} [tileOverlap.y] Vertical overlap.
* @property {number} [tileWidth=256] The tile width without overlap.
* @property {number} [tileHeight=256] The tile height without overlap.
* @property {function} [tilesAtZoom=null] A function that is given a zoom
* level and returns `{x: (num), y: (num)}` with the number of tiles at that
* zoom level.
* @property {number} [cacheSize=600] The maximum number of tiles to cache.
* The default is 200 if keepLower is false.
* @property {geo.fetchQueue} [queue] A fetch queue to use. If unspecified, a
* new queue is created.
* @property {number} [queueSize=6] The queue size. Most browsers make at most
* 6 requests to any domain, so this should be no more than 6 times the
* number of subdomains used.
* @property {number} [initialQueueSize=0] The initial queue size. `0` to use
* the queue size. When querying a tile server that needs to load
* information before serving the first tile, having an initial queue size of
* 1 can reduce the load on the tile server. After the initial queue of
* tiles are loaded, the `queueSize` is used for all additional queries
* unless the `initialQueueSize` is set again or the tile cache is reset.
* @property {boolean} [keepLower=true] When truthy, keep lower zoom level
* tiles when showing high zoom level tiles. This uses more memory but
* results in smoother transitions.
* @property {boolean} [wrapX=true] Wrap in the x-direction.
* @property {boolean} [wrapY=false] Wrap in the y-direction.
* @property {string|function} [url=null] A function taking the current tile
* indices `(x, y, level, subdomains)` and returning a URL or jquery ajax
* config to be passed to the {geo.tile} constructor. Example:
* ```
* (x, y, z, subdomains) => "http://example.com/z/y/x.png"
* ```
* If this is a string, a template url with {x}, {y}, {z}, and {s} as
* template variables. {s} picks one of the subdomains parameter and may
* contain a comma-separated list of subdomains.
* @property {string|string[]} [subdomains="abc"] Subdomains to use in template
* url strings. If a string, this is converted to a list before being passed
* to a url function.
* @property {string} [baseUrl=null] If defined, use the old-style base url
* instead of the url parameter. This is functionally the same as using a
* url of `baseUrl/{z}/{x}/{y}.(imageFormat || png)`. If the specified
* string does not end in a slash, one is added.
* @property {string} [imageFormat='png'] This is only used if a `baseUrl` is
* specified, in which case it determines the image name extension used in
* the url.
* @property {number} [animationDuration=0] The number of milliseconds for the
* tile loading animation to occur. Only some renderers support this.
* @property {string} [attribution] An attribution to display with the layer
* (accepts HTML).
* @property {function} [tileRounding=Math.round] This function determines
* which tiles will be loaded when the map is at a non-integer zoom. For
* example, `Math.floor`, will use tile level 2 when the map is at zoom 2.9.
* @property {function} [tileOffset] This function takes a zoom level argument
* and returns, in units of pixels, the coordinates of the point (0, 0) at
* the given zoom level relative to the bottom left corner of the domain.
* @property {function} [tilesMaxBounds=null] This function takes a zoom level
* argument and returns an object with `x` and `y` in pixels which is used to
* crop the last row and column of tiles. Note that if tiles wrap, only
* complete tiles in the wrapping direction(s) are supported, and this max
* bounds will probably not behave properly.
* @property {boolean} [topDown=false] True if the gcs is top-down, false if
* bottom-up (the ingcs does not matter, only the gcs coordinate system).
* When falsy, this inverts the gcs y-coordinate when calculating local
* coordinates.
* @property {string} [idleAfter='view'] Consider the layer as idle once a
* specific set of tiles is loaded. 'view' is when all tiles in view are
* loaded. 'all' is when tiles in view and tiles that were once requested
* have been loaded (this corresponds to having all network activity
* finished).
* @property {object} [baseQuad] A quad feature element to draw before below
* any tile layers. If specified, this uses the quad defaults, so this is a
* ``geo.quadFeature.position`` object with, typically, an ``image`` property
* added to it. The quad positions are in the map gcs coordinates.
* @property {boolean|number} [nearestPixel] If true, image quads are
* rendered with near-neighbor sampling. If false, with interpolated
* sampling. If a number, interpolate at that zoom level or below and
* nearest neighbor at that zoom level or above.
*/
/**
* Standard modulo operator where the output is in [0, b) for all inputs.
* @private
* @param {number} a Any finite number.
* @param {number} b A positive number.
* @returns {number} The positive version of `a % b`.
*/
function modulo(a, b) {
return ((a % b) + b) % b;
}
/**
* Pick a subdomain from a list of subdomains based on a the tile location.
*
* @param {number} x The x tile coordinate.
* @param {number} y The y tile coordinate.
* @param {number} z The tile layer.
* @param {string[]} subdomains The list of known subdomains.
* @returns {string} A subdomain based on the location.
*/
function m_getTileSubdomain(x, y, z, subdomains) {
return subdomains[modulo(x + y + z, subdomains.length)];
}
/**
* Returns an OSM tile server formatting function from a standard format
* string. Replaces `{s}`, `{z}`, `{x}`, and `{y}`. These may be any case
* and may be prefixed with `$` (e.g., `${X}` is the same as `{x}`). The
* subdomain can be specified by a string of characters, listed as a range,
* or as a comma-separated list (e.g., `{s:abc}`, `{a-c}`, `{a,b,c}` are
* all equivalent. The comma-separated list can have subdomains that are of
* any length; the string and range both use one-character subdomains.
*
* @param {string} base The tile format string
* @returns {function} A conversion function.
* @private.
*/
function m_tileUrlFromTemplate(base) {
var xPattern = /\$?\{[xX]\}/g,
yPattern = /\$?\{[yY]\}/g,
zPattern = /\$?\{[zZ]\}/g,
sPattern = /\$?\{(s|S|[sS]:[^{}]+|[^-{}]-[^-{}]|([^,{}]+,)+[^,{}]+)\}/;
var url = base
.replace(new RegExp(sPattern, 'g'), '{s}')
.replace(xPattern, '{x}')
.replace(yPattern, '{y}')
.replace(zPattern, '{z}');
var urlSubdomains;
var sMatch = base.match(sPattern);
if (sMatch) {
if (sMatch[2]) {
urlSubdomains = sMatch[1].split(',');
} else if (sMatch[1][1] === ':') {
urlSubdomains = sMatch[1].substr(2).split('');
} else if (sMatch[1][1] === '-') {
urlSubdomains = [];
var start = sMatch[1].charCodeAt(0),
end = sMatch[1].charCodeAt(2);
for (var i = Math.min(start, end); i <= Math.max(start, end); i += 1) {
urlSubdomains.push(String.fromCharCode(i));
}
}
}
return function (x, y, z, subdomains) {
return url
.replace(/\{s\}/g, m_getTileSubdomain(x, y, z, urlSubdomains || subdomains))
.replace(/\{x\}/g, x)
.replace(/\{y\}/g, y)
.replace(/\{z\}/g, z);
};
}
/**
* This method defines a tileLayer, an abstract class defining a layer
* divided into tiles of arbitrary data. Notably, this class provides the
* core functionality of {@link geo.osmLayer}, but hooks exist to render
* tiles more generically. When multiple zoom levels are present in a given
* dataset, this class assumes that the space occupied by tile `(i, j)` at
* level `z` is covered by a 2x2 grid of tiles at zoom level `z + 1`:
* ```
* (2i, 2j), (2i, 2j + 1)
* (2i + 1, 2j), (2i + 1, 2j + 1)
* ```
* The higher level tile set should represent a 2x increase in resolution.
*
* @class
* @alias geo.tileLayer
* @extends geo.featureLayer
* @param {geo.tileLayer.spec} [arg] Specification for the layer.
* @returns {geo.tileLayer}
*/
var tileLayer = function (arg) {
'use strict';
if (!(this instanceof tileLayer)) {
return new tileLayer(arg);
}
featureLayer.call(this, arg);
var $ = require('jquery');
var geo_event = require('./event');
var transform = require('./transform');
var tileCache = require('./tileCache');
var fetchQueue = require('./fetchQueue');
var adjustLayerForRenderer = require('./registry').adjustLayerForRenderer;
var Tile = require('./tile');
arg = util.deepMerge({}, this.constructor.defaults, arg || {});
if (!arg.cacheSize) {
// this size should be sufficient for a 4k display
// where display size is (w, h), minimum tile dimension is ts, and total
// number of levels is ml, this is roughly
// sum([(math.ceil((w**2+h**2)**0.5 / (ts*2**l)) + 1) *
// (math.ceil(min(w, h) / (ts*2**l)) + 1) for l in range(ml)])
arg.cacheSize = arg.keepLower ? 600 : 200;
}
if ($.type(arg.subdomains) === 'string') {
arg.subdomains = arg.subdomains.split('');
}
/* We used to call the url option baseUrl. If a baseUrl is specified, use
* it instead of url, interpreting it as before. */
if (arg.baseUrl) {
var url = arg.baseUrl;
if (url && url.charAt(url.length - 1) !== '/') {
url += '/';
}
arg.url = url + '{z}/{x}/{y}.' + (arg.imageFormat || 'png');
}
/* Save the original url so that we can return it if asked */
arg.originalUrl = arg.url;
if ($.type(arg.url) === 'string') {
arg.url = m_tileUrlFromTemplate(arg.url);
}
var s_init = this._init,
s_exit = this._exit,
s_visible = this.visible,
m_queueSize = arg.queueSize || 6,
m_initialQueueSize = arg.initialQueueSize || 0,
m_lastTileSet = [],
m_promisedTiles = {},
m_maxBounds = [],
m_reference,
m_exited,
m_lastBaseQuad,
m_nearestPixel = arg.nearestPixel,
m_this = this;
// copy the options into a private variable
this._options = util.deepMerge({}, arg);
// set the layer attribution text
this.attribution(arg.attribution);
// initialize the object that keeps track of actively drawn tiles
this._activeTiles = {};
// initialize the object that stores active tile regions in a
// tree-like structure providing quick queries to determine
// if tiles are completely obscured or not.
this._tileTree = {};
// initialize the in memory tile cache
this._cache = tileCache({size: arg.cacheSize});
// initialize the tile fetch queue
this._queue = arg.queue || fetchQueue({
// this should probably be 6 * subdomains.length if subdomains are used
size: m_queueSize,
initialSize: m_initialQueueSize,
// if track is the same as the cache size, then neither processing time
// nor memory will be wasted. Larger values will use more memory,
// smaller values will do needless computations.
track: arg.cacheSize,
needed: function (tile) {
if (this._tileLayers && this._tileLayers.length) {
return this._tileLayers.some((tl) => tile === tl.cache.get(tile.toString(), true));
}
return tile === m_this.cache.get(tile.toString(), true);
}
});
this._queue._tileLayers = this._queue._tileLayers || [];
this._queue._tileLayers.push(m_this);
if (this._queue.initialTrack && this._queue.track) {
this._queue.track = this._queue.initialTrack * this._queue._tileLayers.length;
}
var m_tileOffsetValues = {};
/**
* Readonly accessor to the options object.
* @property {object} options A copy of the options object.
* @name geo.tileLayer#options
*/
Object.defineProperty(this, 'options', {get: function () {
return Object.assign({}, m_this._options);
}});
/**
* Readonly accessor to the tile cache object.
* @property {geo.tileCache} cache The tile cache object.
* @name geo.tileLayer#cache
*/
Object.defineProperty(this, 'cache', {get: function () {
return m_this._cache;
}});
/**
* Readonly accessor to the active tile mapping. This is an object
* containing all currently drawn tiles (hash(tile) => tile).
*
* @property {object} activeTiles The keys are `hash(tile)` and the values
* are tiles.
* @name geo.tileLayer#activeTiles
*/
Object.defineProperty(this, 'activeTiles', {get: function () {
return Object.assign({}, m_this._activeTiles); // copy on output
}});
/**
* Get/set the queue object.
* @property {geo.fetchQueue} queue The current queue.
* @name geo.tileLayer#queue
*/
Object.defineProperty(this, 'queue', {
get: function () { return m_this._queue; },
set: function (queue) {
/* The queue's needed function determines if a tile is still needed. A
* tile in the queue is needed if it is needed by at least one layer that
* is using it. _tileLayers tracks the layers that share the queue to
* allow walking through the layers and check if any layer needs a tile.
* When the queue is set, maintain the list of joined tile layers. */
if (m_this._queue !== queue) {
if (this._queue && this._queue._tileLayers && this._queue._tileLayers.indexOf(m_this) >= 0) {
this._queue._tileLayers.splice(this._queue._tileLayers.indexOf(m_this), 1);
if (this._queue.initialTrack && this._queue.track && this._queue._tileLayers.length) {
this._queue.track = this._queue.initialTrack * this._queue._tileLayers.length;
}
}
m_this._queue = queue;
m_this._queue._tileLayers = m_this._queue._tileLayers || [];
m_this._queue._tileLayers.push(m_this);
if (m_this._queue.initialTrack && m_this._queue.track) {
m_this._queue.track = m_this._queue.initialTrack * m_this._queue._tileLayers.length;
}
}
}
});
/**
* Get/set the queue size.
* @property {number} size The queue size.
* @name geo.tileLayer#queueSize
*/
Object.defineProperty(this, 'queueSize', {
get: function () { return m_queueSize; },
set: function (n) {
m_queueSize = n;
m_this._queue.size = n;
}
});
/**
* Get/set the initial queue size.
* @property {number} size The initial queue size. `0` to use the queue
* size.
* @name geo.tileLayer#queueSize
*/
Object.defineProperty(this, 'initialQueueSize', {
get: function () { return m_initialQueueSize; },
set: function (n) {
m_initialQueueSize = n || 0;
m_this._queue.initialSize = n || m_queueSize;
}
});
/**
* Get/set the tile reference value.
* @property {string} reference A reference value to distinguish tiles on
* this layer.
* @name geo.tileLayer#reference
*/
Object.defineProperty(this, 'reference', {
get: function () { return '' + m_this.id() + '_' + (m_reference || 0); },
set: function (reference) {
m_reference = reference;
},
configurable: true
});
/**
* The number of tiles at the given zoom level. The default implementation
* just returns `Math.pow(2, z)`.
*
* @param {number} level A zoom level.
* @returns {object} The number of tiles in each axis in the form
* `{x: nx, y: ny}`.
*/
this.tilesAtZoom = function (level) {
if (m_this._options.tilesAtZoom) {
return m_this._options.tilesAtZoom.call(m_this, level);
}
var s = Math.pow(2, level);
return {x: s, y: s};
};
/**
* The maximum tile bounds at the given zoom level, or null if no special
* tile bounds.
*
* @param {number} level A zoom level.
* @returns {object} The maximum tile bounds in pixels for the specified
* level, or null if none specified (`{x: width, y: height}`).
*/
this.tilesMaxBounds = function (level) {
if (m_this._options.tilesMaxBounds) {
return m_this._options.tilesMaxBounds.call(m_this, level);
}
return null;
};
/**
* Get the crop values for a tile based on the tilesMaxBounds function.
* Returns undefined if the tile should not be cropped.
*
* @param {object} tile The tile to compute crop values for.
* @returns {object} Either `undefined` or an object with `x` and `y` values
* which is the size in pixels for the tile.
*/
this.tileCropFromBounds = function (tile) {
if (!m_this._options.tilesMaxBounds) {
return;
}
var level = tile.index.level,
bounds = m_this._tileBounds(tile);
if (m_maxBounds[level] === undefined) {
m_maxBounds[level] = m_this.tilesMaxBounds(level) || null;
}
if (m_maxBounds[level] && (bounds.right > m_maxBounds[level].x ||
bounds.bottom > m_maxBounds[level].y)) {
return {
x: Math.max(0, Math.min(m_maxBounds[level].x, bounds.right) - bounds.left),
y: Math.max(0, Math.min(m_maxBounds[level].y, bounds.bottom) - bounds.top)
};
}
};
/**
* Returns `true` if the given tile index is valid:
* - min level <= level <= max level
* - 0 <= x <= 2^level - 1
* - 0 <= y <= 2^level - 1
* If the layer wraps, the x and y values may be allowed to extend beyond
* these values.
*
* @param {object} index The tile index.
* @param {number} index.x
* @param {number} index.y
* @param {number} index.level
* @returns {boolean}
*/
this.isValid = function (index) {
if (!(m_this._options.minLevel <= index.level &&
index.level <= m_this._options.maxLevel)) {
return false;
}
if (!(m_this._options.wrapX || (
0 <= index.x && index.x <= m_this.tilesAtZoom(index.level).x - 1))) {
return false;
}
if (!(m_this._options.wrapY || (
0 <= index.y && index.y <= m_this.tilesAtZoom(index.level).y - 1))) {
return false;
}
return true;
};
/**
* Returns the current origin tile and offset at the given zoom level.
* This is intended to be cached in the future to optimize coordinate
* transformations.
*
* @protected
* @param {number} level The target zoom level.
* @returns {object} The origin and offset in the form
* `{index: {x, y}, offset: {x, y}}`.
*/
this._origin = function (level) {
var origin = m_this.toLevel(m_this.toLocal(m_this.map().origin()), level),
o = m_this._options,
index, offset;
// get the tile index
index = {
x: Math.floor(origin.x / o.tileWidth),
y: Math.floor(origin.y / o.tileHeight)
};
// get the offset inside the tile (in pixels)
// This computation should contain the only numerically unstable
// subtraction in this class. All other methods will assume
// coordinates are given relative to the map origin.
offset = {
x: origin.x - o.tileWidth * index.x,
y: origin.y - o.tileHeight * index.y
};
return {index: index, offset: offset};
};
/**
* Returns a tile's bounds in its level coordinates.
*
* @param {geo.tile} tile The tile to check.
* @returns {object} The tile's bounds with `left`, `top`, `right`,
* `bottom`.
*/
this._tileBounds = function (tile) {
var origin = m_this._origin(tile.index.level);
return tile.bounds(origin.index, origin.offset);
};
/**
* Returns the tile indices at the given point.
*
* @param {object} point The coordinates in pixels relative to the map
* origin.
* @param {number} point.x
* @param {number} point.y
* @param {number} level The target zoom level.
* @returns {object} The tile indices. This has `x` and `y` properties.
*/
this.tileAtPoint = function (point, level) {
var o = m_this._origin(level);
var map = m_this.map();
point = m_this.displayToLevel(map.gcsToDisplay(point, null), level);
if (isNaN(point.x)) { point.x = 0; }
if (isNaN(point.y)) { point.y = 0; }
var to = m_this._tileOffset(level);
if (to) {
point.x += to.x;
point.y += to.y;
}
var tile = {
x: Math.floor(
o.index.x + (o.offset.x + point.x) / m_this._options.tileWidth
),
y: Math.floor(
o.index.y + (o.offset.y + point.y) / m_this._options.tileHeight
)
};
return tile;
};
/**
* Returns a tile's bounds in a gcs.
*
* @param {object|geo.tile} indexOrTile Either a tile or an object with
* {x, y, level}` specifying a tile.
* @param {string|geo.transform|null} [gcs] `undefined` to use the
* interface gcs, `null` to use the map gcs, or any other transform.
* @returns {object} The tile bounds in the specified gcs.
*/
this.gcsTileBounds = function (indexOrTile, gcs) {
var tile = (indexOrTile.index ? indexOrTile : Tile({
index: indexOrTile,
size: {x: m_this._options.tileWidth, y: m_this._options.tileHeight},
url: ''
}));
var to = m_this._tileOffset(tile.index.level),
bounds = tile.bounds({x: 0, y: 0}, to),
map = m_this.map(),
unit = map.unitsPerPixel(tile.index.level);
var coord = [{
x: bounds.left * unit, y: m_this._topDown() * bounds.top * unit
}, {
x: bounds.right * unit, y: m_this._topDown() * bounds.bottom * unit
}];
gcs = (gcs === null ? map.gcs() : (
gcs === undefined ? map.ingcs() : gcs));
if (gcs !== map.gcs()) {
coord = transform.transformCoordinates(map.gcs(), gcs, coord);
}
return {
left: coord[0].x,
top: coord[0].y,
right: coord[1].x,
bottom: coord[1].y
};
};
/**
* Returns an instantiated tile object with the given indices. This
* method always returns a new tile object. Use `_getTileCached`
* to use the caching layer.
*
* @param {object} index The tile index.
* @param {number} index.x
* @param {number} index.y
* @param {number} index.level
* @param {object} source The tile index used for constructing the url.
* @param {number} source.x
* @param {number} source.y
* @param {number} source.level
* @returns {geo.tile}
*/
this._getTile = function (index, source) {
var urlParams = source || index;
return Tile({
index: index,
size: {x: m_this._options.tileWidth, y: m_this._options.tileHeight},
queue: m_this._queue,
url: m_this._options.url.call(
m_this, urlParams.x, urlParams.y, urlParams.level || 0,
m_this._options.subdomains)
});
};
/**
* Returns an instantiated tile object with the given indices. This
* method is similar to `_getTile`, but checks the cache before
* generating a new tile.
*
* @param {object} index The tile index.
* @param {number} index.x
* @param {number} index.y
* @param {number} index.level
* @param {object} source The tile index used for constructing the url.
* @param {number} source.x
* @param {number} source.y
* @param {number} source.level
* @param {boolean} delayPurge If true, don't purge tiles from the cache.
* @returns {geo.tile}
*/
this._getTileCached = function (index, source, delayPurge) {
var tile = m_this.cache.get(m_this._tileHash(index));
if (tile === null) {
tile = m_this._getTile(index, source);
m_this.cache.add(tile, m_this.remove, delayPurge);
}
return tile;
};
/**
* Returns a string representation of the tile at the given index.
*
* Note: This method **must** return the same string as:
* ```
* tile({index: index}).toString();
* ```
* This method is used as a hashing function for the caching layer.
*
* @param {object} index The tile index
* @param {number} index.x
* @param {number} index.y
* @param {number} [index.level]
* @param {number} [index.reference]
* @returns {string}
*/
this._tileHash = function (index) {
return [index.level || 0, index.y, index.x, index.reference || 0].join('_');
};
/**
* Returns the optimal starting and ending tile indices (inclusive)
* necessary to fill the given viewport.
*
* @param {number} level The zoom level
* @param {geo.geoBounds} bounds The map bounds in world coordinates.
* @returns {object} The tile range with a `start` and `end` record, each
* with `x` and `y` tile indices.
*/
this._getTileRange = function (level, bounds) {
var corners = [
m_this.tileAtPoint({x: bounds.left, y: bounds.top}, level),
m_this.tileAtPoint({x: bounds.right, y: bounds.top}, level),
m_this.tileAtPoint({x: bounds.left, y: bounds.bottom}, level),
m_this.tileAtPoint({x: bounds.right, y: bounds.bottom}, level)
];
return {
start: {
x: Math.min(corners[0].x, corners[1].x, corners[2].x, corners[3].x),
y: Math.min(corners[0].y, corners[1].y, corners[2].y, corners[3].y)
},
end: {
x: Math.max(corners[0].x, corners[1].x, corners[2].x, corners[3].x),
y: Math.max(corners[0].y, corners[1].y, corners[2].y, corners[3].y)
}
};
};
/**
* Returns a list of tiles necessary to fill the screen at the given
* zoom level, center point, and viewport size. The list is optionally
* ordered by loading priority (center tiles first).
*
* @protected
* @param {number} maxLevel The zoom level
* @param {geo.geoBounds} bounds The map bounds
* @param {boolean} sorted Return a sorted list
* @param {boolean} [onlyIfChanged] If the set of tiles have not changed
* (even if their desired order has), return undefined instead of an
* array of tiles.
* @returns {geo.tile[]} An array of tile objects
*/
this._getTiles = function (maxLevel, bounds, sorted, onlyIfChanged) {
var i, j, tiles = [], index, nTilesLevel,
start, end, indexRange, source, center, changed = false, old, level,
minLevel = (
m_this._options.keepLower ?
m_this._options.minLevel :
Math.min(Math.max(maxLevel, m_this._options.minLevel), m_this._options.maxLevel));
if (maxLevel < minLevel) {
maxLevel = minLevel;
}
/* Generate a list of the tiles that we want to create. This is done
* before sorting, because we want to actually generate the tiles in
* the sort order. */
for (level = minLevel; level <= maxLevel; level += 1) {
// get the tile range to fetch
indexRange = m_this._getTileRange(level, bounds);
start = indexRange.start;
end = indexRange.end;
// total number of tiles existing at m_this level
nTilesLevel = m_this.tilesAtZoom(level);
if (!m_this._options.wrapX) {
start.x = Math.min(Math.max(start.x, 0), nTilesLevel.x - 1);
end.x = Math.min(Math.max(end.x, 0), nTilesLevel.x - 1);
if (level === minLevel && m_this._options.keepLower) {
start.x = 0;
end.x = nTilesLevel.x - 1;
}
}
if (!m_this._options.wrapY) {
start.y = Math.min(Math.max(start.y, 0), nTilesLevel.y - 1);
end.y = Math.min(Math.max(end.y, 0), nTilesLevel.y - 1);
if (level === minLevel && m_this._options.keepLower) {
start.y = 0;
end.y = nTilesLevel.y - 1;
}
}
/* If we are reprojecting tiles, we need a check to not use all levels
* if the number of tiles is excessive. */
if (m_this._options.gcs && m_this._options.gcs !== m_this.map().gcs() &&
level !== minLevel &&
(end.x + 1 - start.x) * (end.y + 1 - start.y) >
(m_this.map().size().width * m_this.map().size().height /
m_this._options.tileWidth / m_this._options.tileHeight) * 16) {
break;
}
// loop over the tile range
for (i = start.x; i <= end.x; i += 1) {
for (j = start.y; j <= end.y; j += 1) {
index = {level: level, x: i, y: j, reference: m_this.reference};
source = {level: level, x: i, y: j, reference: m_this.reference};
if (m_this._options.wrapX) {
source.x = modulo(source.x, nTilesLevel.x);
}
if (m_this._options.wrapY) {
source.y = modulo(source.y, nTilesLevel.y);
}
if (m_this.isValid(source)) {
if (onlyIfChanged && tiles.length < m_lastTileSet.length) {
old = m_lastTileSet[tiles.length];
changed = changed || (index.level !== old.level ||
index.x !== old.x || index.y !== old.y);
}
tiles.push({index: index, source: source});
}
}
}
}
if (onlyIfChanged) {
if (!changed && tiles.length === m_lastTileSet.length) {
return;
}
m_lastTileSet.splice(0, m_lastTileSet.length);
$.each(tiles, function (idx, tile) {
m_lastTileSet.push(tile.index);
});
}
if (sorted) {
center = {
x: (start.x + end.x) / 2,
y: (start.y + end.y) / 2,
level: maxLevel,
bottomLevel: maxLevel
};
var numTiles = Math.max(end.x - start.x, end.y - start.y) + 1;
for (; numTiles >= 1; numTiles /= 2) {
center.bottomLevel -= 1;
}
tiles.sort(m_this._loadMetric(center));
/* If we are using a fetch queue, start a new batch */
if (m_this._queue) {
m_this._queue.batch(true);
}
}
if (m_this.cache.size < tiles.length) {
console.log('Increasing cache size to ' + tiles.length);
m_this.cache.size = tiles.length;
}
/* Actually get the tiles. */
for (i = 0; i < tiles.length; i += 1) {
tiles[i] = m_this._getTileCached(tiles[i].index, tiles[i].source, true);
}
m_this.cache.purge(m_this.remove);
return tiles;
};
/**
* Get or set the layer gcs. This defaults to the map's gcs.
*
* @param {string} [arg] If `undefined`, return the current gcs. Otherwise,
* a new value for the gcs. If `null`, use the map's gcs.
* @returns {string|this} A string used by {@link geo.transform}.
*/
this.gcs = function (arg) {
if (arg === undefined) {
return m_this._options.gcs || m_this.map().gcs();
}
var previous = m_this.gcs();
if (arg === null) {
delete m_this._options.gcs;
} else {
m_this._options.gcs = arg;
}
if (m_this.gcs() !== previous) {
m_this.clear();
m_this.gcsFeatures(m_this.gcs());
m_this.modified();
m_this._update();
}
return m_this;
};
/**
* Prefetches tiles up to a given zoom level around a given bounding box.
*
* @param {number} level The zoom level.
* @param {geo.geoBounds} bounds The map bounds.
* @returns {jQuery.Deferred} resolves when all of the tiles are fetched.
*/
this.prefetch = function (level, bounds) {
var tiles;
tiles = m_this._getTiles(level, bounds, true);
return $.when.apply($, tiles.map(function (tile) {
return tile.fetch();
}));
};
/**
* This method returns a metric that determines tile loading order. The
* default implementation prioritizes tiles that are closer to the center,
* or at a lower zoom level.
*
* @protected
* @param {object} center The center tile.
* @param {number} center.x
* @param {number} center.y
* @returns {function} A function accepted by `Array.prototype.sort`.
*/
this._loadMetric = function (center) {
return function (a, b) {
var a0, b0, dx, dy, cx, cy, scale;
a = a.index || a;
b = b.index || b;
// shortcut if zoom level differs
if (a.level !== b.level) {
if (center.bottomLevel && ((a.level >= center.bottomLevel) !==
(b.level >= center.bottomLevel))) {
return a.level >= center.bottomLevel ? -1 : 1;
}
return a.level - b.level;
}
/* compute the center coordinates relative to a.level. Since we really
* care about the center of the tiles, use an offset */
scale = Math.pow(2, a.level - center.level);
cx = (center.x + 0.5) * scale - 0.5;
cy = (center.y + 0.5) * scale - 0.5;
// calculate distances to the center squared
dx = a.x - cx;
dy = a.y - cy;
a0 = dx * dx + dy * dy;
dx = b.x - cx;
dy = b.y - cy;
b0 = dx * dx + dy * dy;
// return negative if a < b, or positive if a > b
return a0 - b0;
};
};
/**
* Convert a coordinate from pixel coordinates at the given zoom
* level to world coordinates.
*
* @param {object} coord
* @param {number} coord.x The offset in pixels (level 0) from the left
* edge.
* @param {number} coord.y The offset in pixels (level 0) from the bottom
* edge.
* @param {number} level The zoom level of the source coordinates.
* @returns {object} World coordinates with `x` and `y`.
*/
this.fromLevel = function (coord, level) {
var s = Math.pow(2, -level);
return {
x: coord.x * s,
y: coord.y * s
};
};
/**
* Convert a coordinate from layer coordinates to pixel coordinates at the
* given zoom level.
*
* @param {object} coord
* @param {number} coord.x The offset in pixels (level 0) from the left
* edge.
* @param {number} coord.y The offset in pixels (level 0) from the bottom
* edge.
* @param {number} level The zoom level of the new coordinates.
* @returns {object} The pixel coordinates with `x` and `y`.
*/
this.toLevel = function (coord, level) {
var s = Math.pow(2, level);
return {
x: coord.x * s,
y: coord.y * s
};
};
/**
* Draw the given tile on the active canvas
*.
* @param {geo.tile} tile The tile to draw.
*/
this.drawTile = function (tile) {
var hash = tile.toString();
if (m_this._activeTiles.hasOwnProperty(hash)) {
// the tile is already drawn, move it to the top
m_this._moveToTop(tile);
} else {
// pass to the rendering implementation
m_this._drawTile(tile);
}
// add the tile to the active cache
m_this._activeTiles[hash] = tile;
};
/**
* Render the tile on the canvas. This implementation draws the tiles
* directly on the DOM using <img> tags. Derived classes should override
* this method to draw the tile on a renderer specific context.
*
* @protected
* @param {geo.tile} tile The tile to draw.
*/
this._drawTile = function (tile) {
// Make sure this method is not called when there is
// a renderer attached.
if (m_this.renderer() !== null) {
throw new Error('This draw method is not valid on renderer managed layers.');
}
// get the layer node
var level = tile.index.level,
div = $(m_this._getSubLayer(level)),
bounds = m_this._tileBounds(tile),
duration = m_this._options.animationDuration,
container = $('<div class="geo-tile-container"/>').attr(
'tile-reference', tile.toString()),
crop;
// apply a transform to place the image correctly
container.append(tile.image);
container.css({
left: (bounds.left - parseInt(div.attr('offsetx') || 0, 10)) + 'px',
top: (bounds.top - parseInt(div.attr('offsety') || 0, 10)) + 'px'
});
crop = m_this.tileCropFromBounds(tile);
if (crop) {
container.addClass('crop').css({
width: crop.x + 'px',
height: crop.y + 'px'
});
}
// apply fade in animation
if (duration > 0) {
tile.fadeIn(duration);
}
// append the image element
div.append(container);
// add an error handler
tile.catch(function () {
// May want to do something special here later
console.warn('Could not load tile at ' + tile.toString());
m_this._remove(tile);
});
};
/**
* Remove the given tile from the canvas and the active cache.
*
* @param {geo.tile|string} tile The tile (or hash) to remove.
* @returns {geo.tile} The tile removed from the active layer.
*/
this.remove = function (tile) {
var hash = tile.toString();
var value = m_this._activeTiles[hash];
if (value instanceof Tile) {
m_this._remove(value);
}
delete m_this._activeTiles[hash];
return value;
};
/**
* Remove the given tile from the canvas. This implementation just
* finds and removes the <img> element created for the tile.
*
* @param {geo.tile|string} tile The tile object to remove.
*/
this._remove = function (tile) {
if (tile.image) {
if (tile.image.parentElement) {
$(tile.image.parentElement).remove();
} else {
/* This shouldn't happen, but sometimes does. Originally it happened
* when a tile was removed from the cache before it was finished
* being used; there is still some much rarer condition that can
* cause it. Log that it happened until we can figure out how to fix
* the issue. */
console.log('No parent element to remove ' + tile.toString(), tile);
}
$(tile.image).remove();
}
};
/**
* Move the given tile to the top on the canvas.
*
* @param {geo.tile} tile The tile object to move.
*/
this._moveToTop = function (tile) {
$.noop(tile);
};
/**
* Query the attached map for the current bounds and return them as pixels
* at the current zoom level.
*
* @returns {object} Bounds object with `left`, `right`, `top`, `bottom`,
* `scale`, and `level` keys.
*/
this._getViewBounds = function () {
var map = m_this.map(),
mapZoom = map.zoom(),
zoom = m_this._options.tileRounding(mapZoom),
scale = Math.pow(2, mapZoom - zoom),
size = map.size();
var ul = m_this.displayToLevel({x: 0, y: 0}),
ur = m_this.displayToLevel({x: size.width, y: 0}),
ll = m_this.displayToLevel({x: 0, y: size.height}),
lr = m_this.displayToLevel({x: size.width, y: size.height});
return {
level: zoom,
scale: scale,
left: Math.min(ul.x, ur.x, ll.x, lr.x),
right: Math.max(ul.x, ur.x, ll.x, lr.x),
top: Math.min(ul.y, ur.y, ll.y, lr.y),
bottom: Math.max(ul.y, ur.y, ll.y, lr.y)
};
};
/**
* Remove all inactive tiles from the display. An inactive tile is one
* that is no longer visible either because it was panned out of the active
* view or the zoom has changed.
*
* @protected
* @param {number} zoom Tiles in bounds at this zoom level will be kept.
* @param {boolean} doneLoading If true, allow purging additional tiles.
* @param {geo.geoBounds} bounds View bounds. If not specified, this is
* obtained from _getViewBounds().
* @returns {this}
*/
this._purge = function (zoom, doneLoading, bounds) {
var tile, hash;
// Don't purge tiles in an active update
if (m_this._updating) {
return m_this;
}
// get the view bounds
if (!bounds) {
bounds = m_this._getViewBounds();
}
for (hash in m_this._activeTiles) {
tile = m_this._activeTiles[hash];
if (m_this._canPurge(tile, bounds, zoom, doneLoading)) {
m_this.remove(tile);
}
}
return m_this;
};
/**
* Remove all active tiles from the canvas.
*
* @returns {geo.tile[]} The array of tiles removed.
*/
this.clear = function () {
var tiles = [], tile;
// ignoring the warning here because m_this is a privately
// controlled object with simple keys
for (tile in m_this._activeTiles) {
tiles.push(m_this.remove(tile));
}
// clear out the tile coverage tree
m_this._tileTree = {};
m_lastTileSet = [];
return tiles;
};
/**
* Reset the layer to the initial state, clearing the canvas and resetting
* the tile cache.
*
* @returns {this}
*/
this.reset = function () {
m_this.clear();
m_this._cache.clear();
m_this._queue.initialSize = m_initialQueueSize;
return m_this;
};
/**
* Compute local coordinates from the given world coordinates. The tile
* layer uses units of pixels relative to the world space coordinate
* origin.
*
* @param {object} pt A point in world space coordinates with `x` and `y`.
* @param {number} [zoom] If unspecified, use the map zoom.
* @returns {object} Local coordinates with `x` and `y`.
*/
this.toLocal = function (pt, zoom) {
var map = m_this.map(),
unit = map.unitsPerPixel(zoom === undefined ? map.zoom() : zoom);
return {
x: pt.x / unit,
y: m_this._topDown() * pt.y / unit
};
};
/**
* Compute world coordinates from the given local coordinates. The tile
* layer uses units of pixels relative to the world space coordinate
* origin.
*
* @param {object} pt A point in world space coordinates with `x` and `y`.
* @param {number|undefined} zoom If unspecified, use the map zoom.
* @returns {object} Local coordinates with `x` and `y`.
*/
this.fromLocal = function (pt, zoom) {
// these need to always use the *layer* unitsPerPixel, or possibly
// convert tile space using a transform
var map = m_this.map(),
unit = map.unitsPerPixel(zoom === undefined ? map.zoom() : zoom);
return {
x: pt.x * unit,
y: m_this._topDown() * pt.y * unit
};
};
/**
* Return a factor for inverting the y units as appropriate.
*
* @returns {number} Either 1 to not invert y, or -1 to invert it.
*/
this._topDown = function () {
return m_this._options.topDown ? 1 : -1;
};
/**
* Return the DOM element containing a level specific layer. This will
* create the element if it doesn't already exist.
*
* @param {number} level The zoom level of the layer to fetch.
* @returns {HTMLElement} The layer's DOM element.
*/
this._getSubLayer = function (level) {
if (!m_this.canvas()) {
return;
}
var node = m_this.canvas()
.find('div[data-tile-layer=' + level.toFixed() + ']').get(0);
if (!node) {
node = $(
'<div class=geo-tile-layer data-tile-layer="' + level.toFixed() + '"/>'
).get(0);
m_this.canvas().append(node);
}
return node;
};
/**
* Set sublayer transforms to align them with the given zoom level.
*
* @param {number} level The target zoom level.
* @param {geo.geoBounds} view The view bounds. The top and left are used
* to adjust the offset of tile layers.
* @returns {object} The `x` and `y` offsets for the current level.
*/
this._updateSubLayers = function (level, view) {
var canvas = m_this.canvas(),
lastlevel = parseInt(canvas.attr('lastlevel'), 10),
lastx = parseInt(canvas.attr('lastoffsetx') || 0, 10),
lasty = parseInt(canvas.attr('lastoffsety') || 0, 10);
if (lastlevel === level && Math.abs(lastx - view.left) < 65536 &&
Math.abs(lasty - view.top) < 65536) {
return {x: lastx, y: lasty};
}
var map = m_this.map(),
to = m_this._tileOffset(level),
x = parseInt((view.left + view.right - map.size().width) / 2 + to.x, 10),
y = parseInt((view.top + view.bottom - map.size().height) / 2 + to.y, 10);
canvas.find('.geo-tile-layer').each(function (idx, el) {
var $el = $(el),
layer = parseInt($el.data('tileLayer'), 10);
$el.css(
'transform',
'scale(' + Math.pow(2, level - layer) + ')'
);
var layerx = parseInt(x / Math.pow(2, level - layer), 10),
layery = parseInt(y / Math.pow(2, level - layer), 10),
dx = layerx - parseInt($el.attr('offsetx') || 0, 10),
dy = layery - parseInt($el.attr('offsety') || 0, 10);
$el.attr({offsetx: layerx, offsety: layery});
$el.find('.geo-tile-container').each(function (tileidx, tileel) {
$(tileel).css({
left: (parseInt($(tileel).css('left'), 10) - dx) + 'px',
top: (parseInt($(tileel).css('top'), 10) - dy) + 'px'
});
});
});
canvas.attr({lastoffsetx: x, lastoffsety: y, lastlevel: level});
return {x: x, y: y};
};
/**
* Update the view according to the map/camera.
*
* @param {geo.event} evt The event that triggered the change. Zoom and
* rotate events do nothing, since they are always followed by a pan
* event which will cause appropriate action.
* @returns {this}
*/
this._update = function (evt) {
/* Ignore zoom and rotate events, as they are ALWAYS followed by a pan
* event */
if (evt && evt.event && (evt.event.event === geo_event.zoom ||
evt.event.event === geo_event.rotate)) {
return m_this;
}
if (!m_this.visible()) {
return m_this;
}
var map = m_this.map(),
bounds = map.bounds(undefined, null),
mapZoom = map.zoom(),
zoom = m_this._options.tileRounding(mapZoom),
tiles;
if (m_this._updateSubLayers) {
var view = m_this._getViewBounds();
// Update the transform for the local layer coordinates
var offset = m_this._updateSubLayers(zoom, view) || {x: 0, y: 0};
var to = m_this._tileOffset(zoom);
if (m_this.renderer() === null) {
var scale = Math.pow(2, mapZoom - zoom),
rotation = map.rotation(),
rx = -to.x + -(view.left + view.right) / 2 + offset.x,
ry = -to.y + -(view.bottom + view.top) / 2 + offset.y,
dx = (rx + map.size().width / 2),
dy = (ry + map.size().height / 2);
m_this.canvas().css({
'transform-origin': '' +
-rx + 'px ' +
-ry + 'px'
});
var transform = 'translate(' + dx + 'px' + ',' + dy + 'px' + ')' +
'scale(' + scale + ')';
if (rotation) {
transform += 'rotate(' + (rotation * 180 / Math.PI) + 'deg)';
}
m_this.canvas().css('transform', transform);
}
/* Set some attributes that can be used by non-css based viewers. This
* doesn't include the map center, as that may need to be handled
* differently from the view center. */
m_this.canvas().attr({
scale: Math.pow(2, mapZoom - zoom),
dx: -to.x + -(view.left + view.right) / 2,
dy: -to.y + -(view.bottom + view.top) / 2,
offsetx: offset.x,
offsety: offset.y,
rotation: map.rotation()
});
}
tiles = m_this._getTiles(
zoom, bounds, true, true
);
if (tiles === undefined) {
return m_this;
}
// reset the tile coverage tree
m_this._tileTree = {};
tiles.forEach(function (tile) {
if (tile.fetched()) {
delete m_promisedTiles[tile.toString()];
/* if we have already fetched the tile, we know we can just draw it,
* as the bounds won't have changed since the call to _getTiles. */
m_this.drawTile(tile);
// mark the tile as covered
m_this._setTileTree(tile);
} else {
if (!tile._queued) {
tile.then(function () {
if (m_exited) {
/* If we have disconnected the renderer, do nothing. This
* happens when the layer is being deleted. */
return;
}
if (tile !== m_this.cache.get(tile.toString())) {
/* If the tile has fallen out of the cache, don't draw it -- it
* is untracked. This may be an indication that a larger cache
* should have been used. */
return;
}
/* Check if a tile is still desired. Don't draw it if it
* isn't. */
var mapZoom = map.zoom(),
zoom = m_this._options.tileRounding(mapZoom),
view = m_this._getViewBounds();
if (m_this._canPurge(tile, view, zoom)) {
m_this.remove(tile);
return;
}
m_this.drawTile(tile);
// mark the tile as covered
m_this._setTileTree(tile);
});
tile._queued = true;
} else {
/* If we are using a fetch queue, tell the queue so this tile can
* be reprioritized. */
var pos = m_this._queue ? m_this._queue.get(tile) : -1;
if (pos >= 0) {
m_this._queue.add(tile);
}
}
m_this.addPromise(tile);
m_promisedTiles[tile.toString()] = tile;
}
});
// purge all old tiles when the new tiles are loaded (successfully or not)
$.when.apply($, tiles)
.done(// called on success and failure
function () {
var map = m_this.map(),
mapZoom = map.zoom(),
zoom = m_this._options.tileRounding(mapZoom);
m_this._purge(zoom, true);
}
);
// for tiles that aren't in view, remove them from the list of tiles that
// are needed to be loaded to be considered idle.
if (m_this._options.idleAfter !== 'all') {
for (const hash in m_promisedTiles) {
const tile = m_promisedTiles[hash];
if (tiles.indexOf(tile) < 0) {
m_this.removePromise(tile);
delete m_promisedTiles[hash];
}
}
}
return m_this;
};
/**
* Set a value in the tile tree object indicating that the given area of
* the canvas is covered by the tile.
*
* @protected
* @param {geo.tile} tile The tile to add.
*/
this._setTileTree = function (tile) {
if (m_this._options.keepLower) {
return;
}
var index = tile.index;
m_this._tileTree[index.level] = m_this._tileTree[index.level] || {};
m_this._tileTree[index.level][index.x] = m_this._tileTree[index.level][index.x] || {};
m_this._tileTree[index.level][index.x][index.y] = tile;
};
/**
* Get a value in the tile tree object if it exists or return `null`.
* @protected
* @param {object} index A tile index object
* @param {object} index.level
* @param {object} index.x
* @param {object} index.y
* @returns {geo.tile|null}
*/
this._getTileTree = function (index) {
return (
(m_this._tileTree[index.level] || {})[index.x] || {}
)[index.y] || null;
};
/**
* Returns true if the tile is completely covered by other tiles on the
* canvas. Currently this method only checks layers +/- 1 away from
* `tile`. If the zoom level is allowed to change by 2 or more in a single
* update step, this method will need to be refactored to make a more
* robust check. Returns an array of tiles covering it or null if any
* part of the tile is exposed.
*
* @protected
* @param {geo.tile} tile The tile to check.
* @returns {geo.tile[]|null}
*/
this._isCovered = function (tile) {
var level = tile.index.level,
x = tile.index.x,
y = tile.index.y,
tiles = [];
// Check one level up
tiles = m_this._getTileTree({
level: level - 1,
x: Math.floor(x / 2),
y: Math.floor(y / 2)
});
if (tiles) {
return [tiles];
}
// Check one level down
tiles = [
m_this._getTileTree({
level: level + 1,
x: 2 * x,
y: 2 * y
}),
m_this._getTileTree({
level: level + 1,
x: 2 * x + 1,
y: 2 * y
}),
m_this._getTileTree({
level: level + 1,
x: 2 * x,
y: 2 * y + 1
}),
m_this._getTileTree({
level: level + 1,
x: 2 * x + 1,
y: 2 * y + 1
})
];
if (tiles.every(function (t) { return t !== null; })) {
return tiles;
}
return null;
};
/**
* Returns true if the provided tile is outside of the current view bounds
* and can be removed from the canvas.
* @protected
* @param {geo.tile} tile The tile to check.
* @param {geo.geoBounds} bounds The view bounds.
* @returns {boolean}
*/
this._outOfBounds = function (tile, bounds) {
/* We may want to add an (n) tile edge buffer so we appear more
* responsive */
var to = m_this._tileOffset(tile.index.level);
var scale = 1;
if (tile.index.level !== bounds.level) {
scale = Math.pow(2, (bounds.level || 0) - (tile.index.level || 0));
}
return (tile.bottom - to.y) * scale < bounds.top ||
(tile.left - to.x) * scale > bounds.right ||
(tile.top - to.y) * scale > bounds.bottom ||
(tile.right - to.x) * scale < bounds.left;
};
/**
* Returns true if the provided tile can be purged from the canvas. This
* method will return `true` if the tile is completely covered by one or
* more other tiles or it is outside of the active view bounds. This
* method returns the logical and of `_isCovered` and `_outOfBounds`.
* @protected
* @param {geo.tile} tile The tile to check.
* @param {geo.geoBounds} [bounds] The view bounds (if unspecified, assume
* global bounds)
* @param {number} bounds.level The zoom level the bounds are given as.
* @param {number} [zoom] Keep in bound tile at this zoom level.
* @param {boolean} [doneLoading] If true, allow purging additional tiles.
* @returns {boolean}
*/
this._canPurge = function (tile, bounds, zoom, doneLoading) {
if (m_this._options.keepLower) {
zoom = zoom || 0;
if (zoom < tile.index.level &&
tile.index.level !== m_this._options.minLevel) {
return true;
}
if (tile.index.level === m_this._options.minLevel &&
!m_this._options.wrapX && !m_this._options.wrapY) {
return false;
}
} else {
/* For tile layers that should only keep one layer, if loading is
* finished, purge all but the current layer. This is important for
* semi-transparent layers. */
if ((doneLoading || m_this._isCovered(tile)) &&
zoom !== tile.index.level &&
(zoom >= m_this._options.minLevel || tile.index.level !== m_this._options.minLevel) &&
(zoom < m_this._options.maxLevel || tile.index.level !== m_this._options.maxLevel)
) {
return true;
}
}
if (bounds) {
return m_this._outOfBounds(tile, bounds);
}
return false;
};
/**
* Convert display pixel coordinates (where (0,0) is the upper left) to
* layer pixel coordinates (typically (0,0) is the center of the map and
* the upper-left has the most negative values).
* By default, this is done at the current base zoom level.
*
* @param {object} [pt] The point to convert with `x` and `y`. If
* `undefined`, use the center of the display.
* @param {number} [zoom] If specified, the zoom level to use.
* @returns {object} The point in level coordinates with `x` and `y`.
*/
this.displayToLevel = function (pt, zoom) {
var map = m_this.map(),
mapzoom = map.zoom(),
roundzoom = m_this._options.tileRounding(mapzoom),
unit = map.unitsPerPixel(zoom === undefined ? roundzoom : zoom),
gcsPt;
if (pt === undefined) {
var size = map.size();
pt = {x: size.width / 2, y: size.height / 2};
}
/* displayToGcs can fail under certain projections. If this happens,
* just return the origin. */
try {
gcsPt = map.displayToGcs(pt, m_this._options.gcs || null);
} catch (err) {
gcsPt = {x: 0, y: 0};
}
/* Reverse the y coordinate, since we expect the gcs coordinate system
* to be right-handed and the level coordinate system to be
* left-handed. */
var lvlPt = {x: gcsPt.x / unit, y: m_this._topDown() * gcsPt.y / unit};
return lvlPt;
};
/**
* Get or set the tile url string or function. If changed, load the new
* tiles.
*
* @param {string|function} [url] The new tile url.
* @returns {string|function|this}
*/
this.url = function (url) {
if (url === undefined) {
return m_this._options.originalUrl;
}
if (url === m_this._options.originalUrl) {
return m_this;
}
m_this._options.originalUrl = url;
if ($.type(url) === 'string') {
url = m_tileUrlFromTemplate(url);
}
m_this._options.url = url;
m_this.reset();
m_this.map().draw();
return m_this;
};
/**
* Get or set the subdomains used for templating.
*
* @param {string|string[]} [subdomains] A comma-separated list, a string of
* single character subdomains, or a list.
* @returns {string|string[]|this}
*/
this.subdomains = function (subdomains) {
if (subdomains === undefined) {
return m_this._options.subdomains;
}
if (subdomains) {
if ($.type(subdomains) === 'string') {
if (subdomains.indexOf(',') >= 0) {
subdomains = subdomains.split(',');
} else {
subdomains = subdomains.split('');
}
}
m_this._options.subdomains = subdomains;
m_this.reset();
m_this.map().draw();
}
return m_this;
};
/**
* Return a value from the tileOffset function, caching it for different
* levels.
*
* @param {number} level The level to pass to the tileOffset function.
* @returns {object} A tile offset object with `x` and `y` properties.
*/
this._tileOffset = function (level) {
if (m_tileOffsetValues[level] === undefined) {
m_tileOffsetValues[level] = m_this._options.tileOffset(level);
}
return m_tileOffsetValues[level];
};
/**
* Get/Set visibility of the layer.
*
* @param {boolean} [val] If unspecified, return the visibility, otherwise
* set it.
* @returns {boolean|this} Either the visibility (if getting) or the layer
* (if setting).
*/
this.visible = function (val) {
if (val === undefined) {
return s_visible();
}
if (m_this.visible() !== val) {
s_visible(val);
if (val) {
m_this._update();
}
}
return m_this;
};
/**
* Get/Set nearestPixel value.
*
* @param {boolean|number} [val] If not specified, return the current value.
* If true, image quads are rendered with near-neighbor sampling. If
* false, with interpolated sampling. If a number, interpolate at that
* zoom level or below and nearest neighbor at that zoom level or above.
* @param {boolean} [skipUpdate] If specifying val and this value is truthy,
* don't update the layer or mark it as modified.
* @returns {boolean|number|this}
*/
this.nearestPixel = function (val, skipUpdate) {
if (val === undefined) {
return m_nearestPixel;
}
if (m_nearestPixel !== val) {
m_nearestPixel = val;
if (!skipUpdate) {
m_this.modified();
m_this._update();
}
}
return m_this;
};
/**
* Get/set the baseQuad.
*
* @property {object} [baseQuad] A quad feature element to draw below any
* tile layers. If specified, this uses the quad defaults, so this is a
* ``geo.quadFeature.position`` object with, typically, an ``image``
* property added to it. The quad positions are in the map gcs
* coordinates.
* @name geo.tileLayer.baseQuad
*/
Object.defineProperty(this, 'baseQuad', {
get: function () { return m_this._options.baseQuad; },
set: function (baseQuad) {
m_this._options.baseQuad = baseQuad;
m_this._update();
}
});
this._addBaseQuadToTiles = function (quadFeature, tiles) {
if (quadFeature) {
if (this.baseQuad !== m_lastBaseQuad) {
if (m_lastBaseQuad) {
tiles.splice(0, 1);
}
m_lastBaseQuad = this.baseQuad;
if (m_lastBaseQuad) {
tiles.splice(0, 0, this.baseQuad);
quadFeature.cacheUpdate(0);
}
quadFeature.data(tiles);
}
quadFeature._update();
}
};
/**
* Initialize after the layer is added to the map.
*
* @returns {this}
*/
this._init = function () {
var sublayer;
// call super method
s_init.apply(m_this, arguments);
if (m_this.renderer() === null) {
// Initialize sublayers in the correct order
for (sublayer = 0; sublayer <= m_this._options.maxLevel; sublayer += 1) {
m_this._getSubLayer(sublayer);
}
}
return m_this;
};
/**
* Clean up the layer.
*
* @returns {this}
*/
this._exit = function () {
m_this.reset();
// call super method
s_exit.apply(m_this, arguments);
m_exited = true;
if (this._queue && this._queue._tileLayers && this._queue._tileLayers.indexOf(m_this) >= 0) {
this._queue._tileLayers.splice(this._queue._tileLayers.indexOf(m_this), 1);
if (this._queue.initialTrack && this._queue.track && this._queue._tileLayers.length) {
this._queue.track = this._queue.initialTrack * this._queue._tileLayers.length;
}
}
return m_this;
};
adjustLayerForRenderer('tile', this);
return this;
};
/**
* This object contains the default options used to initialize the tileLayer.
*/
tileLayer.defaults = {
minLevel: 0,
maxLevel: 18,
tileOverlap: {x: 0, y: 0},
tileWidth: 256,
tileHeight: 256,
wrapX: true,
wrapY: false,
url: null,
subdomains: 'abc',
tileOffset: function (level) {
return {x: 0, y: 0};
},
tilesMaxBounds: null,
topDown: false,
keepLower: true,
idleAfter: 'view',
// cacheSize: 600, // set depending on keepLower
tileRounding: Math.round,
attribution: '',
animationDuration: 0
};
inherit(tileLayer, featureLayer);
module.exports = tileLayer;