mapbox-gl
Version:
A WebGL interactive maps library
745 lines (647 loc) • 23.1 kB
JavaScript
'use strict';
var Canvas = require('../util/canvas');
var util = require('../util/util');
var browser = require('../util/browser');
var Evented = require('../util/evented');
var DOM = require('../util/dom');
var Style = require('../style/style');
var AnimationLoop = require('../style/animation_loop');
var Painter = require('../render/painter');
var Transform = require('../geo/transform');
var Hash = require('./hash');
var Handlers = require('./handlers');
var Camera = require('./camera');
var LatLng = require('../geo/lat_lng');
var LatLngBounds = require('../geo/lat_lng_bounds');
var Point = require('point-geometry');
var Attribution = require('./control/attribution');
/**
* Options common to Map#addClass, Map#removeClass, and Map#setClasses, controlling
* whether or not to smoothly transition property changes triggered by the class change.
*
* @typedef {Object} StyleOptions
* @property {boolean} transition
*/
/**
* Creates a map instance.
* @class Map
* @param {Object} options
* @param {string} options.container HTML element to initialize the map in (or element id as string)
* @param {number} [options.minZoom=0] Minimum zoom of the map
* @param {number} [options.maxZoom=20] Maximum zoom of the map
* @param {Object} options.style Map style and data source definition (either a JSON object or a JSON URL), described in the [style reference](https://mapbox.com/mapbox-gl-style-spec/)
* @param {boolean} [options.hash=false] If `true`, the map will track and update the page URL according to map position
* @param {boolean} [options.interactive=true] If `false`, no mouse, touch, or keyboard listeners are attached to the map, so it will not respond to input
* @param {Array} options.classes Style class names with which to initialize the map
* @param {boolean} [options.failIfMajorPerformanceCaveat=false] If `true`, map creation will fail if the implementation determines that the performance of the created WebGL context would be dramatically lower than expected.
* @param {boolean} [options.preserveDrawingBuffer=false] If `true`, The maps canvas can be exported to a PNG using `map.getCanvas().toDataURL();`. This is false by default as a performance optimization.
* @example
* var map = new mapboxgl.Map({
* container: 'map',
* center: [37.772537, -122.420679],
* zoom: 13,
* style: style_object,
* hash: true
* });
*/
var Map = module.exports = function(options) {
options = this.options = util.inherit(this.options, options);
this.animationLoop = new AnimationLoop();
this.transform = new Transform(options.minZoom, options.maxZoom);
if (options.maxBounds) {
var b = LatLngBounds.convert(options.maxBounds);
this.transform.latRange = [b.getSouth(), b.getNorth()];
this.transform.lngRange = [b.getWest(), b.getEast()];
}
util.bindAll([
'_forwardStyleEvent',
'_forwardSourceEvent',
'_forwardLayerEvent',
'_forwardTileEvent',
'_onStyleLoad',
'_onStyleChange',
'_onSourceAdd',
'_onSourceRemove',
'_onSourceUpdate',
'update',
'render'
], this);
this._setupContainer();
this._setupPainter();
this.on('move', this.update);
this.on('zoom', this.update.bind(this, true));
this.on('moveend', function() {
this.animationLoop.set(300); // text fading
this._rerender();
}.bind(this));
this.handlers = options.interactive && new Handlers(this);
this._hash = options.hash && (new Hash()).addTo(this);
// don't set position from options if set through hash
if (!this._hash || !this._hash._onHashChange()) {
this.jumpTo(options);
}
this.sources = {};
this.stacks = {};
this._classes = {};
this.resize();
if (options.classes) this.setClasses(options.classes);
if (options.style) this.setStyle(options.style);
if (options.attributionControl) this.addControl(new Attribution());
};
util.extend(Map.prototype, Evented);
util.extend(Map.prototype, Camera.prototype);
util.extend(Map.prototype, /** @lends Map.prototype */{
options: {
center: [0, 0],
zoom: 0,
bearing: 0,
pitch: 0,
minZoom: 0,
maxZoom: 20,
interactive: true,
hash: false,
attributionControl: true,
failIfMajorPerformanceCaveat: false,
preserveDrawingBuffer: false
},
addControl: function(control) {
control.addTo(this);
return this;
},
/**
* Adds a style class to a map
*
* @param {string} klass name of style class
* @param {StyleOptions} [options]
* @fires change
* @returns {Map} `this`
*/
addClass: function(klass, options) {
if (this._classes[klass]) return;
this._classes[klass] = true;
if (this.style) this.style._cascade(this._classes, options);
},
/**
* Removes a style class from a map
*
* @param {string} klass name of style class
* @param {StyleOptions} [options]
* @fires change
* @returns {Map} `this`
*/
removeClass: function(klass, options) {
if (!this._classes[klass]) return;
delete this._classes[klass];
if (this.style) this.style._cascade(this._classes, options);
},
/**
* Helper method to add more than one class
*
* @param {Array<string>} klasses An array of class names
* @param {StyleOptions} [options]
* @fires change
* @returns {Map} `this`
*/
setClasses: function(klasses, options) {
this._classes = {};
for (var i = 0; i < klasses.length; i++) {
this._classes[klasses[i]] = true;
}
if (this.style) this.style._cascade(this._classes, options);
},
/**
* Check whether a style class is active
*
* @param {string} klass Name of style class
* @returns {boolean}
*/
hasClass: function(klass) {
return !!this._classes[klass];
},
/**
* Return an array of the current active style classes
*
* @returns {boolean}
*/
getClasses: function() {
return Object.keys(this._classes);
},
/**
* Detect the map's new width and height and resize it.
*
* @returns {Map} `this`
*/
resize: function() {
var width = 0, height = 0;
if (this._container) {
width = this._container.offsetWidth || 400;
height = this._container.offsetHeight || 300;
}
this._canvas.resize(width, height);
this.transform.width = width;
this.transform.height = height;
this.transform._constrain();
this.painter.resize(width, height);
return this
.fire('movestart')
.fire('move')
.fire('resize')
.fire('moveend');
},
/**
* Get the map's geographical bounds
*
* @returns {LatLngBounds}
*/
getBounds: function() {
return new LatLngBounds(
this.transform.pointLocation(new Point(0, 0)),
this.transform.pointLocation(this.transform.size));
},
/**
* Get pixel coordinates (relative to map container) given a geographical location
*
* @param {LatLng} latlng
* @returns {Object} `x` and `y` coordinates
*/
project: function(latlng) {
return this.transform.locationPoint(LatLng.convert(latlng));
},
/**
* Get geographical coordinates given pixel coordinates
*
* @param {Array<number>} point [x, y] pixel coordinates
* @returns {LatLng}
*/
unproject: function(point) {
return this.transform.pointLocation(Point.convert(point));
},
/**
* Get all features at a point ([x, y])
*
* @param {Array<number>} point [x, y] pixel coordinates
* @param {Object} params
* @param {number} [params.radius=0] Optional. Radius in pixels to search in
* @param {string} params.layer Optional. Only return features from a given layer
* @param {string} params.type Optional. Either `raster` or `vector`
* @param {featuresAtCallback} callback function that returns the response
*
* @callback featuresAtCallback
* @param {Object|null} err Error _If any_
* @param {Array} features Displays a JSON array of features given the passed parameters of `featuresAt`
*
* @returns {Map} `this`
*
* @example
* map.featuresAt([10, 20], { radius: 10 }, function(err, features) {
* console.log(features);
* });
*/
featuresAt: function(point, params, callback) {
var coord = this.transform.pointCoordinate(Point.convert(point));
this.style.featuresAt(coord, params, callback);
return this;
},
/**
* Replaces the map's style object
*
* @param {Object} style A style object formatted as JSON
* @returns {Map} `this`
*/
setStyle: function(style) {
if (this.style) {
this.style
.off('load', this._onStyleLoad)
.off('error', this._forwardStyleEvent)
.off('change', this._onStyleChange)
.off('source.add', this._onSourceAdd)
.off('source.remove', this._onSourceRemove)
.off('source.load', this._onSourceUpdate)
.off('source.error', this._forwardSourceEvent)
.off('source.change', this._onSourceUpdate)
.off('layer.add', this._forwardLayerEvent)
.off('layer.remove', this._forwardLayerEvent)
.off('tile.add', this._forwardTileEvent)
.off('tile.remove', this._forwardTileEvent)
.off('tile.load', this.update)
.off('tile.error', this._forwardTileEvent)
._remove();
this.off('rotate', this.style._redoPlacement);
this.off('pitch', this.style._redoPlacement);
}
if (!style) {
this.style = null;
return this;
} else if (style instanceof Style) {
this.style = style;
} else {
this.style = new Style(style, this.animationLoop);
}
this.style
.on('load', this._onStyleLoad)
.on('error', this._forwardStyleEvent)
.on('change', this._onStyleChange)
.on('source.add', this._onSourceAdd)
.on('source.remove', this._onSourceRemove)
.on('source.load', this._onSourceUpdate)
.on('source.error', this._forwardSourceEvent)
.on('source.change', this._onSourceUpdate)
.on('layer.add', this._forwardLayerEvent)
.on('layer.remove', this._forwardLayerEvent)
.on('tile.add', this._forwardTileEvent)
.on('tile.remove', this._forwardTileEvent)
.on('tile.load', this.update)
.on('tile.error', this._forwardTileEvent);
this.on('rotate', this.style._redoPlacement);
this.on('pitch', this.style._redoPlacement);
return this;
},
/**
* Add a source to the map style.
*
* @param {string} id ID of the source. Must not be used by any existing source.
* @param {Object} source source specification, following the
* [Mapbox GL Style Reference](https://www.mapbox.com/mapbox-gl-style-spec/#sources)
* @fires source.add
* @returns {Map} `this`
*/
addSource: function(id, source) {
this.style.addSource(id, source);
return this;
},
/**
* Remove an existing source from the map style.
*
* @param {string} id ID of the source to remove
* @fires source.remove
* @returns {Map} `this`
*/
removeSource: function(id) {
this.style.removeSource(id);
return this;
},
/**
* Return the style source object with the given `id`.
*
* @param {string} id source ID
* @returns {Object}
*/
getSource: function(id) {
return this.style.getSource(id);
},
/**
* Add a layer to the map style. The layer will be inserted before the layer with
* ID `before`, or appended if `before` is omitted.
* @param {StyleLayer|Object} layer
* @param {string=} before ID of an existing layer to insert before
* @fires layer.add
* @returns {Map} `this`
*/
addLayer: function(layer, before) {
this.style.addLayer(layer, before);
this.style._cascade(this._classes);
return this;
},
/**
* Remove the layer with the given `id` from the map. Any layers which refer to the
* specified layer via a `ref` property are also removed.
*
* @param {string} id layer id
* @fires layer.remove
* @returns {Map} this
*/
removeLayer: function(id) {
this.style.removeLayer(id);
this.style._cascade(this._classes);
return this;
},
/**
* Set the filter for a given style layer.
*
* @param {string} layer ID of a layer
* @param {Array} filter filter specification, as defined in the [Style Specification](https://www.mapbox.com/mapbox-gl-style-spec/#filter)
* @returns {Map} `this`
*/
setFilter: function(layer, filter) {
this.style.setFilter(layer, filter);
return this;
},
/**
* Get the filter for a given style layer.
*
* @param {string} layer ID of a layer
* @returns {Array} filter specification, as defined in the [Style Specification](https://www.mapbox.com/mapbox-gl-style-spec/#filter)
*/
getFilter: function(layer) {
return this.style.getFilter(layer);
},
/**
* Set the value of a paint property in a given style layer.
*
* @param {string} layer ID of a layer
* @param {string} name name of a paint property
* @param {*} value value for the paint propery; must have the type appropriate for the property as defined in the [Style Specification](https://www.mapbox.com/mapbox-gl-style-spec/)
* @param {string=} klass optional class specifier for the property
* @returns {Map} `this`
*/
setPaintProperty: function(layer, name, value, klass) {
this.style.setPaintProperty(layer, name, value, klass);
this.style._cascade(this._classes);
this.update(true);
return this;
},
/**
* Get the value of a paint property in a given style layer.
*
* @param {string} layer ID of a layer
* @param {string} name name of a paint property
* @param {string=} klass optional class specifier for the property
* @returns {*} value for the paint propery
*/
getPaintProperty: function(layer, name, klass) {
return this.style.getPaintProperty(layer, name, klass);
},
/**
* Set the value of a layout property in a given style layer.
*
* @param {string} layer ID of a layer
* @param {string} name name of a layout property
* @param {*} value value for the layout propery; must have the type appropriate for the property as defined in the [Style Specification](https://www.mapbox.com/mapbox-gl-style-spec/)
* @returns {Map} `this`
*/
setLayoutProperty: function(layer, name, value) {
this.style.setLayoutProperty(layer, name, value);
return this;
},
/**
* Get the value of a layout property in a given style layer.
*
* @param {string} layer ID of a layer
* @param {string} name name of a layout property
* @param {string=} klass optional class specifier for the property
* @returns {*} value for the layout propery
*/
getLayoutProperty: function(layer, name) {
return this.style.getLayoutProperty(layer, name);
},
/**
* Get the Map's container as an HTML element
* @returns {HTMLElement} container
*/
getContainer: function() {
return this._container;
},
/**
* Get the container for the map `canvas` element.
*
* If you want to add non-GL overlays to the map, you should append them to this element. This
* is the element to which event bindings for map interactivity such as panning and zooming are
* attached. It will receive bubbled events for child elements such as the `canvas`, but not for
* map controls.
*
* @returns {HTMLElement} container
*/
getCanvasContainer: function() {
return this._canvasContainer;
},
/**
* Get the Map's canvas as an HTML canvas
* @returns {HTMLElement} canvas
*/
getCanvas: function() {
return this._canvas.getElement();
},
_setupContainer: function() {
var id = this.options.container;
var container = this._container = typeof id === 'string' ? document.getElementById(id) : id;
container.classList.add('mapboxgl-map');
var canvasContainer = this._canvasContainer = DOM.create('div', 'mapboxgl-canvas-container', container);
if (this.options.interactive) {
canvasContainer.classList.add('mapboxgl-interactive');
}
this._canvas = new Canvas(this, canvasContainer);
var controlContainer = DOM.create('div', 'mapboxgl-control-container', container);
var corners = this._controlCorners = {};
['top-left', 'top-right', 'bottom-left', 'bottom-right'].forEach(function (pos) {
corners[pos] = DOM.create('div', 'mapboxgl-ctrl-' + pos, controlContainer);
});
},
_setupPainter: function() {
var gl = this._canvas.getWebGLContext({
failIfMajorPerformanceCaveat: this.options.failIfMajorPerformanceCaveat,
preserveDrawingBuffer: this.options.preserveDrawingBuffer
});
if (!gl) {
console.error('Failed to initialize WebGL');
return;
}
this.painter = new Painter(gl, this.transform);
},
_contextLost: function(event) {
event.preventDefault();
if (this._frameId) {
browser.cancelFrame(this._frameId);
}
},
_contextRestored: function() {
this._setupPainter();
this.resize();
this.update();
},
/**
* Is this map fully loaded? If the style isn't loaded
* or it has a change to the sources or style that isn't
* propagated to its style, return false.
*
* @returns {boolean} whether the map is loaded
*/
loaded: function() {
if (this._styleDirty || this._sourcesDirty)
return false;
if (this.style && !this.style.loaded())
return false;
return true;
},
/**
* Update this map's style and re-render the map.
*
* @param {Object} updateStyle new style
* @returns {Map} this
*/
update: function(updateStyle) {
if (!this.style) return this;
this._styleDirty = this._styleDirty || updateStyle;
this._sourcesDirty = true;
this._rerender();
return this;
},
/**
* Call when a (re-)render of the map is required, e.g. when the
* user panned or zoomed,f or new data is available.
* @returns {Map} this
*/
render: function() {
if (this.style && this._styleDirty) {
this._styleDirty = false;
this.style._recalculate(this.transform.zoom);
}
if (this.style && this._sourcesDirty && !this._sourcesDirtyTimeout) {
this._sourcesDirty = false;
this._sourcesDirtyTimeout = setTimeout(function() {
this._sourcesDirtyTimeout = null;
}.bind(this), 50);
this.style._updateSources(this.transform);
}
this.painter.render(this.style, {
debug: this.debug,
vertices: this.vertices,
rotating: this.rotating,
zooming: this.zooming
});
this.fire('render');
if (this.loaded() && !this._loaded) {
this._loaded = true;
this.fire('load');
}
this._frameId = null;
if (!this.animationLoop.stopped()) {
this._styleDirty = true;
}
if (this._sourcesDirty || this._repaint || !this.animationLoop.stopped()) {
this._rerender();
}
return this;
},
/**
* Destroys the map's underlying resources, including web workers.
* @returns {Map} this
*/
remove: function() {
if (this._hash) this._hash.remove();
browser.cancelFrame(this._frameId);
clearTimeout(this._sourcesDirtyTimeout);
this.setStyle(null);
return this;
},
_rerender: function() {
if (this.style && !this._frameId) {
this._frameId = browser.frame(this.render);
}
},
_forwardStyleEvent: function(e) {
this.fire('style.' + e.type, util.extend({style: e.target}, e));
},
_forwardSourceEvent: function(e) {
this.fire(e.type, util.extend({style: e.target}, e));
},
_forwardLayerEvent: function(e) {
this.fire(e.type, util.extend({style: e.target}, e));
},
_forwardTileEvent: function(e) {
this.fire(e.type, util.extend({style: e.target}, e));
},
_onStyleLoad: function(e) {
this.style._cascade(this._classes, {transition: false});
this._forwardStyleEvent(e);
},
_onStyleChange: function(e) {
this.update(true);
this._forwardStyleEvent(e);
},
_onSourceAdd: function(e) {
var source = e.source;
if (source.onAdd)
source.onAdd(this);
this._forwardSourceEvent(e);
},
_onSourceRemove: function(e) {
var source = e.source;
if (source.onRemove)
source.onRemove(this);
this._forwardSourceEvent(e);
},
_onSourceUpdate: function(e) {
this.update();
this._forwardSourceEvent(e);
}
});
util.extendAll(Map.prototype, /** @lends Map.prototype */{
/**
* Enable debugging mode
*
* @name debug
* @type {boolean}
*/
_debug: false,
get debug() { return this._debug; },
set debug(value) { this._debug = value; this.update(); },
/**
* Show collision boxes: useful for debugging label placement
* in styles.
*
* @name collisionDebug
* @type {boolean}
*/
_collisionDebug: false,
get collisionDebug() { return this._collisionDebug; },
set collisionDebug(value) {
this._collisionDebug = value;
for (var i in this.style.sources) {
this.style.sources[i].reload();
}
this.update();
},
/**
* Enable continuous repaint to analyze performance
*
* @name repaint
* @type {boolean}
*/
_repaint: false,
get repaint() { return this._repaint; },
set repaint(value) { this._repaint = value; this.update(); },
// show vertices
_vertices: false,
get vertices() { return this._vertices; },
set vertices(value) { this._vertices = value; this.update(); }
});