How to set Geoviews map extent programmatically in Panel dashboard

In this version I attempt to use .options(xlim,ylim) on the overlay to set the map extent e, which is a param.Parameter() after the ‘Run’ button is pressed.

This version doesn’t exactly work but I don’t know why. The properties of the map are set correctly but only work on a new instance of the map inline in the Jupyter Notebook. In the dashboard it results in the map being reprojected and the drawn boxes vanishing. I’m stumped as to why the hv.streams.BoxEdit data is removed from the map.

You will see that my code to set the extents of the map has works correctly, but is not reflected in the dashboard for some reason. It is only reflected in a new instance of the map. And while the map in the dashboard is skewed (reprojected?) the new inline map in the notebook is not.

import panel as pn
import param
import geoviews as gv
import holoviews as hv
import pandas as pd
import geopandas as gpd
from cartopy import crs
from datashader.utils import lnglat_to_meters
from shapely.geometry import Point, box

gv.extension('bokeh',logo=False)
pn.extension()


class Inputs(param.Parameterized):

input_file = param.FileSelector(path='.\jupyter-geo-box-query\sample_data\*.csv', doc='A .csv file selector for input data')
x_field    = param.Selector(doc='A field selector for longitude')
y_field    = param.Selector(doc='A field selector for latitude')
id_field   = param.Selector(doc='A field selector for the identifier')
data       = None

@param.depends('input_file', watch=True)
def update_inputs(self):
    
    df                          = pd.read_csv(self.input_file)
    numeric_cols                = list(df.select_dtypes(include=['float64']).columns)
    columns                     = [x for i,x in enumerate(df.columns) if x !='Unnamed: 0']
    self.param.x_field.objects  = numeric_cols
    self.x_field                = numeric_cols[0]
    self.param.y_field.objects  = numeric_cols
    self.x_field                = numeric_cols[0]
    self.param.id_field.objects = columns
    self.id_field               = columns[0]
    self.data                   = df

    
class Mapview(param.Parameterized):

opts          = dict(width=1200,height=750,xaxis=None,yaxis=None,show_grid=False)
tiles         = gv.tile_sources.CartoEco().apply.opts(**opts)
box_polygons  = gv.Polygons([]).opts(fill_alpha=0.1)
box_colours   = ['red','blue','green','orange','purple']
box_stream    = hv.streams.BoxEdit(source=box_polygons, num_objects=5, styles={'fill_color': box_colours})
template_df   = pd.DataFrame({'x_meters': [], 'y_meters': []}, columns=['x_meters', 'y_meters'])
dfstream      = hv.streams.Buffer(template_df, index=False, length=10000)
points        = hv.DynamicMap(hv.Points, streams=[dfstream]).opts(
                              size=5, color='green', fill_alpha=0.3, line_alpha=0.4)
e             = param.Parameter(default=[None,None,None,None],precedence=-1)

@param.depends('e',watch=True)
def show_map(self):
    print('show_map() has been called.')
    try:
        if self.e[0] is None:
            print('Using global extent')
            return (self.tiles * self.box_polygons * self.points).options(xlim=(-20000000,20000000),ylim=(-6000000,6000000))
        else:
            print(f'Using new extents: {self.e[0]},{self.e[2]},{self.e[1]},{self.e[3]}')
            return (self.tiles * self.box_polygons * self.points).options(xlim=(self.e[0],self.e[2]),ylim=(self.e[1],self.e[3]))            
    except:
        print('Problem in show_map()')


class Dashboard(param.Parameterized):

input1    = Inputs()
input2    = Inputs()
input3    = Inputs()
input4    = Inputs()
input5    = Inputs()
mapview   = Mapview()

button = param.Action(lambda x: x.param.trigger('button'), label='Run')

def project_to_meters(self, df, x, y):
    try:
        df.loc[:,'x_meters'], df.loc[:,'y_meters'] = lnglat_to_meters(df[x],df[y])
        geometry = [Point(xy) for xy in zip(df.x_meters, df.y_meters)]
        web_merc = {'init': 'epsg:3857'}
        return gpd.GeoDataFrame(df, crs=web_merc, geometry=geometry)
    except:
        print("""Could not project specified coordinate fields to meters. Make sure the X Field and Y Field 
              values have been set correctly.\n""")


def convert_box_stream_data_to_geometry(self):
    try:
        box_data = self.mapview.box_stream.data        
        try:
            num_boxes = len(box_data['x0'])
        except:
            num_boxes = 0

        boxes     = []
        for i in range(0,num_boxes):
            x0 = box_data['x0'][i]
            y0 = box_data['y0'][i]
            x1 = box_data['x1'][i]
            y1 = box_data['y1'][i]
            boxes.append(box(x0,y0,x1,y1))
        if len(boxes)>0:
            return boxes
    except:
        print('Could not convert boxes drawn on map into geometries.\n')


@param.depends('button')
def run_analysis(self):
    try:
        # clear previous results
        self.mapview.dfstream.clear()

        # Convert each input into a GeoDataFrame and project coordinates to meters
        datasets  = []
        id_fields = []
        for i in [self.input1, self.input2, self.input3, self.input4, self.input5]:
            if isinstance(i.data, pd.core.frame.DataFrame):
                gdf = self.project_to_meters(i.data, i.x_field, i.y_field)
                datasets.append(gdf)
                id_fields.append(i.id_field)

        # Convert boxes drawn on the map into Shapely geometries
        if len(datasets)>0:
            boxes     = []
            box_data = self.mapview.box_stream.data
            for i in range(0,len(box_data['x0'])):
                x0 = box_data['x0'][i]
                y0 = box_data['y0'][i]
                x1 = box_data['x1'][i]
                y1 = box_data['y1'][i]
                boxes.append(box(x0,y0,x1,y1))

            # Do intersections of each box on each GeoDataFrame & send results to the map
            df_all_boxes = pd.DataFrame()
            df_final = pd.DataFrame()
            box_labels = []
            for i in range(0,len(datasets)):
                for b in boxes:
                    box_label = self.mapview.box_colours[boxes.index(b)] + '_box'
                    box_labels.append(box_label)
                    datasets[i][box_label] = datasets[i].intersects(b)
                    selected = datasets[i].loc[datasets[i][box_label]==True].drop(columns='geometry')
                    self.mapview.dfstream.send(pd.DataFrame(selected[['x_meters','y_meters']]))                   
                    selected.rename(columns={id_fields[i]:'identifier'},inplace=True)
                    df_all_boxes = pd.concat([df_all_boxes,selected[['identifier',box_label]]],axis=0,ignore_index=True)

            for ident in pd.unique(df_all_boxes['identifier']):
                dfx = df_all_boxes.loc[df_all_boxes['identifier']==ident]
                box_values = {'identifier':[ident]}
                for bl in box_labels:
                    # returns true if any of the values are true
                    # else returns false
                    box_values[bl] = [dfx[bl].any()]
                df_ident = pd.DataFrame.from_dict(box_values)
                df_final = pd.concat([df_final, df_ident],axis=0)

            def summarize_boxes(row):
                cols = list(row.axes[0])
                s = []
                for c in cols:
                    if 'box' in c:
                        if row.values[cols.index(c)]:
                            s.append(c.split('_')[0])
                return ','.join(s)

            summary = df_final.copy()
            
            try:
                # build an extent to zoom to based on the current drawn boxes
                boxes = self.mapview.box_stream.data
                xmin = min(boxes['x0'])
                xmax = max(boxes['x1'])
                ymin = min(boxes['y0'])
                ymax = max(boxes['y1'])
                new_extent = box(xmin,ymin,xmax,ymax)
                self.mapview.e = new_extent.bounds
            except:
                print('Problem calculating new map extent.')
            
            
            try:
                summary['Found_In'] = summary.apply(lambda row: summarize_boxes(row),axis=1)
                return pn.widgets.DataFrame(summary, disabled=True, fit_columns=True, width=700)
            except:
                print('There was an error creating the final output summary table.\n')
        else:
            print('You must select at least one input dataset.\n')
    except:
        print('Something went wrong in the run_analysis() function.\n')
        return('Something went wrong in the run_analysis() function.')

   
def view(self):
    
    desc = """This is a demonstration dashboard with incomplete functionality. Its purpose
    is to sit here and look pretty. We can put graphics and stuff in here to
    make it look all fancy."""

    logo = '.\images\logo_panel_stacked_s.png'
    
    button_desc = """The <i>Run</i> button will execute the spatial intersection and generate
    a table of identifiers found within each box and in multiple boxes.<br><br>
    Push the <i>Run</i> button after configuring all inputs and drawing boxes on the map."""

    return pn.Row(
            pn.Column(
                '## Description',
                desc,logo),
            pn.Column(
                '### Configure Inputs',
                pn.Tabs(
                    ('Input 1',self.input1),
                    ('Input 2',self.input2),
                    ('Input 3',self.input3),
                    ('Input 4',self.input4),
                    ('Input 5',self.input5)),
                button_desc,
                self.param['button']),
            pn.Tabs(
                ('Map View', pn.Column(self.mapview.show_map())),
                ('Results Table', self.run_analysis)))

d = Dashboard()
d.view().show()

After you run this notebook, select a file and fields and draw boxes in the dashboard and hit ‘Run’. Look at the map. Then switch to the notebook and run this in a new cell at the bottom:

d.mapview.show_map()

Result in dashboard:


You can see that:

  • the map extents were reset to the initial extent
  • the map has been skewed (projection has changed?)
  • the green dots are there
  • the drawn boxes are there

Result in new inline map in the notebook:

You can see that:

  • the map extents were set correctly to match the extents of the drawn boxes
  • the map has not been skewed or reprojected
  • the green dots are there
  • the drawn boxes are not there

I’m confused by the differing properties of these two maps and don’t understand why they are different because they are produced by the same code.

I found this in the Holoviews FAQ so I set clone=False in my Mapview.show_map() method but the dashboard map still does not zoom to the extent I specify with xlim & ylim, but a new instance of the map/overlay does.

http://holoviews.org/FAQ.html

Q: Why are my .options(), .relabel(), .redim(), and similar settings not having any effect?

A: By default, HoloViews object methods like .options and .redim return a copy of your object, rather than modifying your original object. In HoloViews, making a copy of the object is cheap, because only the metadata is copied, not the data, and returning a copy makes it simple to work with a variety of differently customized versions of any given object. You can use .opts() or pass clone=False to .options() if you wish to modify the object in place, or you can just reassign the new object to the old name (as in e = e.relabel("New Label")

    @param.depends('e',watch=True)
    def show_map(self):
        
        self.map_layout.opts(
            gv.opts.WMTS(apply_extents=True,width=1200,height=750,show_grid=False,xaxis=None,yaxis=None),
            gv.opts.Polygons(fill_alpha=0.1),
            gv.opts.Points(size=5, color='green', fill_alpha=0.3, line_alpha=0.4),
            clone=False
        )
    
        if self.e is None:
            print('Using global extent')
            return self.map_layout
        else:
            print(f'Using new extents: {self.e[0]},{self.e[2]},{self.e[1]},{self.e[3]}')
            return self.map_layout.options(xlim=(self.e[0],self.e[2]),ylim=(self.e[1],self.e[3]),clone=False)

This version of my notebook is attached here.

Fix Extents --Options(xlim, ylim).ipynb (12.3 KB)

I’m starting to think that what I am trying to do is not currently possible and that I should submit a ticket to expose this functionality instead of pursuing this question here.

Ok, here’s a simpler example and a simpler question to try to get some clarity on this. @Marc @philippjfr I would very much appreciate your thoughts. I’ve stripped this down to a much simpler example without Panel or Param or any of my classes. Running the first cell gives me this map plot.

Then I can zoom the map & draw boxes using BoxEdit.

So far, so good. But when I run the last cell to send data to the map via hv.streams.Buffer() the map extents reset and the boxes I drew disappear.

dfstream.send(df[['lng','lat']])

I want to prevent the map from resetting and I want to prevent the boxes from disappearing. Can I do this?

The code and sample file for the points are here.

ebird_10000.csv (1.5 MB)

Map Reset Issue.ipynb (1.6 KB)

import geoviews as gv
import holoviews as hv
import pandas as pd

gv.extension('bokeh',logo=False)

tiles         = gv.tile_sources.CartoEco()

box_polygons  = gv.Polygons([])
box_colours   = ['red','blue','green','orange','purple']
box_stream    = hv.streams.BoxEdit(source=box_polygons, num_objects=5, styles={'fill_color': box_colours})

template_df   = pd.DataFrame({'lng': [], 'lat': []}, columns=['lng', 'lat'])
dfstream      = hv.streams.Buffer(template_df, index=False, length=10000)
points        = hv.DynamicMap(gv.Points, streams=[dfstream])

df = pd.read_csv('.\sample_data\ebird_10000.csv')

tiles.opts(global_extent=True,width=750,height=400) * box_polygons * points.opts(data_aspect=1)

And the last cell:

dfstream.send(df[['lng','lat']])
1 Like

Is there a bug with hv.streams.Buffer() which causes the map plot to reset extents? Is that expected behaviour? When I used hv.streams.Pipe() instead of .Buffer() the map does not reset. However, I can’t use .Pipe() as my solution because the hv.streams.Pipe.clear() method doesn’t seem to work, while calling hv.streams.Buffer.clear() does work. Is there a bug with hv.streams.Pipe.clear()?

From Working with Streaming Data — HoloViews v1.18.1Note that when using the Buffer stream the view will always follow the current range of the data by default, by setting buffer.following=False or passing following as an argument to the constructor this behavior may be disabled.

I don’t think this is working properly when the hv.streams.Buffer() is used with Geoviews. When I set following=False it is true that the map extents don’t reset but the overlay does not maintain the correct aspect ratio for the x & y axes so that the plot ends up looking like this even though I set data_aspect=1.

Hi @dr_clank.

I have been hoping to get back but have a lot of work. Maybe on the weekend. I hope other Geoviews/ Panel users would chime in. Your use case looks really awesome.

1 Like

Wow. I think I fixed it.

So creating my plot by setting data_aspect=0.5 seems to do the trick since the range of longitudes is -/+180 while the range of latitudes is -/+90 (i.e. y:x ratio of 0.5)

(tiles * box_polygons * points).opts(data_aspect=0.5)

:sob: Nope, not fixed. That only works in my simpler example notebook. When I set data_aspect=0.5 in my dashboard as below it does not resolve the problem.

class Mapview(param.Parameterized):

tiles         = gv.tile_sources.CartoEco()
extents       = param.Parameter(default=(-168, -60, 168, 83), precedence=-1)
tiles.extents = extents.default
box_polygons  = gv.Polygons([])
box_colours   = ['red','blue','green','orange','purple']
box_stream    = hv.streams.BoxEdit(source=box_polygons, num_objects=5, styles={'fill_color': box_colours})
template_df   = pd.DataFrame({'x_meters': [], 'y_meters': []}, columns=['x_meters', 'y_meters'])
dfstream      = hv.streams.Buffer(template_df, index=False, length=10000, following=False)
points        = hv.DynamicMap(hv.Points, streams=[dfstream])

def show_map(self):

    map_layout = self.tiles * self.box_polygons * self.points
        
    map_layout.opts(
        gv.opts.WMTS(width=1200,height=750,show_grid=False,xaxis=None,yaxis=None),
        gv.opts.Polygons(fill_alpha=0.1),
        hv.opts.Points(size=5, color='green', fill_alpha=0.3, line_alpha=0.4)
    )
    
    return map_layout.opts(data_aspect=0.5)

… I think I just fixed it for real. I was on the right track to set data_aspect=0.5 on my plot but the missing link was also setting the width and height properties on the plot to match that ratio. I had previously set width=1200 and height=750, creating a height:width ratio for the plot of 0.625. I’m not entirely sure why this ratio plays a role in skewing the data within the plot but it seems to play a role. When I set the plot size to width=1200, height=600 to match the data_aspectratio of 0.5 the resulting map does not skew whenhv.streams.Buffer()` sends data.

When I set following=False on the hv.streams.Buffer() it stops the map from resetting it’s extent when the hv.streams.Buffer() sends data.

2 Likes

Would you mind sharing the full, working example?

I would like to give it a refactor and maybe prepare it as a reference example for the Panel Gallery.

Thanks.

@Marc my plotting solution somehow broke the code that does the summary table creation so I’ve got a couple of things to fix before it’s working.

1 Like

@Marc sorry for the lag, this is a work project and I ran into a bit of red tape. I’ve posted code with the correctly working map features here.