diff --git a/bench/benchmarks/remove_paint_state.js b/bench/benchmarks/remove_paint_state.js new file mode 100644 index 00000000000..d4a04394f1c --- /dev/null +++ b/bench/benchmarks/remove_paint_state.js @@ -0,0 +1,117 @@ + +import style from '../data/empty.json'; +import Benchmark from '../lib/benchmark'; +import createMap from '../lib/create_map'; + +function generateLayers(layer) { + const generated = []; + for (let i = 0; i < 50; i++) { + const id = layer.id + i; + generated.push(Object.assign({}, layer, {id})); + } + return generated; +} + +const width = 1024; +const height = 768; +const zoom = 4; + +class RemovePaintState extends Benchmark { + constructor(center) { + super(); + this.center = center; + } + + setup() { + return fetch('/bench/data/naturalearth-land.json') + .then(response => response.json()) + .then(data => { + this.numFeatures = data.features.length; + return Object.assign({}, style, { + sources: {'land': {'type': 'geojson', data, 'maxzoom': 23}}, + layers: generateLayers({ + 'id': 'layer', + 'type': 'fill', + 'source': 'land', + 'paint': { + 'fill-color': [ + 'case', + ['boolean', ['feature-state', 'bench'], false], + ['rgb', 21, 210, 210], + ['rgb', 233, 233, 233] + ] + } + }) + }); + }) + .then((style) => { + return createMap({ + zoom, + width, + height, + center: this.center, + style + }).then(map => { + this.map = map; + }); + }); + } + + bench() { + this.map._styleDirty = true; + this.map._sourcesDirty = true; + this.map._render(); + } + + teardown() { + this.map.remove(); + } +} + +class propertyLevelRemove extends RemovePaintState { + bench() { + + for (let i = 0; i < this.numFeatures; i += 50) { + this.map.setFeatureState({ source: 'land', id: i }, { bench: true }); + } + for (let i = 0; i < this.numFeatures; i += 50) { + this.map.removeFeatureState({ source: 'land', id: i }, 'bench'); + } + this.map._render(); + + } +} + +class featureLevelRemove extends RemovePaintState { + bench() { + + for (let i = 0; i < this.numFeatures; i += 50) { + this.map.setFeatureState({ source: 'land', id: i }, { bench: true }); + } + for (let i = 0; i < this.numFeatures; i += 50) { + this.map.removeFeatureState({ source: 'land', id: i }); + } + this.map._render(); + + } +} + +class sourceLevelRemove extends RemovePaintState { + bench() { + + for (let i = 0; i < this.numFeatures; i += 50) { + this.map.setFeatureState({ source: 'land', id: i }, { bench: true }); + } + for (let i = 0; i < this.numFeatures; i += 50) { + this.map.removeFeatureState({ source: 'land', id: i }); + } + this.map._render(); + + } +} + +export default [ + propertyLevelRemove, + featureLevelRemove, + sourceLevelRemove +]; diff --git a/bench/versions/benchmarks.js b/bench/versions/benchmarks.js index 87e160e09ab..5729ee2eae3 100644 --- a/bench/versions/benchmarks.js +++ b/bench/versions/benchmarks.js @@ -22,6 +22,7 @@ import SymbolLayout from '../benchmarks/symbol_layout'; import WorkerTransfer from '../benchmarks/worker_transfer'; import Paint from '../benchmarks/paint'; import PaintStates from '../benchmarks/paint_states'; +import RemovePaintState from '../benchmarks/remove_paint_state'; import LayerBenchmarks from '../benchmarks/layers'; import Load from '../benchmarks/map_load'; import Validate from '../benchmarks/style_validate'; @@ -46,6 +47,7 @@ register(new StyleLayerCreate(style)); ExpressionBenchmarks.forEach((Bench) => register(new Bench(style))); register(new WorkerTransfer(style)); register(new PaintStates(center)); +register(new RemovePaintState(center)); LayerBenchmarks.forEach((Bench) => register(new Bench())); register(new Load()); register(new LayoutDDS()); diff --git a/debug/highlightpoints.html b/debug/highlightpoints.html index 6a1aee227e3..94dc7aab4c0 100644 --- a/debug/highlightpoints.html +++ b/debug/highlightpoints.html @@ -38,8 +38,8 @@ paint: { "circle-radius": ["case", ["boolean", ["feature-state", "hover"], false], - ["number", ["*", ["get", "scalerank"], 2]], - ["number", ["*", ["get", "scalerank"], 1.5]] + ["number", 20], + ["number", 10] ], "circle-color": ["case", ["boolean", ["feature-state", "hover"], false], @@ -49,16 +49,15 @@ } }); let hoveredFeature; - map.on('mousemove', 'places', function(e) { - if (e.features.length) { - const f = e.features[0]; - if (!f.state.hover) { - map.setFeatureState(f, {'hover': true}); - if (hoveredFeature) { - map.setFeatureState(hoveredFeature, {'hover': false}); - } - hoveredFeature = f; - } + map.on('mousemove', function(e) { + var f = map.queryRenderedFeatures(e.point, {layers:['places']})[0]; + if (f) { + map.setFeatureState(f, {'hover': true}); + if (hoveredFeature && f.id !== hoveredFeature.id) map.removeFeatureState(hoveredFeature); + hoveredFeature = f; + } else if (hoveredFeature) { + map.removeFeatureState(hoveredFeature); + hoveredFeature = null; } }); }); diff --git a/src/source/source_cache.js b/src/source/source_cache.js index 0255f204781..76a5cb2660d 100644 --- a/src/source/source_cache.js +++ b/src/source/source_cache.js @@ -830,6 +830,15 @@ class SourceCache extends Evented { this._state.updateState(sourceLayer, feature, state); } + /** + * Resets the value of a particular state key for a feature + * @private + */ + removeFeatureState(sourceLayer?: string, feature?: number, key?: string) { + sourceLayer = sourceLayer || '_geojsonTileLayer'; + this._state.removeFeatureState(sourceLayer, feature, key); + } + /** * Get the entire state object for a feature * @private diff --git a/src/source/source_state.js b/src/source/source_state.js index 68ba65e277c..2881432c5a7 100644 --- a/src/source/source_state.js +++ b/src/source/source_state.js @@ -8,32 +8,95 @@ export type FeatureStates = {[feature_id: string]: FeatureState}; export type LayerFeatureStates = {[layer: string]: FeatureStates}; /** - * SourceFeatureState manages the state and state changes + * SourceFeatureState manages the state and pending changes * to features in a source, separated by source layer. - * + * stateChanges and deletedStates batch all changes to the tile (updates and removes, respectively) + * between coalesce() events. addFeatureState() and removeFeatureState() also update their counterpart's + * list of changes, such that coalesce() can apply the proper state changes while agnostic to the order of operations. + * In deletedStates, all null's denote complete removal of state at that scope * @private */ class SourceFeatureState { state: LayerFeatureStates; stateChanges: LayerFeatureStates; + deletedStates: {}; constructor() { this.state = {}; this.stateChanges = {}; + this.deletedStates = {}; } - updateState(sourceLayer: string, featureId: number, state: Object) { + updateState(sourceLayer: string, featureId: number, newState: Object) { const feature = String(featureId); this.stateChanges[sourceLayer] = this.stateChanges[sourceLayer] || {}; this.stateChanges[sourceLayer][feature] = this.stateChanges[sourceLayer][feature] || {}; - extend(this.stateChanges[sourceLayer][feature], state); + extend(this.stateChanges[sourceLayer][feature], newState); + + if (this.deletedStates[sourceLayer] === null) { + this.deletedStates[sourceLayer] = {}; + for (const ft in this.state[sourceLayer]) { + if (ft !== feature) this.deletedStates[sourceLayer][ft] = null; + } + } else { + const featureDeletionQueued = this.deletedStates[sourceLayer] && this.deletedStates[sourceLayer][feature] === null; + if (featureDeletionQueued) { + this.deletedStates[sourceLayer][feature] = {}; + for (const prop in this.state[sourceLayer][feature]) { + if (!newState[prop]) this.deletedStates[sourceLayer][feature][prop] = null; + } + } else { + for (const key in newState) { + const deletionInQueue = this.deletedStates[sourceLayer] && this.deletedStates[sourceLayer][feature] && this.deletedStates[sourceLayer][feature][key] === null; + if (deletionInQueue) delete this.deletedStates[sourceLayer][feature][key]; + } + } + } + } + + removeFeatureState(sourceLayer: string, featureId?: number, key?: string) { + const sourceLayerDeleted = this.deletedStates[sourceLayer] === null; + if (sourceLayerDeleted) return; + + const feature = String(featureId); + + this.deletedStates[sourceLayer] = this.deletedStates[sourceLayer] || {}; + + if (key && featureId) { + if (this.deletedStates[sourceLayer][feature] !== null) { + this.deletedStates[sourceLayer][feature] = this.deletedStates[sourceLayer][feature] || {}; + this.deletedStates[sourceLayer][feature][key] = null; + } + } else if (featureId) { + const updateInQueue = this.stateChanges[sourceLayer] && this.stateChanges[sourceLayer][feature]; + if (updateInQueue) { + this.deletedStates[sourceLayer][feature] = {}; + for (key in this.stateChanges[sourceLayer][feature]) this.deletedStates[sourceLayer][feature][key] = null; + + } else { + this.deletedStates[sourceLayer][feature] = null; + } + } else { + this.deletedStates[sourceLayer] = null; + } + } getState(sourceLayer: string, featureId: number) { const feature = String(featureId); const base = this.state[sourceLayer] || {}; const changes = this.stateChanges[sourceLayer] || {}; - return extend({}, base[feature], changes[feature]); + + const reconciledState = extend({}, base[feature], changes[feature]); + + //return empty object if the whole source layer is awaiting deletion + if (this.deletedStates[sourceLayer] === null) return {}; + else if (this.deletedStates[sourceLayer]) { + const featureDeletions = this.deletedStates[sourceLayer][featureId]; + if (featureDeletions === null) return {}; + for (const prop in featureDeletions) delete reconciledState[prop]; + } + return reconciledState; } initializeTileState(tile: Tile, painter: any) { @@ -41,25 +104,52 @@ class SourceFeatureState { } coalesceChanges(tiles: {[any]: Tile}, painter: any) { - const changes: LayerFeatureStates = {}; + //track changes with full state objects, but only for features that got modified + const featuresChanged: LayerFeatureStates = {}; + for (const sourceLayer in this.stateChanges) { this.state[sourceLayer] = this.state[sourceLayer] || {}; const layerStates = {}; - for (const id in this.stateChanges[sourceLayer]) { - if (!this.state[sourceLayer][id]) { - this.state[sourceLayer][id] = {}; + for (const feature in this.stateChanges[sourceLayer]) { + if (!this.state[sourceLayer][feature]) this.state[sourceLayer][feature] = {}; + extend(this.state[sourceLayer][feature], this.stateChanges[sourceLayer][feature]); + layerStates[feature] = this.state[sourceLayer][feature]; + } + featuresChanged[sourceLayer] = layerStates; + } + + for (const sourceLayer in this.deletedStates) { + this.state[sourceLayer] = this.state[sourceLayer] || {}; + const layerStates = {}; + + if (this.deletedStates[sourceLayer] === null) { + for (const ft in this.state[sourceLayer]) layerStates[ft] = {}; + this.state[sourceLayer] = {}; + } else { + for (const feature in this.deletedStates[sourceLayer]) { + const deleteWholeFeatureState = this.deletedStates[sourceLayer][feature] === null; + if (deleteWholeFeatureState) this.state[sourceLayer][feature] = {}; + else { + for (const key of Object.keys(this.deletedStates[sourceLayer][feature])) { + delete this.state[sourceLayer][feature][key]; + } + } + layerStates[feature] = this.state[sourceLayer][feature]; } - extend(this.state[sourceLayer][id], this.stateChanges[sourceLayer][id]); - layerStates[id] = this.state[sourceLayer][id]; } - changes[sourceLayer] = layerStates; + + featuresChanged[sourceLayer] = featuresChanged[sourceLayer] || {}; + extend(featuresChanged[sourceLayer], layerStates); } + this.stateChanges = {}; - if (Object.keys(changes).length === 0) return; + this.deletedStates = {}; + + if (Object.keys(featuresChanged).length === 0) return; for (const id in tiles) { const tile = tiles[id]; - tile.setFeatureState(changes, painter); + tile.setFeatureState(featuresChanged, painter); } } } diff --git a/src/style/style.js b/src/style/style.js index 8dbfaf7a78b..a9c07ce169f 100644 --- a/src/style/style.js +++ b/src/style/style.js @@ -837,6 +837,10 @@ class Style extends Evented { return; } const sourceType = sourceCache.getSource().type; + if (sourceType === 'geojson' && sourceLayer) { + this.fire(new ErrorEvent(new Error(`GeoJSON sources cannot have a sourceLayer parameter.`))); + return; + } if (sourceType === 'vector' && !sourceLayer) { this.fire(new ErrorEvent(new Error(`The sourceLayer parameter must be provided for vector source types.`))); return; @@ -849,6 +853,38 @@ class Style extends Evented { sourceCache.setFeatureState(sourceLayer, featureId, state); } + removeFeatureState(target: { source: string; sourceLayer?: string; id?: string | number; }, key?: string) { + this._checkLoaded(); + const sourceId = target.source; + const sourceCache = this.sourceCaches[sourceId]; + + if (sourceCache === undefined) { + this.fire(new ErrorEvent(new Error(`The source '${sourceId}' does not exist in the map's style.`))); + return; + } + + const sourceType = sourceCache.getSource().type; + const sourceLayer = sourceType === 'vector' ? target.sourceLayer : undefined; + const featureId = parseInt(target.id, 10); + + if (sourceType === 'vector' && !sourceLayer) { + this.fire(new ErrorEvent(new Error(`The sourceLayer parameter must be provided for vector source types.`))); + return; + } + + if (target.id && isNaN(featureId) || featureId < 0) { + this.fire(new ErrorEvent(new Error(`The feature id parameter must be non-negative.`))); + return; + } + + if (key && !target.id) { + this.fire(new ErrorEvent(new Error(`A feature id is requred to remove its specific state property.`))); + return; + } + + sourceCache.removeFeatureState(sourceLayer, featureId, key); + } + getFeatureState(feature: { source: string; sourceLayer?: string; id: string | number; }) { this._checkLoaded(); const sourceId = feature.source; diff --git a/src/ui/map.js b/src/ui/map.js index 501bd28c8a6..dc77d872cbe 100755 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -1409,6 +1409,25 @@ class Map extends Camera { return this._update(); } + /** + * Removes feature state, setting it back to the default behavior. If only + * source is specified, removes all states of that source. If + * target.id is also specified, removes all keys for that feature's state. + * If key is also specified, removes that key from that feature's state. + * + * @param {Object} target Identifier of where to set state: can be a source, a feature, or a specific key of feature. + * Feature objects returned from {@link Map#queryRenderedFeatures} or event handlers can be used as feature identifiers. + * @param {string | number} target.id (optional) Unique id of the feature. Optional if key is not specified. + * @param {string} target.source The Id of the vector source or GeoJSON source for the feature. + * @param {string} [target.sourceLayer] (optional) *For vector tile sources, the sourceLayer is + * required.* + * @param {string} key (optional) The key in the feature state to reset. + */ + removeFeatureState(target: { source: string; sourceLayer?: string; id?: string | number; }, key?: string) { + this.style.removeFeatureState(target, key); + return this._update(); + } + /** * Gets the state of a feature. * @@ -1701,7 +1720,6 @@ class Map extends Camera { } else if (!this.isMoving() && this.loaded()) { this.fire(new Event('idle')); } - return this; } @@ -1840,7 +1858,6 @@ class Map extends Camera { this.triggerRepaint(); } } - // show vertices get vertices(): boolean { return !!this._vertices; } set vertices(value: boolean) { this._vertices = value; this._update(); } diff --git a/test/integration/query-tests/remove-feature-state/default/expected.json b/test/integration/query-tests/remove-feature-state/default/expected.json new file mode 100644 index 00000000000..51de80fe451 --- /dev/null +++ b/test/integration/query-tests/remove-feature-state/default/expected.json @@ -0,0 +1,92 @@ +[ + { + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 13.406662344932556, + 52.49845542419487 + ], + [ + 13.406715989112854, + 52.49853706825692 + ], + [ + 13.407037854194641, + 52.499007335102704 + ], + [ + 13.40782642364502, + 52.50002296369735 + ], + [ + 13.409215807914734, + 52.50175045812034 + ] + ] + }, + "type": "Feature", + "properties": { + "class": "main", + "oneway": 0, + "osm_id": 4612696, + "type": "secondary" + }, + "id": 4612696, + "source": "mapbox", + "sourceLayer": "road", + "state": {} + }, + { + "geometry": { + "type": "LineString", + "coordinates": [ + [ + 13.404956459999084, + 52.50075446300568 + ], + [ + 13.405857682228088, + 52.500525870779285 + ], + [ + 13.40782642364502, + 52.50002296369735 + ], + [ + 13.41029942035675, + 52.49939268890719 + ], + [ + 13.410347700119019, + 52.49937962612168 + ], + [ + 13.410476446151733, + 52.49934370344147 + ], + [ + 13.410674929618835, + 52.499291452217875 + ], + [ + 13.4122896194458, + 52.498840782836766 + ] + ] + }, + "type": "Feature", + "properties": { + "class": "street", + "oneway": 0, + "osm_id": 4612752, + "type": "residential" + }, + "id": 4612752, + "source": "mapbox", + "sourceLayer": "road", + "state": { + "stateA": false + } + } +] \ No newline at end of file diff --git a/test/integration/query-tests/remove-feature-state/default/style.json b/test/integration/query-tests/remove-feature-state/default/style.json new file mode 100644 index 00000000000..2e0da0bd8eb --- /dev/null +++ b/test/integration/query-tests/remove-feature-state/default/style.json @@ -0,0 +1,112 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 256, + "operations": [ + [ + "setFeatureState", + { + "source": "mapbox", + "sourceLayer": "road", + "id": 4612696 + }, + { + "stateA": 1, + "stateB": true + } + ], + [ + "setFeatureState", + { + "source": "mapbox", + "sourceLayer": "road", + "id": 4612752 + }, + { + "stateA": 1, + "stateB": false + } + ], + [ + "removeFeatureState", + { + "source": "mapbox", + "sourceLayer": "road" + } + ], + [ + "setFeatureState", + { + "source": "mapbox", + "sourceLayer": "road", + "id": 4612696 + }, + { + "stateA": true, + "stateB": true + } + ], + [ + "setFeatureState", + { + "source": "mapbox", + "sourceLayer": "road", + "id": 4612752 + }, + { + "stateA": false, + "stateB": false + } + ], + [ + "removeFeatureState", + { + "source": "mapbox", + "sourceLayer": "road", + "id": 4612696 + } + ], + [ + "removeFeatureState", + { + "source": "mapbox", + "sourceLayer": "road", + "id": 4612752 + }, + "stateB" + ] + ], + "queryGeometry": [ + 10, + 100 + ] + } + }, + "center": [ + 13.418056, + 52.499167 + ], + "zoom": 14, + "sources": { + "mapbox": { + "type": "vector", + "maxzoom": 14, + "tiles": [ + "local://tiles/{z}-{x}-{y}.mvt" + ] + } + }, + "layers": [ + { + "id": "road", + "type": "circle", + "source": "mapbox", + "source-layer": "road", + "paint": { + "circle-radius": 10 + }, + "interactive": true + } + ] +} \ No newline at end of file diff --git a/test/integration/render-tests/remove-feature-state/composite-expression/expected.png b/test/integration/render-tests/remove-feature-state/composite-expression/expected.png new file mode 100644 index 00000000000..4cb77303dde Binary files /dev/null and b/test/integration/render-tests/remove-feature-state/composite-expression/expected.png differ diff --git a/test/integration/render-tests/remove-feature-state/composite-expression/style.json b/test/integration/render-tests/remove-feature-state/composite-expression/style.json new file mode 100644 index 00000000000..0823a9e8afa --- /dev/null +++ b/test/integration/render-tests/remove-feature-state/composite-expression/style.json @@ -0,0 +1,78 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 64, + "height": 64, + "operations": [ + [ + "setFeatureState", + { + "source": "geojson", + "id": "1" + }, + { + "color": "red" + } + ], + [ + "wait" + ], + [ + "removeFeatureState", + { + "source": "geojson", + "id": "1" + }, + "color" + ], + [ + "wait" + ] + ] + } + }, + "zoom": 2, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Feature", + "id": "1", + "geometry": { + "type": "Point", + "coordinates": [ + 0, + 0 + ] + } + } + } + }, + "layers": [ + { + "id": "circle", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 5, + "circle-color": [ + "step", + [ + "zoom" + ], + "black", + 1, + [ + "coalesce", + [ + "feature-state", + "color" + ], + "black" + ] + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render-tests/remove-feature-state/data-expression/expected.png b/test/integration/render-tests/remove-feature-state/data-expression/expected.png new file mode 100644 index 00000000000..4cb77303dde Binary files /dev/null and b/test/integration/render-tests/remove-feature-state/data-expression/expected.png differ diff --git a/test/integration/render-tests/remove-feature-state/data-expression/style.json b/test/integration/render-tests/remove-feature-state/data-expression/style.json new file mode 100644 index 00000000000..2c6e0b03c4d --- /dev/null +++ b/test/integration/render-tests/remove-feature-state/data-expression/style.json @@ -0,0 +1,70 @@ +{ + "version": 8, + "metadata": { + "test": { + "width": 64, + "height": 64, + "operations": [ + [ + "setFeatureState", + { + "source": "geojson", + "id": "1" + }, + { + "color": "red" + } + ], + [ + "wait" + ], + [ + "removeFeatureState", + { + "source": "geojson", + "id": "1" + }, + "color" + ], + [ + "wait" + ] + ] + } + }, + "zoom": 2, + "sources": { + "geojson": { + "type": "geojson", + "data": { + "type": "Feature", + "id": "1", + "geometry": { + "type": "Point", + "coordinates": [ + 0, + 0 + ] + } + } + } + }, + "layers": [ + { + "id": "circle", + "type": "circle", + "source": "geojson", + "paint": { + "circle-radius": 5, + "circle-color": [ + "coalesce", + [ + "feature-state", + "color" + ], + "black" + ] + } + } + ] +} \ No newline at end of file diff --git a/test/integration/render-tests/remove-feature-state/vector-source/expected.png b/test/integration/render-tests/remove-feature-state/vector-source/expected.png new file mode 100644 index 00000000000..d51d572887d Binary files /dev/null and b/test/integration/render-tests/remove-feature-state/vector-source/expected.png differ diff --git a/test/integration/render-tests/remove-feature-state/vector-source/style.json b/test/integration/render-tests/remove-feature-state/vector-source/style.json new file mode 100644 index 00000000000..ff67795589c --- /dev/null +++ b/test/integration/render-tests/remove-feature-state/vector-source/style.json @@ -0,0 +1,76 @@ +{ + "version": 8, + "metadata": { + "test": { + "height": 256, + "operations": [ + [ + "setFeatureState", + { + "source": "mapbox", + "sourceLayer": "poi_label", + "id": "1000059876748" + }, + { + "color": "red" + } + ], + [ + "wait" + ], + [ + "removeFeatureState", + { + "source": "mapbox", + "sourceLayer": "poi_label", + "id": "1000059876748" + }, + "color" + ], + [ + "wait" + ] + ] + } + }, + "center": [ + 13.418056, + 52.499167 + ], + "zoom": 14, + "sources": { + "mapbox": { + "type": "vector", + "maxzoom": 14, + "tiles": [ + "local://tiles/{z}-{x}-{y}.mvt" + ] + } + }, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, + { + "id": "poi_label", + "type": "circle", + "source": "mapbox", + "source-layer": "poi_label", + "paint": { + "circle-radius": 5, + "circle-color": [ + "coalesce", + [ + "feature-state", + "color" + ], + "black" + ] + } + } + ] +} \ No newline at end of file diff --git a/test/unit/ui/map.test.js b/test/unit/ui/map.test.js index 4de768d55b4..55fa4eb7b24 100755 --- a/test/unit/ui/map.test.js +++ b/test/unit/ui/map.test.js @@ -1484,6 +1484,278 @@ test('Map', (t) => { t.end(); }); + t.test('#removeFeatureState', (t) => { + t.test('remove specific state property', (t) => { + const map = createMap(t, { + style: { + "version": 8, + "sources": { + "geojson": createStyleSource() + }, + "layers": [] + } + }); + map.on('load', () => { + map.setFeatureState({ source: 'geojson', id: 12345}, {'hover': true}); + map.removeFeatureState({ source: 'geojson', id: 12345}, 'hover'); + const fState = map.getFeatureState({ source: 'geojson', id: 12345}); + t.equal(fState.hover, undefined); + t.end(); + }); + }); + t.test('remove all state properties of one feature', (t) => { + const map = createMap(t, { + style: { + "version": 8, + "sources": { + "geojson": createStyleSource() + }, + "layers": [] + } + }); + map.on('load', () => { + map.setFeatureState({ source: 'geojson', id: 1}, {'hover': true, 'foo': true}); + map.removeFeatureState({ source: 'geojson', id: 1}); + + const fState = map.getFeatureState({ source: 'geojson', id: 1}); + t.equal(fState.hover, undefined); + t.equal(fState.foo, undefined); + + t.end(); + }); + }); + t.test('other properties persist when removing specific property', (t) => { + const map = createMap(t, { + style: { + "version": 8, + "sources": { + "geojson": createStyleSource() + }, + "layers": [] + } + }); + map.on('load', () => { + map.setFeatureState({ source: 'geojson', id: 1}, {'hover': true, 'foo': true}); + map.removeFeatureState({ source: 'geojson', id: 1}, 'hover'); + + const fState = map.getFeatureState({ source: 'geojson', id: 1}); + t.equal(fState.foo, true); + + t.end(); + }); + }); + t.test('remove all state properties of all features in source', (t) => { + const map = createMap(t, { + style: { + "version": 8, + "sources": { + "geojson": createStyleSource() + }, + "layers": [] + } + }); + map.on('load', () => { + map.setFeatureState({ source: 'geojson', id: 1}, {'hover': true, 'foo': true}); + map.setFeatureState({ source: 'geojson', id: 2}, {'hover': true, 'foo': true}); + + map.removeFeatureState({ source: 'geojson'}); + + const fState1 = map.getFeatureState({ source: 'geojson', id: 1}); + t.equal(fState1.hover, undefined); + t.equal(fState1.foo, undefined); + + const fState2 = map.getFeatureState({ source: 'geojson', id: 2}); + t.equal(fState2.hover, undefined); + t.equal(fState2.foo, undefined); + + t.end(); + }); + }); + t.test('specific state deletion should not interfere with broader state deletion', (t) => { + const map = createMap(t, { + style: { + "version": 8, + "sources": { + "geojson": createStyleSource() + }, + "layers": [] + } + }); + map.on('load', () => { + map.setFeatureState({ source: 'geojson', id: 1}, {'hover': true, 'foo': true}); + map.setFeatureState({ source: 'geojson', id: 2}, {'hover': true, 'foo': true}); + + map.removeFeatureState({ source: 'geojson', id: 1}); + map.removeFeatureState({ source: 'geojson', id: 1}, 'foo'); + + const fState1 = map.getFeatureState({ source: 'geojson', id: 1}); + t.equal(fState1.hover, undefined); + + map.setFeatureState({ source: 'geojson', id: 1}, {'hover': true, 'foo': true}); + map.removeFeatureState({ source: 'geojson'}); + map.removeFeatureState({ source: 'geojson', id: 1}, 'foo'); + + const fState2 = map.getFeatureState({ source: 'geojson', id: 2}); + t.equal(fState2.hover, undefined); + + map.setFeatureState({ source: 'geojson', id: 2}, {'hover': true, 'foo': true}); + map.removeFeatureState({ source: 'geojson'}); + map.removeFeatureState({ source: 'geojson', id: 2}, 'foo'); + + const fState3 = map.getFeatureState({ source: 'geojson', id: 2}); + t.equal(fState3.hover, undefined); + + t.end(); + }); + }); + t.test('add/remove and remove/add state', (t) => { + const map = createMap(t, { + style: { + "version": 8, + "sources": { + "geojson": createStyleSource() + }, + "layers": [] + } + }); + map.on('load', () => { + map.setFeatureState({ source: 'geojson', id: 12345}, {'hover': true}); + + map.removeFeatureState({ source: 'geojson', id: 12345}); + map.setFeatureState({ source: 'geojson', id: 12345}, {'hover': true}); + + const fState1 = map.getFeatureState({ source: 'geojson', id: 12345}); + t.equal(fState1.hover, true); + + map.removeFeatureState({ source: 'geojson', id: 12345}); + + const fState2 = map.getFeatureState({ source: 'geojson', id: 12345}); + t.equal(fState2.hover, undefined); + + t.end(); + }); + }); + t.test('throw before loaded', (t) => { + const map = createMap(t, { + style: { + "version": 8, + "sources": { + "geojson": createStyleSource() + }, + "layers": [] + } + }); + t.throws(() => { + map.removeFeatureState({ source: 'geojson', id: 12345}, {'hover': true}); + }, Error, /load/i); + + t.end(); + }); + t.test('fires an error if source not found', (t) => { + const map = createMap(t, { + style: { + "version": 8, + "sources": { + "geojson": createStyleSource() + }, + "layers": [] + } + }); + map.on('load', () => { + map.on('error', ({ error }) => { + t.match(error.message, /source/); + t.end(); + }); + map.removeFeatureState({ source: 'vector', id: 12345}, {'hover': true}); + }); + }); + t.test('fires an error if sourceLayer not provided for a vector source', (t) => { + const map = createMap(t, { + style: { + "version": 8, + "sources": { + "vector": { + "type": "vector", + "tiles": ["http://example.com/{z}/{x}/{y}.png"] + } + }, + "layers": [] + } + }); + map.on('load', () => { + map.on('error', ({ error }) => { + t.match(error.message, /sourceLayer/); + t.end(); + }); + map.removeFeatureState({ source: 'vector', sourceLayer: 0, id: 12345}, {'hover': true}); + }); + }); + t.test('fires an error if state property is provided without a feature id', (t) => { + const map = createMap(t, { + style: { + "version": 8, + "sources": { + "vector": { + "type": "vector", + "tiles": ["http://example.com/{z}/{x}/{y}.png"] + } + }, + "layers": [] + } + }); + map.on('load', () => { + map.on('error', ({ error }) => { + t.match(error.message, /id/); + t.end(); + }); + map.removeFeatureState({ source: 'vector', sourceLayer: "1"}, {'hover': true}); + }); + }); + t.test('removeFeatureState fires an error if id is less than zero', (t) => { + const map = createMap(t, { + style: { + "version": 8, + "sources": { + "vector": { + "type": "vector", + "tiles": ["http://example.com/{z}/{x}/{y}.png"] + } + }, + "layers": [] + } + }); + map.on('load', () => { + map.on('error', ({ error }) => { + t.match(error.message, /id/); + t.end(); + }); + map.removeFeatureState({ source: 'vector', sourceLayer: "1", id: -1}, {'hover': true}); + }); + }); + t.test('fires an error if id cannot be parsed as an int', (t) => { + const map = createMap(t, { + style: { + "version": 8, + "sources": { + "vector": { + "type": "vector", + "tiles": ["http://example.com/{z}/{x}/{y}.png"] + } + }, + "layers": [] + } + }); + map.on('load', () => { + map.on('error', ({ error }) => { + t.match(error.message, /id/); + t.end(); + }); + map.removeFeatureState({ source: 'vector', sourceLayer: "1", id: 'abc'}, {'hover': true}); + }); + }); + t.end(); + }); + t.test('error event', (t) => { t.test('logs errors to console when it has NO listeners', (t) => { const map = createMap(t);