mapbox-gl
Version:
A WebGL interactive maps library
907 lines (744 loc) • 29.1 kB
JavaScript
const assert = require('assert');
const Evented = require('../util/evented');
const StyleLayer = require('./style_layer');
const ImageSprite = require('./image_sprite');
const Light = require('./light');
const GlyphSource = require('../symbol/glyph_source');
const SpriteAtlas = require('../symbol/sprite_atlas');
const LineAtlas = require('../render/line_atlas');
const util = require('../util/util');
const ajax = require('../util/ajax');
const mapbox = require('../util/mapbox');
const browser = require('../util/browser');
const Dispatcher = require('../util/dispatcher');
const AnimationLoop = require('./animation_loop');
const validateStyle = require('./validate_style');
const Source = require('../source/source');
const QueryFeatures = require('../source/query_features');
const SourceCache = require('../source/source_cache');
const styleSpec = require('../style-spec/reference/latest');
const MapboxGLFunction = require('../style-spec/function');
const getWorkerPool = require('../util/global_worker_pool');
const deref = require('../style-spec/deref');
const diff = require('../style-spec/diff');
const rtlTextPlugin = require('../source/rtl_text_plugin');
const supportedDiffOperations = util.pick(diff.operations, [
'addLayer',
'removeLayer',
'setPaintProperty',
'setLayoutProperty',
'setFilter',
'addSource',
'removeSource',
'setLayerZoomRange',
'setLight',
'setTransition'
// 'setGlyphs',
// 'setSprite',
]);
const ignoredDiffOperations = util.pick(diff.operations, [
'setCenter',
'setZoom',
'setBearing',
'setPitch'
]);
/**
* @private
*/
class Style extends Evented {
constructor(stylesheet, map, options) {
super();
this.map = map;
this.animationLoop = (map && map.animationLoop) || new AnimationLoop();
this.dispatcher = new Dispatcher(getWorkerPool(), this);
this.spriteAtlas = new SpriteAtlas(1024, 1024);
this.spriteAtlas.setEventedParent(this);
this.lineAtlas = new LineAtlas(256, 512);
this._layers = {};
this._order = [];
this.sourceCaches = {};
this.zoomHistory = {};
this._loaded = false;
util.bindAll(['_redoPlacement'], this);
this._resetUpdates();
options = util.extend({
validate: typeof stylesheet === 'string' ? !mapbox.isMapboxURL(stylesheet) : true
}, options);
this.setEventedParent(map);
this.fire('dataloading', {dataType: 'style'});
const self = this;
this._rtlTextPluginCallback = rtlTextPlugin.registerForPluginAvailability((args) => {
self.dispatcher.broadcast('loadRTLTextPlugin', args.pluginBlobURL, args.errorCallback);
for (const id in self.sourceCaches) {
self.sourceCaches[id].reload(); // Should be a no-op if the plugin loads before any tiles load
}
});
const stylesheetLoaded = (err, stylesheet) => {
if (err) {
this.fire('error', {error: err});
return;
}
if (options.validate && validateStyle.emitErrors(this, validateStyle(stylesheet))) return;
this._loaded = true;
this.stylesheet = stylesheet;
this.updateClasses();
for (const id in stylesheet.sources) {
this.addSource(id, stylesheet.sources[id], options);
}
if (stylesheet.sprite) {
this.sprite = new ImageSprite(stylesheet.sprite, this);
}
this.glyphSource = new GlyphSource(stylesheet.glyphs, options.localIdeographFontFamily, this);
this._resolve();
this.fire('data', {dataType: 'style'});
this.fire('style.load');
};
if (typeof stylesheet === 'string') {
ajax.getJSON(mapbox.normalizeStyleURL(stylesheet), stylesheetLoaded);
} else {
browser.frame(stylesheetLoaded.bind(this, null, stylesheet));
}
this.on('data', (event) => {
if (event.dataType === 'source' && event.sourceDataType === 'metadata') {
const source = !!this.sourceCaches[event.sourceId] && this.sourceCaches[event.sourceId].getSource();
if (source && source.vectorLayerIds) {
for (const layerId in this._layers) {
const layer = this._layers[layerId];
if (layer.source === source.id) {
this._validateLayer(layer);
}
}
}
}
});
}
_validateLayer(layer) {
const sourceCache = this.sourceCaches[layer.source];
if (!layer.sourceLayer) return;
if (!sourceCache) return;
const source = sourceCache.getSource();
if (source.type === 'geojson' || (source.vectorLayerIds &&
source.vectorLayerIds.indexOf(layer.sourceLayer) === -1)) {
this.fire('error', {
error: new Error(
`Source layer "${layer.sourceLayer}" ` +
`does not exist on source "${source.id}" ` +
`as specified by style layer "${layer.id}"`
)
});
}
}
loaded() {
if (!this._loaded)
return false;
if (Object.keys(this._updatedSources).length)
return false;
for (const id in this.sourceCaches)
if (!this.sourceCaches[id].loaded())
return false;
if (this.sprite && !this.sprite.loaded())
return false;
return true;
}
_resolve() {
const layers = deref(this.stylesheet.layers);
this._order = layers.map((layer) => layer.id);
this._layers = {};
for (let layer of layers) {
layer = StyleLayer.create(layer);
layer.setEventedParent(this, {layer: {id: layer.id}});
this._layers[layer.id] = layer;
}
this.dispatcher.broadcast('setLayers', this._serializeLayers(this._order));
this.light = new Light(this.stylesheet.light);
}
_serializeLayers(ids) {
return ids.map((id) => this._layers[id].serialize());
}
_applyClasses(classes, options) {
if (!this._loaded) return;
classes = classes || [];
options = options || {transition: true};
const transition = this.stylesheet.transition || {};
const layers = this._updatedAllPaintProps ? this._layers : this._updatedPaintProps;
for (const id in layers) {
const layer = this._layers[id];
const props = this._updatedPaintProps[id];
if (this._updatedAllPaintProps || props.all) {
layer.updatePaintTransitions(classes, options, transition, this.animationLoop, this.zoomHistory);
} else {
for (const paintName in props) {
this._layers[id].updatePaintTransition(paintName, classes, options, transition, this.animationLoop, this.zoomHistory);
}
}
}
this.light.updateLightTransitions(options, transition, this.animationLoop);
}
_recalculate(z) {
if (!this._loaded) return;
for (const sourceId in this.sourceCaches)
this.sourceCaches[sourceId].used = false;
this._updateZoomHistory(z);
for (const layerId of this._order) {
const layer = this._layers[layerId];
layer.recalculate(z);
if (!layer.isHidden(z) && layer.source) {
this.sourceCaches[layer.source].used = true;
}
}
this.light.recalculate(z);
const maxZoomTransitionDuration = 300;
if (Math.floor(this.z) !== Math.floor(z)) {
this.animationLoop.set(maxZoomTransitionDuration);
}
this.z = z;
}
_updateZoomHistory(z) {
const zh = this.zoomHistory;
if (zh.lastIntegerZoom === undefined) {
// first time
zh.lastIntegerZoom = Math.floor(z);
zh.lastIntegerZoomTime = 0;
zh.lastZoom = z;
}
// check whether an integer zoom level as passed since the last frame
// and if yes, record it with the time. Used for transitioning patterns.
if (Math.floor(zh.lastZoom) < Math.floor(z)) {
zh.lastIntegerZoom = Math.floor(z);
zh.lastIntegerZoomTime = Date.now();
} else if (Math.floor(zh.lastZoom) > Math.floor(z)) {
zh.lastIntegerZoom = Math.floor(z + 1);
zh.lastIntegerZoomTime = Date.now();
}
zh.lastZoom = z;
}
_checkLoaded () {
if (!this._loaded) {
throw new Error('Style is not done loading');
}
}
/**
* Apply queued style updates in a batch
*/
update(classes, options) {
if (!this._changed) return;
const updatedIds = Object.keys(this._updatedLayers);
const removedIds = Object.keys(this._removedLayers);
if (updatedIds.length || removedIds.length || this._updatedSymbolOrder) {
this._updateWorkerLayers(updatedIds, removedIds);
}
for (const id in this._updatedSources) {
const action = this._updatedSources[id];
assert(action === 'reload' || action === 'clear');
if (action === 'reload') {
this._reloadSource(id);
} else if (action === 'clear') {
this._clearSource(id);
}
}
this._applyClasses(classes, options);
this._resetUpdates();
this.fire('data', {dataType: 'style'});
}
_updateWorkerLayers(updatedIds, removedIds) {
const symbolOrder = this._updatedSymbolOrder ? this._order.filter((id) => this._layers[id].type === 'symbol') : null;
this.dispatcher.broadcast('updateLayers', {
layers: this._serializeLayers(updatedIds),
removedIds: removedIds,
symbolOrder: symbolOrder
});
}
_resetUpdates() {
this._changed = false;
this._updatedLayers = {};
this._removedLayers = {};
this._updatedSymbolOrder = false;
this._updatedSources = {};
this._updatedPaintProps = {};
this._updatedAllPaintProps = false;
}
/**
* Update this style's state to match the given style JSON, performing only
* the necessary mutations.
*
* May throw an Error ('Unimplemented: METHOD') if the mapbox-gl-style-spec
* diff algorithm produces an operation that is not supported.
*
* @returns {boolean} true if any changes were made; false otherwise
* @private
*/
setState(nextState) {
this._checkLoaded();
if (validateStyle.emitErrors(this, validateStyle(nextState))) return false;
nextState = util.extend({}, nextState);
nextState.layers = deref(nextState.layers);
const changes = diff(this.serialize(), nextState)
.filter(op => !(op.command in ignoredDiffOperations));
if (changes.length === 0) {
return false;
}
const unimplementedOps = changes.filter(op => !(op.command in supportedDiffOperations));
if (unimplementedOps.length > 0) {
throw new Error(`Unimplemented: ${unimplementedOps.map(op => op.command).join(', ')}.`);
}
changes.forEach((op) => {
if (op.command === 'setTransition') {
// `transition` is always read directly off of
// `this.stylesheet`, which we update below
return;
}
this[op.command].apply(this, op.args);
});
this.stylesheet = nextState;
return true;
}
addSource(id, source, options) {
this._checkLoaded();
if (this.sourceCaches[id] !== undefined) {
throw new Error('There is already a source with this ID');
}
if (!source.type) {
throw new Error(`The type property must be defined, but the only the following properties were given: ${Object.keys(source)}.`);
}
const builtIns = ['vector', 'raster', 'geojson', 'video', 'image', 'canvas'];
const shouldValidate = builtIns.indexOf(source.type) >= 0;
if (shouldValidate && this._validate(validateStyle.source, `sources.${id}`, source, null, options)) return;
const sourceCache = this.sourceCaches[id] = new SourceCache(id, source, this.dispatcher);
sourceCache.style = this;
sourceCache.setEventedParent(this, () => ({
isSourceLoaded: this.loaded(),
source: sourceCache.serialize(),
sourceId: id
}));
sourceCache.onAdd(this.map);
this._changed = true;
}
/**
* Remove a source from this stylesheet, given its id.
* @param {string} id id of the source to remove
* @throws {Error} if no source is found with the given ID
*/
removeSource(id) {
this._checkLoaded();
if (this.sourceCaches[id] === undefined) {
throw new Error('There is no source with this ID');
}
const sourceCache = this.sourceCaches[id];
delete this.sourceCaches[id];
delete this._updatedSources[id];
sourceCache.fire('data', {sourceDataType: 'metadata', dataType:'source', sourceId: id});
sourceCache.setEventedParent(null);
sourceCache.clearTiles();
if (sourceCache.onRemove) sourceCache.onRemove(this.map);
this._changed = true;
}
/**
* Get a source by id.
* @param {string} id id of the desired source
* @returns {Object} source
*/
getSource(id) {
return this.sourceCaches[id] && this.sourceCaches[id].getSource();
}
/**
* 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
*/
addLayer(layerObject, before, options) {
this._checkLoaded();
const id = layerObject.id;
if (typeof layerObject.source === 'object') {
this.addSource(id, layerObject.source);
layerObject = util.extend(layerObject, { source: id });
}
// this layer is not in the style.layers array, so we pass an impossible array index
if (this._validate(validateStyle.layer,
`layers.${id}`, layerObject, {arrayIndex: -1}, options)) return;
const layer = StyleLayer.create(layerObject);
this._validateLayer(layer);
layer.setEventedParent(this, {layer: {id: id}});
const index = before ? this._order.indexOf(before) : this._order.length;
this._order.splice(index, 0, id);
this._layers[id] = layer;
if (this._removedLayers[id] && layer.source) {
// If, in the current batch, we have already removed this layer
// and we are now re-adding it with a different `type`, then we
// need to clear (rather than just reload) the underyling source's
// tiles. Otherwise, tiles marked 'reloading' will have buckets /
// buffers that are set up for the _previous_ version of this
// layer, causing, e.g.:
// https://github.com/mapbox/mapbox-gl-js/issues/3633
const removed = this._removedLayers[id];
delete this._removedLayers[id];
if (removed.type !== layer.type) {
this._updatedSources[layer.source] = 'clear';
} else {
this._updatedSources[layer.source] = 'reload';
this.sourceCaches[layer.source].pause();
}
}
this._updateLayer(layer);
if (layer.type === 'symbol') {
this._updatedSymbolOrder = true;
}
this.updateClasses(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
*/
moveLayer(id, before) {
this._checkLoaded();
this._changed = true;
const layer = this._layers[id];
if (!layer) {
this.fire('error', {
error: new Error(
`The layer '${id}' does not exist in ` +
`the map's style and cannot be moved.`
)
});
return;
}
const index = this._order.indexOf(id);
this._order.splice(index, 1);
const newIndex = before ? this._order.indexOf(before) : this._order.length;
this._order.splice(newIndex, 0, id);
if (layer.type === 'symbol') {
this._updatedSymbolOrder = true;
if (layer.source && !this._updatedSources[layer.source]) {
this._updatedSources[layer.source] = 'reload';
this.sourceCaches[layer.source].pause();
}
}
}
/**
* Remove the layer with the given id from the style.
*
* If no such layer exists, an `error` event is fired.
*
* @param {string} id id of the layer to remove
* @fires error
*/
removeLayer(id) {
this._checkLoaded();
const layer = this._layers[id];
if (!layer) {
this.fire('error', {
error: new Error(
`The layer '${id}' does not exist in ` +
`the map's style and cannot be removed.`
)
});
return;
}
layer.setEventedParent(null);
const index = this._order.indexOf(id);
this._order.splice(index, 1);
if (layer.type === 'symbol') {
this._updatedSymbolOrder = true;
}
this._changed = true;
this._removedLayers[id] = layer;
delete this._layers[id];
delete this._updatedLayers[id];
delete this._updatedPaintProps[id];
}
/**
* Return the style layer object with the given `id`.
*
* @param {string} id - id of the desired layer
* @returns {?Object} a layer, if one with the given `id` exists
*/
getLayer(id) {
return this._layers[id];
}
setLayerZoomRange(layerId, minzoom, maxzoom) {
this._checkLoaded();
const layer = this.getLayer(layerId);
if (!layer) {
this.fire('error', {
error: new Error(
`The layer '${layerId}' does not exist in ` +
`the map's style and cannot have zoom extent.`
)
});
return;
}
if (layer.minzoom === minzoom && layer.maxzoom === maxzoom) return;
if (minzoom != null) {
layer.minzoom = minzoom;
}
if (maxzoom != null) {
layer.maxzoom = maxzoom;
}
this._updateLayer(layer);
}
setFilter(layerId, filter) {
this._checkLoaded();
const layer = this.getLayer(layerId);
if (!layer) {
this.fire('error', {
error: new Error(
`The layer '${layerId}' does not exist in ` +
`the map's style and cannot be filtered.`
)
});
return;
}
if (filter !== null && filter !== undefined && this._validate(validateStyle.filter, `layers.${layer.id}.filter`, filter)) return;
if (util.deepEqual(layer.filter, filter)) return;
layer.filter = util.clone(filter);
this._updateLayer(layer);
}
/**
* Get a layer's filter object
* @param {string} layer the layer to inspect
* @returns {*} the layer's filter, if any
*/
getFilter(layer) {
return util.clone(this.getLayer(layer).filter);
}
setLayoutProperty(layerId, name, value) {
this._checkLoaded();
const layer = this.getLayer(layerId);
if (!layer) {
this.fire('error', {
error: new Error(
`The layer '${layerId}' does not exist in ` +
`the map's style and cannot be styled.`
)
});
return;
}
if (util.deepEqual(layer.getLayoutProperty(name), value)) return;
layer.setLayoutProperty(name, value);
this._updateLayer(layer);
}
/**
* Get a layout property's value from a given layer
* @param {string} layer the layer to inspect
* @param {string} name the name of the layout property
* @returns {*} the property value
*/
getLayoutProperty(layer, name) {
return this.getLayer(layer).getLayoutProperty(name);
}
setPaintProperty(layerId, name, value, klass) {
this._checkLoaded();
const layer = this.getLayer(layerId);
if (!layer) {
this.fire('error', {
error: new Error(
`The layer '${layerId}' does not exist in ` +
`the map's style and cannot be styled.`
)
});
return;
}
if (util.deepEqual(layer.getPaintProperty(name, klass), value)) return;
const wasFeatureConstant = layer.isPaintValueFeatureConstant(name);
layer.setPaintProperty(name, value, klass);
const isFeatureConstant = !(
value &&
MapboxGLFunction.isFunctionDefinition(value) &&
value.property !== '$zoom' &&
value.property !== undefined
);
if (!isFeatureConstant || !wasFeatureConstant) {
this._updateLayer(layer);
}
this.updateClasses(layerId, name);
}
getPaintProperty(layer, name, klass) {
return this.getLayer(layer).getPaintProperty(name, klass);
}
getTransition() {
return util.extend({ duration: 300, delay: 0 },
this.stylesheet && this.stylesheet.transition);
}
updateClasses(layerId, paintName) {
this._changed = true;
if (!layerId) {
this._updatedAllPaintProps = true;
} else {
const props = this._updatedPaintProps;
if (!props[layerId]) props[layerId] = {};
props[layerId][paintName || 'all'] = true;
}
}
serialize() {
return util.filterObject({
version: this.stylesheet.version,
name: this.stylesheet.name,
metadata: this.stylesheet.metadata,
light: this.stylesheet.light,
center: this.stylesheet.center,
zoom: this.stylesheet.zoom,
bearing: this.stylesheet.bearing,
pitch: this.stylesheet.pitch,
sprite: this.stylesheet.sprite,
glyphs: this.stylesheet.glyphs,
transition: this.stylesheet.transition,
sources: util.mapObject(this.sourceCaches, (source) => source.serialize()),
layers: this._order.map((id) => this._layers[id].serialize())
}, (value) => { return value !== undefined; });
}
_updateLayer(layer) {
this._updatedLayers[layer.id] = true;
if (layer.source && !this._updatedSources[layer.source]) {
this._updatedSources[layer.source] = 'reload';
this.sourceCaches[layer.source].pause();
}
this._changed = true;
}
_flattenRenderedFeatures(sourceResults) {
const features = [];
for (let l = this._order.length - 1; l >= 0; l--) {
const layerId = this._order[l];
for (const sourceResult of sourceResults) {
const layerFeatures = sourceResult[layerId];
if (layerFeatures) {
for (const feature of layerFeatures) {
features.push(feature);
}
}
}
}
return features;
}
queryRenderedFeatures(queryGeometry, params, zoom, bearing) {
if (params && params.filter) {
this._validate(validateStyle.filter, 'queryRenderedFeatures.filter', params.filter);
}
const includedSources = {};
if (params && params.layers) {
if (!Array.isArray(params.layers)) {
this.fire('error', {error: 'parameters.layers must be an Array.'});
return [];
}
for (const layerId of params.layers) {
const layer = this._layers[layerId];
if (!layer) {
// this layer is not in the style.layers array
this.fire('error', {error: `The layer '${layerId}' does not exist ` +
`in the map's style and cannot be queried for features.`});
return [];
}
includedSources[layer.source] = true;
}
}
const sourceResults = [];
for (const id in this.sourceCaches) {
if (params.layers && !includedSources[id]) continue;
const results = QueryFeatures.rendered(this.sourceCaches[id], this._layers, queryGeometry, params, zoom, bearing);
sourceResults.push(results);
}
return this._flattenRenderedFeatures(sourceResults);
}
querySourceFeatures(sourceID, params) {
if (params && params.filter) {
this._validate(validateStyle.filter, 'querySourceFeatures.filter', params.filter);
}
const sourceCache = this.sourceCaches[sourceID];
return sourceCache ? QueryFeatures.source(sourceCache, params) : [];
}
addSourceType(name, SourceType, callback) {
if (Source.getType(name)) {
return callback(new Error(`A source type called "${name}" already exists.`));
}
Source.setType(name, SourceType);
if (!SourceType.workerSourceURL) {
return callback(null, null);
}
this.dispatcher.broadcast('loadWorkerSource', {
name: name,
url: SourceType.workerSourceURL
}, callback);
}
getLight() {
return this.light.getLight();
}
setLight(lightOptions, transitionOptions) {
this._checkLoaded();
const light = this.light.getLight();
let _update = false;
for (const key in lightOptions) {
if (!util.deepEqual(lightOptions[key], light[key])) {
_update = true;
break;
}
}
if (!_update) return;
const transition = this.stylesheet.transition || {};
this.light.setLight(lightOptions);
this.light.updateLightTransitions(transitionOptions || {transition: true}, transition, this.animationLoop);
}
_validate(validate, key, value, props, options) {
if (options && options.validate === false) {
return false;
}
return validateStyle.emitErrors(this, validate.call(validateStyle, util.extend({
key: key,
style: this.serialize(),
value: value,
styleSpec: styleSpec
}, props)));
}
_remove() {
rtlTextPlugin.evented.off('pluginAvailable', this._rtlTextPluginCallback);
for (const id in this.sourceCaches) {
this.sourceCaches[id].clearTiles();
}
this.dispatcher.remove();
}
_clearSource(id) {
this.sourceCaches[id].clearTiles();
}
_reloadSource(id) {
this.sourceCaches[id].resume();
this.sourceCaches[id].reload();
}
_updateSources(transform) {
for (const id in this.sourceCaches) {
this.sourceCaches[id].update(transform);
}
}
_redoPlacement() {
for (const id in this.sourceCaches) {
this.sourceCaches[id].redoPlacement();
}
}
// Callbacks from web workers
getIcons(mapId, params, callback) {
const updateSpriteAtlas = () => {
this.spriteAtlas.setSprite(this.sprite);
this.spriteAtlas.addIcons(params.icons, callback);
};
if (!this.sprite || this.sprite.loaded()) {
updateSpriteAtlas();
} else {
this.sprite.on('data', updateSpriteAtlas);
}
}
getGlyphs(mapId, params, callback) {
const stacks = params.stacks;
let remaining = Object.keys(stacks).length;
const allGlyphs = {};
for (const fontName in stacks) {
this.glyphSource.getSimpleGlyphs(fontName, stacks[fontName], params.uid, done);
}
function done(err, glyphs, fontName) {
if (err) console.error(err);
allGlyphs[fontName] = glyphs;
remaining--;
if (remaining === 0)
callback(null, allGlyphs);
}
}
}
module.exports = Style;