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

Interactive vector tile layers #1213

Merged
merged 13 commits into from
Jul 22, 2024

Conversation

lopezvoliver
Copy link
Contributor

@lopezvoliver lopezvoliver commented Jun 28, 2024

Renderer factory

Vector tile layers can be rendered using either L.canvas.tile or L.svg.tile. Currently, only the canvas option is implemented in ipyleaflet and it's not doing a great job at higher levels of zoom, see for example #1095.

Here a new renderer option was added to VectorTileLayer, which can be either svg or canvas, with the new default being svg.

Here's an example using the ms-buildings from Microsoft Planetary Computer, with the new default (svg):

import ipyleaflet
m = ipyleaflet.Map(center=(41.91867,-88.10602), zoom=15)
url = 'https://planetarycomputer.microsoft.com/api/data/v1/vector/collections/ms-buildings/tilesets/global-footprints/tiles/{z}/{x}/{y}'
layer = ipyleaflet.VectorTileLayer(
    url=url, 
    attribution='Microsoft', 
    max_native_zoom=13,
    max_zoom=20,
    vector_tile_layer_styles={"bingmlbuildings":{
        "fill":True,
        "weight":0.5
    }},    
)
m.add(layer)
m

And here's how it renders using renderer='canvas':

Interactivity

A new interactive option was added with False as default, which enables the user to add listeners to the layer, which include information about the feature. Note that the default renderer='svg' option should be used for interactivity. For example:

m = ipyleaflet.Map()
layer = ipyleaflet.VectorTileLayer(
    url=url_data,  # My vector tile layer 
    interactive=True,  # New interactive option 
    max_native_zoom=13,
    max_zoom=20,
    renderer='svg', # New renderer option. Defaults to 'svg'
    layer_styles=jStyles,   # javascript function given as a string
    feature_id = 'label', # New feature_id option. Here, 'label' is the name of the (numeric) 
    # attribute in my layer that uniquely identifies each feature (see below for more information)
)
m.add(layer)

def handle_click(**kwargs):    
    if ("properties" in kwargs):
        properties = kwargs["properties"]
        options = kwargs["options"]
        print(properties)
        print(options)

layer.on_click(handle_click)  

m

feature_id

This is an optional attribute that is used to construct a simple javascript function to uniquely identify a feature. This is required if you will be updating feature styles through the new set_feature_style and reset_feature_style methods. The javascript function is of the form:

function (feat: any) {return feat.properties[idVar];}

where feat is the feature, and idVar is the name of the (numeric) attribute in the layer to identify a feature. Note that features with the same id will be treated as one when changing style (see the original getFeatureId documentation here).

Updating styles

Two new methods for VectorTileLayer were added: set_feature_style and reset_feature_style. The first one is used to update the style for an individual feature, which is useful for highlighting a feature (e.g., on click or mouseover), while the second one is useful for resetting the style to the default (e.g. to clear the highlight).

Example

Here's a motivating example that demonstrates all of the new features.

m = ipyleaflet.Map()
layer = ipyleaflet.VectorTileLayer(
    url=url_data,  # My vector tile layer 
    interactive=True,  # New interactive option 
    max_native_zoom=13,
    max_zoom=20,
    renderer='svg', # New renderer option. Defaults to 'svg'
    layer_styles=jStyles,   # javascript function given as a string
    feature_id = 'label', # New feature_id option. Here, 'label' is the name of the (numeric) 
    # attribute in my layer that uniquely identifies each feature.
)
m.add(layer)

info_title = "<h4>Field info</h4>"
info_default_value = "Hover over a field" 

info_widget = widgets.HTML(value=info_title + info_default_value)

highlight_style = {
                "weight": 5,
                "color": '#666',
                "dashArray": '',
                "fillOpacity": 0.7,
                "fill": True 
                }

def highlight_feature(**kwargs):    
    if ("properties" in kwargs):
        properties = kwargs["properties"]
        options = kwargs["options"]
        feature_id = properties["label"]
        fill_color = options["fillColor"]
        highlight_style["fillColor"] = fill_color
        layer.set_feature_style(
            id=feature_id,
            layer_style=highlight_style,
        )
        info_html = info_title
        for k,v in properties.items():
            info_html += "<b>"+ k + "</b>" + ": " + str(v) + "<br />"
        info_widget.value = info_html

def clear_highlight(**kwargs):
    if layer.feature_style: 
        layer.reset_feature_style(layer.feature_style["id"])
        info_widget.value = info_title + info_default_value
        
layer.on_mouseover(highlight_feature)
layer.on_mouseout(clear_highlight)

widget_control = ipyleaflet.WidgetControl(widget=info_widget, position='topright')
m.add(widget_control)

m

@lopezvoliver lopezvoliver marked this pull request as draft July 2, 2024 16:44
@lopezvoliver lopezvoliver changed the title Add renderer_factory option Interactive vector tile layers Jul 2, 2024
@lopezvoliver lopezvoliver marked this pull request as ready for review July 2, 2024 18:25
@lopezvoliver
Copy link
Contributor Author

@martinRenou could you please suggest someone for review?

@martinRenou
Copy link
Member

Thanks! I'll have a look

layer_styles: dict
Style to apply to the feature
"""
self.send({"msg":
Copy link
Member

Choose a reason for hiding this comment

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

I'm wondering if this could be a traitlets property instead of a custom comm message?

Something like

feature_styles = Dict().tag(sync=True)

feature_styles being the {id: layerStyle} dictionary?

Custom comm messages are transient and will not be saved in your widget model, resulting in many issues.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

How would that work in VectorTileLayer.ts so that the this.obj.setFeatureStyle is called when the python method is called?

Copy link
Member

Choose a reason for hiding this comment

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

You would need to set a new value for feature_styles Python side:

def set_feature_style(self, id:Int, layer_style:Dict):
    self.feature_styles = {**feature_styles, id: layer_style}

Then you would listen for changes in the JavaScript side:

this.model.on("change:feature_styles", () => {
  const feature_styles = this.model.get("feature_styles");
  // You may need to reset all previous features styles here
  // [...]
  for (const id of Object.keys(feature_styles)) {
    this.obj.setFeatureStyle(id, feature_styles[id]);
  }
})

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, I'll give it a try and make a new commit.

FYI I am also testing another commit with changes to how rendererFactory, getFeatureId, and vectorTileLayerStyles are exposed in python, because I encountered an issue as follows:

  1. Create a vector tile layer with renderer_factory='svg', which is a string.
  2. In javascript, the rendererFactory is set to a function L.svg.tile.
  3. Change the opacity in python.
  4. Move the map
  5. Error in javascript -- because renderer_factory is in sync with rendererFactory, it's now set to the string svg, and it throws a javascript error that the rendererFactory is not a function.

The new change to avoid this issue is that in python, instead of rendererFactory I will have a separate option called renderer, which is used in javascript to create rendererFactory. A similar strategy can be used for getFeatureId and vectorTileLayerStyles because they are constructed in javascript as functions, they shouldn't be in 'sync' with python.

I will aim to commit this change soon as well.

@martinRenou
Copy link
Member

Here's an example using the ms-buildings from Microsoft Planetary Computer, with the new default (svg):

@lopezvoliver I was curious to run this example, but it seems I'm getting 404s on this https://planetarycomputer.microsoft.com url when LeafletJS tries to download the tiles. Is there anything particular to do to allow this example to work?

@lopezvoliver
Copy link
Contributor Author

Here's an example using the ms-buildings from Microsoft Planetary Computer, with the new default (svg):

@lopezvoliver I was curious to run this example, but it seems I'm getting 404s on this https://planetarycomputer.microsoft.com url when LeafletJS tries to download the tiles. Is there anything particular to do to allow this example to work?

I just checked and it should work as is. Are you able to access the following tile directly?

https://planetarycomputer.microsoft.com/api/data/v1/vector/collections/ms-buildings/tilesets/global-footprints/tiles/13/2090/3043

This layer is only available until z=13, so the maxNativeZoom (added in #1206) is set to 13.

@lopezvoliver
Copy link
Contributor Author

Here's a description of the recent five commits:

  1. As suggested, I used feature_style as a traitlets property so that we can add observe its change in javascript and call the setFeatureStyle method. The user can call the set_feature_style method with an id and a layer_style dictionary. The user can also call the reset_feature_style method with an id, which updates the same feature_style traitlets property but with a reset value set to True. At this time, only one id should be used at a time, as is the intended use case of the original setFeatureStyle and resetFeatureStyle methods in VectorGrid.

  2. I realized that in Vector tile layer opacity and visible #1210 one small issue persisted: when initializing a VectorTileLayer with visible=False, this wasn't reflected initially. This commit fixes this issue by setting opacity=0 after initializing the vector tile layer with visible=False.

3, 4, and 5: Each of these commits is a decision to rename vector_tile_layer_styles, renderer_factory, and get_feature_id (the latter two are additions in this pull request) to a different name in python (layer_styles, renderer, and feature_id, respectively). The reason for this is that because all three are converted to functions in javascript, they shouldn't be synced to the python model. Instead, they are used to construct the javascript counterparts VectorTileLayerStyles, rendererFactory, and getFeatureId.

I will update the original PR comment to reflect these changes where needed.

@martinRenou
Copy link
Member

This layer is only available until z=13, so the maxNativeZoom (added in #1206) is set to 13.

Ah right I was not using the main branch! Thanks for the clarification.

Here's a description of the recent five commits:

Is it ready for another review?

Happy to make a release of your recent PRs after this one is merged

@lopezvoliver
Copy link
Contributor Author

lopezvoliver commented Jul 12, 2024

Is it ready for another review?

Yes, it's ready for review. (Edit: I just pushed one last commit -- I figured out a way to keep vector_tile_layer_styles as input for backwards compatibility)

Happy to make a release of your recent PRs after this one is merged

That would be great, thanks!


def __init__(self, **kwargs):
super(VectorTileLayer, self).__init__(**kwargs)
# Backwards compatibility: allow vector_tile_layer_styles as input:
Copy link
Member

Choose a reason for hiding this comment

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

This will be backward compatible only at the layer creation.

I see three options here:

  • should we really rename this property? If not we can keep vector_tile_layer_styles?
    If yes:
  • we remain backward compatible and we'd need a Python setter too @vector_tile_layer_styles.setter def set_vector_tile_layer_styles
  • we remove backward compatibility and go with 0.20.0

I'd prefer one of the two first options

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@martinRenou

I went ahead with option 2: vector_tile_layer_styles is backwards compatible, and it uses a setter that updates layer_styles.

Regarding the ability to update styles after initialization -- this wasn't contemplated in #1206, so I also fixed this by adding a listener to layer_styles.

Here's a demo of the new capabilities:

Copy link
Member

@martinRenou martinRenou left a comment

Choose a reason for hiding this comment

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

Thanks!

@martinRenou
Copy link
Member

The CI would deserve some love, let's do that separately

@martinRenou martinRenou merged commit 8a500e7 into jupyter-widgets:master Jul 22, 2024
2 of 4 checks passed
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.

2 participants