Updating hv.points categorical labels with a callback

I’m trying to capture user’s lasso point selections and update the categorical labels for those points using a dynamicmap. It seems like the parameterized class instance is updating (I can print the .param.get_param_values() and see the updated dict); but I think I am missing something on how streams work, because the dynamicmap doesn’t seem to update the plot (point colors don’t change, despite the change in the vdim that the cmap is based on).

Anyone else who’s used this kind of UI pattern before have some hints?

import geoviews as gv
import geoviews.tile_sources as gts
import geopandas
from cartopy import crs
import holoviews as hv
import panel as pn
import pandas as pd
from holoviews.streams import param

hv.extension('bokeh')

class TerrMapper(param.Parameterized):
        cities = ['Buenos Aires', 'Brasilia', 'Santiago', 'Bogota', 'Caracas','Tacna','Pucallpa','Lima','Langa']
        terrs = ['A','A','B','B','C','C','C','C','C']
        terr_map = param.Dict(default=dict(zip(cities, terrs)), doc = 'mapping dictionary for cities-->territories')
        terr_colors = param.Dict(default={'A':'red','B':'blue','C':'green'})
        
class Point_Remap_Test:
    def __init__(self):
        df = pd.DataFrame(
                    {'City': ['Buenos Aires', 'Brasilia', 'Santiago', 'Bogota', 'Caracas','Tacna','Pucallpa','Lima','Langa'],
                    'Country': ['Argentina', 'Brazil', 'Chile', 'Colombia', 'Venezuela','Peru','Peru','Peru','Peru'],
                    'Latitude': [-34.58, -15.78, -33.45, 4.60, 10.48, -18.006,-8.392,-12.046,-12.125],
                    'Longitude': [-58.66, -47.91, -70.66, -74.08, -66.86,-70.246,-74.582,-77.0427,-76.4211]})       
        
        self.gdf = geopandas.GeoDataFrame(df, geometry=geopandas.points_from_xy(df.Longitude, df.Latitude),crs="EPSG:3857")
        
        #tried to split the stream out to a parameterized class as part of troubleshooting; not sure this added any value
        self.terrs = TerrMapper()
        self.terrs.param.set_param(terr_map=self.terrs.param.terr_map.default) 
        
        self.territory_list = list(set(self.terrs.param.terr_map.default.values()))
        self.territory_picklist = pn.widgets.Select(options= self.territory_list,name='Territory',width=250)
        self.terr_stream = {'terr_map':self.terrs.param.terr_map}
        
        #point collection should be dynamic and update it's territory assignment based on the mapping dictionary from the stream
        self.points = hv.DynamicMap(self.dynamic_pnts,streams=self.terr_stream)
        
        #second selection stream to capture lasso point selections
        self.sel = hv.streams.Selection1D(source=self.points)

        #coloring based on New_Territory; which is the dimension that should change as self.terr_stream changes
        poly_plot = self.points.opts(color='New_Territory',cmap=self.terrs.param.terr_colors.default,tools=['lasso_select'],size=10)
        self.tiles = gts.OSM.options(level='glyph')
        self.plot=self.tiles*poly_plot
         
        #button + drop down are attempting to allow relabeling of lasso-selected points
        self.button = pn.widgets.Button(name='<--Remap Selected', button_type='primary')
        self.button.on_click(self.update_terrs)   
    
    def dynamic_pnts(self,terr_map):
        '''
        Apply current territory map to base geo dataframe
        '''
        new_df = self.gdf.copy()
        new_df.loc[:,'New_Territory'] = new_df['City'].map(terr_map)
        print('updating')
        return gv.Points(new_df,vdims=['New_Territory'])       
    
    def update_terrs(self,event):
        '''
        Update the territory map based on selections
        '''
        if len(self.sel.index)>0:
            selected_cities = self.gdf.loc[self.sel.index,'City'].values[:]
            
            #there must be a better way to do this
            param_vals = self.terrs.param.get_param_values()
            temp_dict = [v for (k,v) in param_vals if k=='terr_map'][0]

            for city in selected_cities:
                temp_dict[city] = self.territory_picklist.value
            
            self.terrs.param.set_param(terr_map=temp_dict)
            print(self.terrs.param.get_param_values())   # from printouts; it seems like parameter is being updated, but color of points remains unchanged
        else:
            print('no selection')
        
def createApp():
    geoplt = Point_Remap_Test()
    gspec = pn.GridSpec(sizing_mode='stretch_both', max_height=800,)

    #context switcher
    gspec[0,0:4] = pn.Column("#New Territory",pn.Row(geoplt.territory_picklist,geoplt.button))


    #map
    gspec[1:4,1:4] = pn.pane.HoloViews(geoplt.plot, sizing_mode="stretch_both")
    return gspec

test = createApp()
test

Hi @sbi_vm !

Are you sure terr_map is correctly updated in the app? When I tried it I didn’t see any update (it’d be nice if you could clarify how to use the app and what should happen exactly when you select points or click on the button), which means that the DynamicMap callback isn’t triggered.

Thank you so much for the quick reply @maximlt !

Desired behavior is to:

  1. lasso some points
  2. pick the territory label to be assigned (from the picklist widget)
  3. press the “<–Remap Selected” button
  4. color of points on the map should switch to the appropriate color based on their new territory assignment (A/B/C in the legend is the territory value)

for troubleshooting, I have a print statement:
print(self.terrs.param.get_param_values())

when I press the button, I can see text load above the panel cell (Jupyter notebook) that seems to indicate the terr_map dictionary was updated successfully, but the plot points don’t change color.

Ok I found the issue. In update_terrs you are obtaining the terr_map dict, assigning a new name temp_dict that refers to the same dict, changing its values, and then setting terr_map again. The problem is that since you’re using the same object, Param doesn’t know that you’ve updated it (there’s the same behavior with lists)!

So what you can do is simplify and adapt these lines:

            #there must be a better way to do this
            param_vals = self.terrs.param.get_param_values()
            temp_dict = [v for (k,v) in param_vals if k=='terr_map'][0]

down to:

            temp_dict = self.terrs.terr_map.copy()

Note the call to .copy() that will create a copy of the dictionary so that when later you set terr_map Param will react to this new data.

Hope this helps, that’s a sweet app!

1 Like