Extract the spatial extents of a Holoviews pane that contains a map

Hello,

I’m working on an application that visualizes geospatial vector data, with ESRI Imagery as an overlay. Given the extensive nature of my vector dataset, it’s impractical to show the whole dataset on the map. Therefore, I’m aiming to integrate a feature where users can navigate the map and click a button to load the data relevant to the map’s current extent (geospatial join operation between the dataset and the bounds from the map). So clicking the button should trigger an event which joins the dataset to the current map extent, and updates the map. I can achieve such geospatial join by using FloatInput widgets for the west, south, east north extent of the region of interest. Yet, this is not very user friendly, so my preference is to have a button. My problem is that I cannot extract the extent from the map.

To give you a better idea of the attributes I’m looking for; with IPyleaflet you can do something like this:

from ipyleaflet import Map, basemaps
m = Map(
    basemap=basemaps.Esri.WorldImagery,
    scroll_wheel_zoom=True,
    center=(37.768137, -122.511066),
    zoom=16,
)
bounds = (m.west, m.south, m.east, m.north) # only after the map has been rendered. 

This is a minimum reproducible example of my app:

import geopandas as gpd
import geoviews as gv
import geoviews.tile_sources as gvts
import panel as pn
import shapely.geometry


class SimpleApp:
    def __init__(self, bounds):
        self.data = gpd.read_file(gpd.datasets.get_path("naturalearth_lowres"))
        self.bounds = bounds
        self.region = gpd.GeoDataFrame(
            geometry=[shapely.geometry.box(*bounds)], crs=4326
        )

        # Input widgets to accept new bounds, but start with bounds provided in init
        self.bound_widgets = {
            "west": pn.widgets.FloatInput(name="West", value=bounds[0]),
            "south": pn.widgets.FloatInput(name="South", value=bounds[1]),
            "east": pn.widgets.FloatInput(name="East", value=bounds[2]),
            "north": pn.widgets.FloatInput(name="North", value=bounds[3]),
        }

        self.extract_button = pn.widgets.Button(name="Load data by map extent")
        self.extract_button.on_click(self._extent_from_map)

        self.view = pn.depends(
            self.bound_widgets["west"].param.value,
            self.bound_widgets["south"].param.value,
            self.bound_widgets["east"].param.value,
            self.bound_widgets["north"].param.value,
        )(self._update_map)

        self._app = self._compose()

    def get_view_region(self):
        data_region = gpd.sjoin(self.data, self.region).drop(columns=["index_right"])
        gv_data = gv.Polygons(data_region)
        return gv_data * gvts.EsriImagery

    def _update_map(self, west, south, east, north):
        self.region = gpd.GeoDataFrame(
            geometry=[shapely.geometry.box(west, north, east, south)], crs=4326
        )
        return self.get_view_region()

    def _extent_from_map(self, event):
        # Here we have to extract the actual values from the map. So when when a user has
        # zoomed to a different area, and clicks the "load data by map extent" button, it should update the map.
        # For now, let's use some dummy values for demonstration.
        new_bounds = [0, 30, 10, 40]
        self.bound_widgets["west"].value = new_bounds[0]
        self.bound_widgets["south"].value = new_bounds[1]
        self.bound_widgets["east"].value = new_bounds[2]
        self.bound_widgets["north"].value = new_bounds[3]

    def _compose(self):
        title_bar = pn.Row(
            pn.pane.Markdown("## Simple App "),
            pn.Spacer(),
        )

        widgets_column = pn.Column(*self.bound_widgets.values(), self.extract_button)

        app = pn.Column(title_bar, widgets_column, self.view)
        return app

    def viewable(self):
        return self._app


bounds = [3.6, 51, 8, 54]
app = SimpleApp(bounds)
app.viewable()
print(app.viewable())
Column
    [0] Row
        [0] Markdown(str)
        [1] Spacer()
    [1] Column
        [0] FloatInput(name='West', value=3.6)
        [1] FloatInput(name='South', value=51)
        [2] FloatInput(name='East', value=8)
        [3] FloatInput(name='North', value=54)
        [4] Button(name='Load data by map extent')
    [2] ParamFunction(function, _pane=HoloViews, defer_load=False)

Here are some resources that I believe can be relevant!

Show individual points when zoomed, else datashade

https://pydeas.readthedocs.io/en/latest/holoviz_interactions/tips_and_tricks.html#Wrap-DynamicMap-around-Panel-methods-to-maintain-extents-and-improve-runtime

Thanks! I’ll provide an update once I have it working.