Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Arbitrary states for feature interactivity #6263

Merged
merged 30 commits into from
Apr 26, 2018
Merged

Conversation

asheemmamoowala
Copy link
Contributor

@asheemmamoowala asheemmamoowala commented Mar 2, 2018

Based on @ansis' work in data-update-proof-of-concept

Implements map.setFeatureState(), a new method to set key: value pairs on specific features in a source. The source must provide unique feature id's per source-layer for this to work correctly.

Also implemented ['state', '<stateName>'] expression syntax. Similar to get, this allows accessing a feature's state instead of source data property.

todo

Launch Checklist

  • write tests for all new functionality
  • document any changes to public APIs
  • post benchmark scores
  • create debug page

cc @ansis @mollymerp

@@ -89,6 +93,7 @@ module.exports = {
// look up StyleLayer objects from layer ids (since we don't
// want to waste time serializing/copying them from the worker)
(bucket: any).layers = layers;
(bucket: any).stateDependentLayers = layers.filter((l) => l.isStateDependent());
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-compute the layers with state-dependent expressions during deserialize. These are needed to re-compute the expressions in ProgramConfiguration until expression evaluators can be re-created when deserialized (#6255)
After that change, layer ids will be sufficient

Copy link
Contributor

@mollymerp mollymerp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@asheemmamoowala this is looking good! I think the biggest question left for me is whether this is the best way to store and mutate the individual features' states.

I wonder if we could use the _idMap on each bucket as the canonical state store. Keeping state/state changes separate from the feature ids and indices seems like it may add in some unnecessary complexity.

updatePaintArrays(featureStates: FeatureStates, vtLayer: VectorTileLayer, layers: $ReadOnlyArray<TypedStyleLayer>): boolean {
let changed: boolean = false;
layers.forEach((layer) => {
changed = changed || this.programConfigurations[layer.id].updatePaintArrays(featureStates, vtLayer, layer);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in general we avoid using forEach – looks like this would be better suited for a for (let i = 0; i <layers.length;... loop with a break statement if changed is ever true.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 or even for(const layer of layers) {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I think we can't break early from the loop, though, since we always need to call updatePaintArrays on each layer's ProgramConfiguration)

options.featureIndex.insert(feature, geometry, index, sourceLayerIndex, this.index);
}
}
}

update(states: FeatureStates, vtLayer: VectorTileLayer) {
if (!this.stateDependentLayers.length) return;
this.changed = this.programConfigurations.updatePaintArrays(states, vtLayer, this.stateDependentLayers);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we just use uploaded for this state as well instead of adding changed state? in other words, a dirty = true return value from updatePaintArrays would set uploaded to false

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like uploaded and changed may be distinct in order to avoid re-uploading the layout and index buffers when only the paint buffers need to change.

Alternative simplification: could we move the changed tracking into ProgramConfigurationSet?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@anandthakker - If we follow that idea, the correct place to track changes would be in the individual ProgramConfiguration's. That would require that Bucket.uploadPending() check the state of each program.

It's not clear to me that the result is simpler.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we follow that idea, the correct place to track changes would be in the individual ProgramConfiguration's

We can put a dirty property on ProgramConfigurationSet, which is set during updatePaintArrays, checked in upload() to decide whether to actually do work (and then cleared). I suppose it might not be simpler per se, but it has the advantage of localizing and centralizing this state tracking in program_configuration.js, rather than having it replicated in each bucket.

@@ -52,6 +54,7 @@ class SourceCache extends Evented {
transform: Transform;
_isIdRenderable: (id: number) => boolean;
used: boolean;
_state: SourceFeatureState
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there any alternatives to storing state for all features on all loaded tiles in the SourceCache? seems like this is a muddling of concerns 💭

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it does make sense to store a SourceFeatureState here on the SourceCache, since it belongs to a source and should be discarded precisely when a source is discarded.

But I agree that the state-aware logic in below feels a bit out of place here. What if we:

  • move the responsibility for calling Tile#updateFeatureState during prepare() directly into SourceFeatureState#coalesceChanges() (i.e., take a set of tiles as a parameter to coalesceChanges)?
  • Add a SourceFeatureState#initializeTileState(Tile) method and replace the other two cases of tile.updateFeatureState(this._state.state); with this._state.initializeTileState(tile)?

This comment was marked as resolved.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since SourceCache is responsible for creating sources and managing their tiles, it is the appropriate place to manage the feature state of the source as well

I disagree. SourceCache is already very difficult to reason about, in part because it's doing too much. (See also #2291.) Ideally, it would not manipulate the "content" of its Tiles at all (except perhaps setting tile.state, since that's directly related to its job of caching tiles).

@@ -780,6 +780,23 @@ class Style extends Evented {
return this.getLayer(layer).getPaintProperty(name);
}

setFeatureState(source: string, feature: string, key: string, value: any, sourceLayer?: string) {
if (this.sourceCaches[source] === undefined) {
throw new Error('There is no source with this ID');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for a more helpful error message, maybe print the passed source id?


getFeatureState(source: string, feature: string, key?: string, sourceLayer?: string) {
if (this.sourceCaches[source] === undefined) {
throw new Error('There is no source with this ID');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same comment about error as above

});
}

this._bufferPos = length;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 should this be += instead of =?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At first, I thought that too. The length input parameter is confusing - its the end index of the paint array after adding new elements. Its usage in the binder popluatePaintArray makes it more clear.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, yeah that's confusing! Maybe we should rename the length parameter to targetLength or something?

constructor() {
this.binders = {};
this.cacheKey = '';

this._buffers = [];
this._idMap = {};
this._bufferPos = 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: name this a little more descriptively: maybe _populatedVertexCount? _paintBufferLength? 🤷‍♂️

for (const id in featureStates) {
const posArray = this._idMap[id];

if (posArray) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: if (!posArray) continue;


if (posArray) {
for (let i = 0; i < posArray.length; i++) {
const pos = posArray[i];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: for (const pos of posArray) {

updatePaintArrays(featureStates: FeatureStates, vtLayer: VectorTileLayer, layers: $ReadOnlyArray<TypedStyleLayer>): boolean {
let changed: boolean = false;
layers.forEach((layer) => {
changed = changed || this.programConfigurations[layer.id].updatePaintArrays(featureStates, vtLayer, layer);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 or even for(const layer of layers) {

@@ -52,6 +54,7 @@ class SourceCache extends Evented {
transform: Transform;
_isIdRenderable: (id: number) => boolean;
used: boolean;
_state: SourceFeatureState
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it does make sense to store a SourceFeatureState here on the SourceCache, since it belongs to a source and should be discarded precisely when a source is discarded.

But I agree that the state-aware logic in below feels a bit out of place here. What if we:

  • move the responsibility for calling Tile#updateFeatureState during prepare() directly into SourceFeatureState#coalesceChanges() (i.e., take a set of tiles as a parameter to coalesceChanges)?
  • Add a SourceFeatureState#initializeTileState(Tile) method and replace the other two cases of tile.updateFeatureState(this._state.state); with this._state.initializeTileState(tile)?

(ctx, [key]) => get(key.evaluate(ctx), ctx.state())
]
]
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since there's only one implementation, use:

ValueType,
[StringType],
(ctx, [key]) => get(key.evaluate(ctx), ctx.state())

@@ -8,6 +8,8 @@ function isFeatureConstant(e: Expression) {
if (e instanceof CompoundExpression) {
if (e.name === 'get' && e.args.length === 1) {
return false;
} else if (e.name === 'state' && e.args.length === 1) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just e.name === 'state', no need to check argument count

@@ -28,6 +30,19 @@ function isFeatureConstant(e: Expression) {
return result;
}

function isStateConstant(e: Expression) {
if (e instanceof CompoundExpression) {
if (e.name === 'state' && e.args.length === 1) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to check e.args.length

src/ui/map.js Outdated
*/
setFeatureState(source: string, feature: string, key: string, value: any, sourceLayer?: string) {
this.style.setFeatureState(source, feature, key, value, sourceLayer);
this._rerender();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use this._update()

options.featureIndex.insert(feature, geometry, index, sourceLayerIndex, this.index);
}
}
}

update(states: FeatureStates, vtLayer: VectorTileLayer) {
if (!this.stateDependentLayers.length) return;
this.changed = this.programConfigurations.updatePaintArrays(states, vtLayer, this.stateDependentLayers);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@anandthakker - If we follow that idea, the correct place to track changes would be in the individual ProgramConfiguration's. That would require that Bucket.uploadPending() check the state of each program.

It's not clear to me that the result is simpler.

const binder: Binder<any> = this.binders[property];
if (binder instanceof ConstantBinder) continue;
if ((binder: any).expression.isStateDependent === true) {
//AHM: Remove after https://github.com/mapbox/mapbox-gl-js/issues/6255
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it OK to leave this comment in here? Or should it be separated into a ticket?

});
}

this._bufferPos = length;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At first, I thought that too. The length input parameter is confusing - its the end index of the paint array after adding new elements. Its usage in the binder popluatePaintArray makes it more clear.

@@ -52,6 +54,7 @@ class SourceCache extends Evented {
transform: Transform;
_isIdRenderable: (id: number) => boolean;
used: boolean;
_state: SourceFeatureState

This comment was marked as resolved.

@asheemmamoowala asheemmamoowala changed the title [wip] Arbitrary states for feature interactivity Arbitrary states for feature interactivity Apr 3, 2018
src/ui/map.js Outdated
* @param {string | number | boolean} value The value to be set.
* @param {string} sourceLayer (optional) The source-layer identifier for Vector Tile sources.
*/
setFeatureState(source: string, feature: string, key: string, value: any, sourceLayer?: string) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calls to setFeatureState with a sourceLayer parameter may be more clear if we pass the sourceLayer as an object rather than an optional parameter: source: string | { id: string; layerId: string; }. What do you think?

Copy link
Contributor Author

@asheemmamoowala asheemmamoowala Apr 5, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a good improvement! I'd also like to make the API error out if a source layer is not provided for Vector sources. It would mean checking the type of the source id, but it would prevent erroneous use of the API.

const util = require('../util/util');
const Tile = require('./tile');

export type FeatureStates = {[feature_id: string]: {[key: string]: string | number | boolean }};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we intend to only allow string | number | boolean in feature states? Aren't GeoJSON properties allowed to include null, arrays, and objects now?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think everything but object is OK to use. Nested objects are not supported in expressions, so it wouldn't be usable for runtime styling, and possibly cause confusion.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@anandthakker Are nested objects supported in GeoJSON feature properties? I suspect the least confusing thing will be parity between GeoJSON feature properties and feature state.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, they're supported. E.g., the following works:

map.on('load', () => {
    map.addLayer({
        id: 'test',
        source: {
            type: 'geojson',
            data: {
                type: 'Feature',
                properties: {
                    x: { y: 'hello' }
                },
                geometry: {
                    type: 'Point',
                    coordinates: [0, 0]
                }
            }
        },
        type: 'symbol',
        layout: {
            'text-field': ["get", "y", ["get", "x"]]
        }
    })
})

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although note that in queryRenderedFeatures, array or object property values currently get stringified.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the correction @anandthakker! In that case, feature states should accept all JSON types.
For queryRenderedFeatures, this means that arrays and objects would be stringified in properties but not in state.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's fine -- qRF shouldn't stringify them, it's just a limitation of a current implementation detail: #2434

@@ -414,16 +418,28 @@ class SymbolBucket implements Bucket {
}
}

update(states: FeatureStates, vtLayer: VectorTileLayer) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would assume that a method called Bucket#update did something more general than this (like update all the data in the bucket). Would a name like Bucket#updateFeatureState be more clear?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bucket#update updates the dynamic buffers in the bucket with the passed in states. The layout and index buffers are static, and so cannot be updated. The more specific name is more descriptive, but it doesn't distinguish itself from any other type of update.

this.opacityVertexBuffer = context.createVertexBuffer(this.opacityVertexArray, shaderOpacityAttributes, true);
// This is a performance hack so that we can write to opacityVertexArray with uint32s
// even though the shaders read uint8s
this.opacityVertexBuffer.itemSize = 1;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain how this works in more detail? Is this prone to break in the future?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe @ChrisLoer can help with its futureproof-ness. I havent changed this code, except move it so it runs only once.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is a relatively brittle performance hack. We might be able to make it more future proof by pushing the hack into StructArray so the external interface was always clearly "this is an array of uint8s" even if uint32s were used internally. But it's a weird special case and it's not clear to me how we'd define that for StructArray.

Background: we do lots of opacity vertex updates for symbols: for each symbol, we have one packed opacity value that we need to update on all four vertices for every glyph. Profiling in Chrome, the actual cost of building the array showed up as a significant part of rendering time. Pushing n/4 uint32s turned out to be significantly faster than pushing n uint8s (see #5150 (comment), there's an additional wrinkle that the same change included packing "target opacity" into the same value). At some point it will probably make sense to re-test this optimization, since testing this kind of thing is so squirrelly and changes in the javascript engine could render it useless.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might be able to make it more future proof by pushing the hack into StructArray

I think that's a good approach 👍

Copy link
Contributor

@ansis ansis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this looks great! I commented on a couple things that raised some questions for me and a couple things that looked like possible bugs. The changes I think might be necessary are:

  • a fix for the this.needsUpload/|| short-circuiting if that is not intentional
  • queryPadding possibly being invalid if the statistics.max value changed

paintArray.emplace(i, value);
}

this.statistics.max = Math.max(this.statistics.max, value);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't handle the case where the maximum value is replaced with a smaller value and the max should decrease. I think this is fine though, because having a bigger max won't cause bugs the way we use it and maintaining the real max value would be more complicated and expensive. This should be fine and safe the way it is, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping track of the max value correctly may not be worth it. When a property value is reduced, the update logic would need to filter the previously populated values to check if there is a new lower max.

Leaving this as is won't return incorrect results, but it will increase the number of features that are matched in the initial query (with queryPadding) and then have to be filtered out.

If there are use cases that significantly affect the performance of queryRenderedFeatures, this can be revisited.

src/ui/map.js Outdated
*
* @returns {any} The value of the specified specified state or all states.
*/
getFeatureState(source: string | { sourceId: string; sourceLayer: string; }, feature: string, key?: string): any {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The optional key param changes the return type. Should returning the full state object be split into a separate method? If it's useful to return the entire state as an object, should it be possible to set state with an object instead of individual calls for each key?

Copy link
Contributor Author

@asheemmamoowala asheemmamoowala Apr 13, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The optional key parameter is only for internal use and can be removed from the external interface.
I'm not entirely sure that getting and setting the entire state as an object is a good idea. Setting state as a single object can create confusion - Does it extend the current object or replace it? Updating a single value would require fetching the entire object first and setting it again.


updatePaintArrays(featureStates: FeatureStates, vtLayer: VectorTileLayer, layers: $ReadOnlyArray<TypedStyleLayer>) {
for (const layer of layers) {
this.needsUpload = this.needsUpload || this.programConfigurations[layer.id].updatePaintArrays(featureStates, vtLayer, layer);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this.needsUpload is true before is gets called then the || will short-circuit and this.programConfigurations[layer.id].updatePaintArrays(...) will not get called. It should always get called, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great catch! I didn't run into the short-circuiting, but it is possible.

const layerStates = {};
for (const id in this.stateChanges[sourceLayer]) {
this.state[sourceLayer][id] = extend(
{},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to create and extend a new object? or could it just extend the existing this.state[sourceLayer][id]?

paintArray.emplace(i, value);
}

this.statistics.max = Math.max(this.statistics.max, value);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, the max changing means the cached queryPadding value might be invalid:

this.queryPadding = 0;
for (const id in this.buckets) {
const bucket = this.buckets[id];
this.queryPadding = Math.max(this.queryPadding, painter.style.getLayer(bucket.layerIds[0]).queryRadius(bucket));
}

I think we need some way of either invalidating it, or if it is cheap enough, recalculating it every time we need to use it instead of caching it.

@asheemmamoowala asheemmamoowala force-pushed the update-paint-arrays branch 2 times, most recently from 8e79e95 to 25d23f9 Compare April 17, 2018 21:20
@lucaswoj
Copy link
Contributor

I haven't been this excited about a new feature in a loooong time!

@ansis
Copy link
Contributor

ansis commented Apr 18, 2018

This looks good to me. The only remaining questions I have are details about the external interface. The current interface seems fine but I think it's worth looking at this extra carefully since it will be public.

The current public signature is:

setFeatureStateValue(
    source: string | { sourceId: string; sourceLayer: string; },
    feature: string,
    key: string,
    value: any)

setFeatureStateValue seems like a good name, but would setFeatureState be just as clear?

What if the method signature was:

setFeatureStateValue(
    feature: {source: string; sourceLayer: string; id: string},
    key: string,
    value: any)

The advantages I'm seeing:

  • (very minor) the first argument is always an object
  • if we set source and sourceLayer on geojson features returned by queryRenderedFeatures and on the features event handlers are called with we could pass those features directly to this method:
    setFeatureStateValue(feature, 'key', 'value') instead of
    setFeatureStateValue({ source: 'id', sourceLayer: 'layerId' }, feature.id, 'key', 'value').

@lucaswoj @ChrisLoer @anandthakker @mollymerp @jfirebaugh did any of you want to review this any further?

@jfirebaugh
Copy link
Contributor

I agree with @ansis's suggestion for public API. In addition, I think the expression operator should be spelled "feature-state" to match the setter and leave room for the possibility of future "global" state.

@anandthakker
Copy link
Contributor

Should there be a method that allows updating multiple state values at once? E.g., updateFeatureState(feature, { key1: 'value1', key2: 'value2' })

setFeatureStateValue(feature, 'key', 'value') instead of setFeatureStateValue({ source: 'id', sourceLayer: 'layerId' }, feature.id, 'key', 'value').

This is appealing. Is there any chance it would come across as though this method actually makes changes to feature itself?

@asheemmamoowala
Copy link
Contributor Author

queryPadding possibly being invalid if the statistics.max value changed

@ansis I opened #6536 to make queryPadding computation on-demand.

@asheemmamoowala
Copy link
Contributor Author

Should there be a method that allows updating multiple state values at once? E.g., updateFeatureState(feature, { key1: 'value1', key2: 'value2' })

@anandthakker Were you thinking of this as a replacement for setFeatureStateValue, or in addition to it?

@asheemmamoowala
Copy link
Contributor Author

asheemmamoowala commented Apr 18, 2018

if we set source and sourceLayer on geojson features returned by queryRenderedFeatures and on the features event handlers

I'm concerned that this would make the API less convenient to use when not directly piping the result from an event handler or call to Map#queryRenderedFeatures(). It also makes it hard to hold on to just the 'identifier' portion of a feature without the additional layer and properties objects.

A globalid object on geojson features could be added instead, that corresponds to the {source: string; sourceLayer: string; id: string} object.

map.on('mousemove', 'places', function(e) {
  const feature = e.features[0];
  map.setFeatureStateValue(feature.globalid, 'hover', true);
  currentId = feature.globalid;

@anandthakker
Copy link
Contributor

Were you thinking of this as a replacement for setFeatureStateValue, or in addition to it?

I was thinking of it as an alternative, mainly because it seems to me like it covers the setFeatureStateValue case nearly as ergonomically: updateFeatureState(feature, {key1: 'value1'}) (or updateFeatureState(feature, {[key]: value}) if key and value are variables) seems pretty close to setFeatureStateValue(feature, key, value)

@asheemmamoowala
Copy link
Contributor Author

asheemmamoowala commented Apr 18, 2018

@anandthakker updateFeatureState breaks from the convention of get/set methods used on Map. I do like the idea of a single set/update method for multiple key/value pairs though.

Map#setFeatureState(feature, { key: value, ... }, update /* = true */) would follow the existing convention and avoid confusion over replace vs. merge of the state parameter

@anandthakker
Copy link
Contributor

I'm okay with breaking with that convention in this case: 1) there are ideas about changing it anyway, and 2) I think it's different in a meaningful way: update is doing more than just setting a single property.

@lucaswoj
Copy link
Contributor

lucaswoj commented Apr 19, 2018

The original API design, setFeatureState(feature, key, value), is much more consistent with the rest of our APIs, such as setPaintProperty and ["feature-state"]. If we are going to adopt a new naming paradigm (i.e. update vs set), let's do it separately and everywhere.

I don't have a strong opinion about whether or not we also allow setFeatureState(feature, values).

#2741 is about internal architecture not naming conventions.

@anandthakker
Copy link
Contributor

anandthakker commented Apr 19, 2018

I see this not as a new naming convention, but instead a different behavior for which there isn't a precedent in our existing APIs. There's a broader convention that setXxxx() methods are merely setters, which would lead me to think that setFeatureState(feature, {p1: v1}) would replace the given feature's state with {p1: v1}, rather than merging {p1: v1} into the existing state.

@jfirebaugh
Copy link
Contributor

React's setState does a merge. I think the name we had was fine.

@anandthakker
Copy link
Contributor

Another inconsistency: our own setStyle() does not do a merge. See also #4006

@asheemmamoowala
Copy link
Contributor Author

Benchmark results

screencapture-localhost-9966-bench-2018-04-19-21_35_24

screencapture-localhost-9966-bench-2018-04-19-21_35_24-2

@thunderkid
Copy link

This looks like nice technology, but I'm trying to understand exactly when it should be used. It's generally been encouraged to have a separate layer for features that change rapidly (eg a layer that contains only a copy of the hovered item but with some different styling.) That approach is pretty performant, and as long as the hovered item obscures the original it works well. So when should the separate hover-layer approach be used and when should one use a single layer with setFeatureState? Are there any general guidelines?

@lucaswoj
Copy link
Contributor

So when should the separate hover-layer approach be used and when should one use a single layer with setFeatureState? Are there any general guidelines?

setFeatureState was designed specifically with the hover use case in mind. I would not recommend using the "hover layer approach" now that setFeatureState has been implemented.

The only case in which you may want to use a second layer is if the geometry of a feature needs to change rapidly (i.e. for a drawing plugin) as setFeatureState cannot change the feature's geometry.

@shayke
Copy link

shayke commented Jun 23, 2018

I'm not sure if there is a bug in this feature or I'm using it wrong
but please take a look at those (hopefully) equivalent examples:

  1. Using setFeatureState - http://jsfiddle.net/01b97w0L/672/
  2. Using extra layer - http://jsfiddle.net/01b97w0L/673/

The idea is to select all "related" features to the selected one and color them as red (regardless of zoom level).
If you click on the left most line and zoom out

  1. setFeatureState - Doesn't color the zoomed out features
  2. "extra layer" - properly colors every feature on the map (regardless of zoom level)

EDIT: So it seems as setting the same feature id to those features work, is that how it's suppose to work? Since the documentation says the feature id should be unique but it doesn't say anything across zoom levels.

@ansis
Copy link
Contributor

ansis commented Jun 26, 2018

@shayke your version using setFeatureState isn't working because querySourceFeatures only returns features in the current viewport. This might be something we should fix for geojson sources where we do have all the features. In the mean time, you could filter the source geojson data yourself to get all the ids of features with the correct county

@Amit-Gore
Copy link

Amit-Gore commented Jun 27, 2018

@lucaswoj Does setFeatureState always require feature.id? Can it work on feature.properties.name?
Any good working example will be appreciated!

@asheemmamoowala
Copy link
Contributor Author

@Amit-Gore Currently setFeatureState does require the top-level feature.id attribute. See #6019 for a discussion on future improvements to this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants