Search code examples
pythonholoviews

Create a DynamicMap in Holoviews that responds to both a RadioButton and a tap


Consider the following code that creates a Points plot that changes which DataFrame it is plotting based on a RadioButton.

import pandas as pd
import panel as pn
import holoviews as hv
hv.extension('bokeh')

df_a = pd.DataFrame(index = ['a','b', 'c', 'd'], data = {'x':range(4), 'y':range(4)})
df_b = pd.DataFrame(index = ['w','x', 'y', 'z'], data = {'x':range(4), 'y':range(3,-1,-1)})

radio_button = pn.widgets.RadioButtonGroup(options=['df_a', 'df_b'])
@pn.depends(option = radio_button.param.value)
def update_plot(option):
    if option == 'df_a':
        points = hv.Points(data=df_a, kdims=['x', 'y'])
    if option == 'df_b':
        points = hv.Points(data=df_b, kdims=['x', 'y'])
    points = points.opts(size = 10, tools = ['tap'])
    
    return points

pn.Column(radio_button, hv.DynamicMap(update_plot))

What I would like to add is functionality where when one of the points is tapped, a table to the right is filled in with location information from the corresponding DataFrame (i.e. if the lower left point is tapped when df_a is selected, the data at df_a.loc['a'] should be printed in a table.

I’ve tried a few things, but I can’t find a good way that 1) Updates the table on new clicks and 2) doesn’t reset the zoom level whenever the RadioButton selection is switched.

Number 2 is particularly important for my actual purpose (this is an extremely stripped down version).


Solution

  • This is a complicated question, mostly because holoviews is a more advanced library, but after a bunch of digging, I figured it out.


    We've got three main objects in our program.

    1. A Points graph.
    2. A RadioButtonGroup selector.
    3. A Table view.

    Both the Points and the Table views are to update dynamically based on the RadioButtonGroup, and the Table view is also going to update based on the selected Point.


    So, we're going to need two Stream objects. One, a Selection1D(), so we know when a Point is selected. Two, a custom Stream based on the RadioButtonGroup. But we'll get to that in a second.


    We've got your imports...

    import holoviews as hv
    from holoviews import streams
    import panel as pn
    import pandas as pd
    from bokeh.models import RadioButtonGroup
    hv.extension('bokeh')
    

    And your given data.

    df_a = pd.DataFrame(index = ['a','b', 'c', 'd'], data = {'x':range(4), 'y':range(4)})
    df_b = pd.DataFrame(index = ['w','x', 'y', 'z'], data = {'x':range(4), 'y':range(3,-1,-1)})
    

    Also, for convenience, let's make a dictionary based on the names, so we can easily reference the data from the RadioButtonGroup. And I'll make a variable to keep track of the current DataFrame

    dfs = {'df_a': df_a, 'df_b': df_b}
    current_df = df_a #this will change
    

    Here's where we define our DynamicMaps and the RadioButtonGroup. Streams included too. I added some comments so it is more clear.

    radio_button_group = RadioButtonGroup(labels=['df_a', 'df_b'], active=0)
    
    #STREAMS HERE. VERY IMPORTANT
    selection_stream = streams.Selection1D() #updates when Point selected
    selected_df = streams.Stream.define('selected_df', df=current_df) #updates when RadioButtonGroup selection changes.
    
    def radio_button_callback(attr, old, new): #attr not used. old is previous value of radio_button
        global current_df
        current_df = dfs[list(dfs.keys())[new]] #set current_df
        dynamic_map.event(df=current_df) #these events update the map and table.
        dynamic_table.event(df=current_df)
        selection_stream.source = dynamic_map
    
    radio_button_group.on_change("active", radio_button_callback) #trigger for callback
    
    #keywords must be called index and df.
    def update_table(index=current_df.index, df=current_df):
        if index == []: #this happens when the plot is clicked but no Point is selected.
            index = [x for x in range(len(current_df.index))]
        selected_df = current_df.iloc[index]
        return hv.Table(selected_df)
    
    def update_plot(index=0, df=current_df):
        points = hv.Points(data=df)
        return points.opts(size = 10, tools = ['tap'])
    
    dynamic_map = hv.DynamicMap(update_plot, streams=[selection_stream, selected_df()])
    dynamic_table = hv.DynamicMap(update_table, streams=[selection_stream, selected_df()])
    

    And finally, add your Panel formatting.

    column_layout = pn.Column(radio_button_group, dynamic_map)
    row_layout = pn.Row(dynamic_table, column_layout)
    

    Final Result: Streamable Link

    Please let me know if this is not the intended result, or if I am missing something important. Also, I forgot to record this, but the DynamicMap keeps the scale of the Points graph the same, even if you change the DataFrame, fulfilling your second requirement.

    Hope this helped!

    --

    EDIT AFTER COMMENT

    To have a dynamically updating title, the easiest method is to probably define another variable current_df_index (set it to 0 of course).

    Then, in the method radio_button_callback, add current_df_index to the global variables, and set it to new.

    current_df_index = new
    

    Finally, in the update_table method, let's change the return statement to a variable instead, assign the title, and then return.

    table = hv.Table(selected_df)
    table.opts(title=list(dfs.keys())[current_df_index])
    return table
    

    --

    Here is the full updated code.

    import holoviews as hv
    from holoviews import streams
    import panel as pn
    import pandas as pd
    from bokeh.models import RadioButtonGroup
    hv.extension('bokeh')
    
    df_a = pd.DataFrame(index = ['a','b', 'c', 'd'], data = {'x':range(4), 'y':range(4)})
    df_b = pd.DataFrame(index = ['w','x', 'y', 'z'], data = {'x':range(4), 'y':range(3,-1,-1)})
    
    dfs = {'df_a': df_a, 'df_b': df_b}
    current_df = df_a #this will change
    current_df_index = 0
    
    radio_button_group = RadioButtonGroup(labels=['df_a', 'df_b'], active=0)
    
    #STREAMS HERE. VERY IMPORTANT
    selection_stream = streams.Selection1D() #updates when Point selected
    selected_df = streams.Stream.define('selected_df', df=current_df) #updates when RadioButtonGroup selection changes.
    
    def radio_button_callback(attr, old, new): #attr not used. old is previous value of radio_button
        global current_df, current_df_index
        current_df = dfs[list(dfs.keys())[new]] #set current_df
        current_df_index = new
        dynamic_map.event(df=current_df) #these events update the map and table.
        dynamic_table.event(df=current_df)
        selection_stream.source = dynamic_map
    
    radio_button_group.on_change("active", radio_button_callback) #trigger for callback
    
    #keywords must be called index and df.
    def update_table(index=current_df.index, df=current_df):
        if index == []: #this happens when the plot is clicked but no Point is selected.
            index = [x for x in range(len(current_df.index))]
        selected_df = current_df.iloc[index]
        table = hv.Table(selected_df)
        table.opts(title=list(dfs.keys())[current_df_index])
        return table
    
    def update_plot(index=0, df=current_df):
        points = hv.Points(data=df)
        return points.opts(size = 10, tools = ['tap'])
    
    dynamic_map = hv.DynamicMap(update_plot, streams=[selection_stream, selected_df()])
    dynamic_table = hv.DynamicMap(update_table, streams=[selection_stream, selected_df()])
    
    column_layout = pn.Column(radio_button_group, dynamic_map)
    row_layout = pn.Row(dynamic_table, column_layout)