Repository navigation
Arbitrary states for feature interactivity - #6263
Conversation
| // 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()); |
There was a problem hiding this comment.
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
f10a553 to
d1cd559
Compare
mollymerp
left a comment
There was a problem hiding this comment.
@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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
👍 or even for(const layer of layers) {
There was a problem hiding this comment.
(I think we can't break early from the loop, though, since we always need to call updatePaintArrays on each layer's ProgramConfiguration)
|
|
||
| update(states: FeatureStates, vtLayer: VectorTileLayer) { | ||
| if (!this.stateDependentLayers.length) return; | ||
| this.changed = this.programConfigurations.updatePaintArrays(states, vtLayer, this.stateDependentLayers); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
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.
| transform: Transform; | ||
| _isIdRenderable: (id: number) => boolean; | ||
| used: boolean; | ||
| _state: SourceFeatureState |
There was a problem hiding this comment.
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 💭
There was a problem hiding this comment.
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#updateFeatureStateduringprepare()directly intoSourceFeatureState#coalesceChanges()(i.e., take a set of tiles as a parameter tocoalesceChanges)? - Add a
SourceFeatureState#initializeTileState(Tile)method and replace the other two cases oftile.updateFeatureState(this._state.state);withthis._state.initializeTileState(tile)?
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
There was a problem hiding this comment.
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).
|
|
||
| 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'); |
There was a problem hiding this comment.
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'); |
There was a problem hiding this comment.
same comment about error as above
| }); | ||
| } | ||
|
|
||
| this._bufferPos = length; |
There was a problem hiding this comment.
🤔 should this be += instead of =?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Oh, yeah that's confusing! Maybe we should rename the length parameter to targetLength or something?
|
|
||
| this._buffers = []; | ||
| this._idMap = {}; | ||
| this._bufferPos = 0; |
There was a problem hiding this comment.
Nit: name this a little more descriptively: maybe _populatedVertexCount? _paintBufferLength? 🤷♂️
| for (const id in featureStates) { | ||
| const posArray = this._idMap[id]; | ||
|
|
||
| if (posArray) { |
There was a problem hiding this comment.
Nit: if (!posArray) continue;
|
|
||
| if (posArray) { | ||
| for (let i = 0; i < posArray.length; i++) { | ||
| const pos = posArray[i]; |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
👍 or even for(const layer of layers) {
| transform: Transform; | ||
| _isIdRenderable: (id: number) => boolean; | ||
| used: boolean; | ||
| _state: SourceFeatureState |
There was a problem hiding this comment.
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#updateFeatureStateduringprepare()directly intoSourceFeatureState#coalesceChanges()(i.e., take a set of tiles as a parameter tocoalesceChanges)? - Add a
SourceFeatureState#initializeTileState(Tile)method and replace the other two cases oftile.updateFeatureState(this._state.state);withthis._state.initializeTileState(tile)?
| (ctx, [key]) => get(key.evaluate(ctx), ctx.state()) | ||
| ] | ||
| ] | ||
| }, |
There was a problem hiding this comment.
Since there's only one implementation, use:
ValueType,
[StringType],
(ctx, [key]) => get(key.evaluate(ctx), ctx.state())| if (e instanceof CompoundExpression) { | ||
| if (e.name === 'get' && e.args.length === 1) { | ||
| return false; | ||
| } else if (e.name === 'state' && e.args.length === 1) { |
There was a problem hiding this comment.
Just e.name === 'state', no need to check argument count
|
|
||
| function isStateConstant(e: Expression) { | ||
| if (e instanceof CompoundExpression) { | ||
| if (e.name === 'state' && e.args.length === 1) { |
There was a problem hiding this comment.
No need to check e.args.length
| */ | ||
| setFeatureState(source: string, feature: string, key: string, value: any, sourceLayer?: string) { | ||
| this.style.setFeatureState(source, feature, key, value, sourceLayer); | ||
| this._rerender(); |
|
|
||
| update(states: FeatureStates, vtLayer: VectorTileLayer) { | ||
| if (!this.stateDependentLayers.length) return; | ||
| this.changed = this.programConfigurations.updatePaintArrays(states, vtLayer, this.stateDependentLayers); |
There was a problem hiding this comment.
@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 |
There was a problem hiding this comment.
Is it OK to leave this comment in here? Or should it be separated into a ticket?
| }); | ||
| } | ||
|
|
||
| this._bufferPos = length; |
There was a problem hiding this comment.
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.
| transform: Transform; | ||
| _isIdRenderable: (id: number) => boolean; | ||
| used: boolean; | ||
| _state: SourceFeatureState |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
| * @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) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 }}; |
There was a problem hiding this comment.
Do we intend to only allow string | number | boolean in feature states? Aren't GeoJSON properties allowed to include null, arrays, and objects now?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
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"]]
}
})
})There was a problem hiding this comment.
Although note that in queryRenderedFeatures, array or object property values currently get stringified.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Yeah, that's fine -- qRF shouldn't stringify them, it's just a limitation of a current implementation detail: #2434
| } | ||
| } | ||
|
|
||
| update(states: FeatureStates, vtLayer: VectorTileLayer) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
Can you explain how this works in more detail? Is this prone to break in the future?
There was a problem hiding this comment.
Maybe @ChrisLoer can help with its futureproof-ness. I havent changed this code, except move it so it runs only once.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
We might be able to make it more future proof by pushing the hack into
StructArray
I think that's a good approach 👍
e65553b to
bd5a653
Compare
ansis
left a comment
There was a problem hiding this comment.
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 queryPaddingpossibly being invalid if thestatistics.maxvalue changed
| paintArray.emplace(i, value); | ||
| } | ||
|
|
||
| this.statistics.max = Math.max(this.statistics.max, value); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| * | ||
| * @returns {any} The value of the specified specified state or all states. | ||
| */ | ||
| getFeatureState(source: string | { sourceId: string; sourceLayer: string; }, feature: string, key?: string): any { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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( | ||
| {}, |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
Also, the max changing means the cached queryPadding value might be invalid:
mapbox-gl-js/src/source/tile.js
Lines 170 to 174 in bd5a653
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.
8e79e95 to
25d23f9
Compare
|
I haven't been this excited about a new feature in a loooong time! |
|
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:
What if the method signature was: The advantages I'm seeing:
@lucaswoj @ChrisLoer @anandthakker @mollymerp @jfirebaugh did any of you want to review this any further? |
|
I agree with @ansis's suggestion for public API. In addition, I think the expression operator should be spelled |
|
Should there be a method that allows updating multiple state values at once? E.g.,
This is appealing. Is there any chance it would come across as though this method actually makes changes to |
25d23f9 to
6a88bce
Compare
@anandthakker Were you thinking of this as a replacement for |
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 A |
I was thinking of it as an alternative, mainly because it seems to me like it covers the |
|
@anandthakker
|
|
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: |
4b0aedb to
adcc01e
Compare
|
The original API design, I don't have a strong opinion about whether or not we also allow #2741 is about internal architecture not naming conventions. |
|
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 |
|
React's |
|
Another inconsistency: our own |
|
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 |
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 |
|
I'm not sure if there is a bug in this feature or I'm using it wrong
The idea is to select all "related" features to the selected one and color them as red (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. |
|
@shayke your version using |
|
@lucaswoj Does setFeatureState always require feature.id? Can it work on feature.properties.name? |
|
@Amit-Gore Currently |


Based on @ansis' work in
data-update-proof-of-conceptImplements
map.setFeatureState(), a new method to setkey: valuepairs 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 toget, this allows accessing a feature's state instead of source data property.todo
Improve ExpressionCreate benchmarkdynamicDrawon vertex and index buffersLaunch Checklist
cc @ansis @mollymerp