pythonbokehholoviews

Link DynamicMap to two streams in Holoviews


I have the following code that does mostly what I want it to do: make a scatter plot colored by a value, and a linked DynamicMap that shows an associated timeseries with each Point when you tap on it

import pandas as pd 
import holoviews as hv 
import panel as pn 
from holoviews import streams
import numpy as np

hv.extension('bokeh')

df = pd.DataFrame(data = {'id':['a', 'b', 'c', 'd'], 'type':['d', 'd', 'h', 'h'], 'value':[1,2,3,4], 'x':range(4), 'y':range(4)})

points = hv.Points(data=df, kdims=['x', 'y'], vdims = ['id', 'type', 'value']).redim.range(value=(0,None))
options = hv.opts.Points(size = 10, color = 'value', tools = ['hover', 'tap'])

df_a = pd.DataFrame(data = {'id':['a']*5, 'hour':range(5), 'value':np.random.random(5)})
df_b = pd.DataFrame(data = {'id':['b']*5, 'hour':range(5), 'value':np.random.random(5)})
df_c = pd.DataFrame(data = {'id':['c']*10, 'hour':range(10), 'value':np.random.random(10)})
df_d = pd.DataFrame(data = {'id':['d']*10, 'hour':range(10), 'value':np.random.random(10)})
df_ts = pd.concat([df_a, df_b, df_c, df_d])
df_ts = df_ts.set_index(['id', 'hour'])

stream = hv.streams.Selection1D(source=points)
empty = hv.Curve(df_ts.loc['a']).opts(visible = False)
def tap_station(index):
    if not index:
        return empty
    id = df.iloc[index[0]]['id']
    
    return hv.Curve(df_ts.loc[id], label = str(id)).redim.range(value=(0,None))

ts_curve = hv.DynamicMap(tap_station, kdims=[], streams=[stream]).opts(framewise=True, show_grid=True)

pn.Row(points.opts(options), ts_curve)

However, there is one more thing I want to do: have the 'd' and 'h' Points have different marker shapes.

One thing I can do is change the options line to this:

options = hv.opts.Points(size = 10, color = 'value', tools = ['hover', 'tap'], 
                         marker = hv.dim("type").categorize({'d': 'square', 'h':'circle'}))

But with this, holoviews doesn't show a legend that distinguishes between the two marker shapes

The other thing I can do is something like this:

df_d = df[df['type'] == 'd']
df_h = df[df['type'] == 'h']

d_points = hv.Points(data=df_d, kdims=['x', 'y'], vdims = ['id', 'type', 'value'], label = 'd').redim.range(value=(0,None)).opts(marker = 's')
h_points = hv.Points(data=df_h, kdims=['x', 'y'], vdims = ['id', 'type', 'value'], label = 'h').redim.range(value=(0,None)).opts(marker = 'o')

d_stream = hv.streams.Selection1D(source=d_points)
h_stream = hv.streams.Selection1D(source=h_points)

This gets me the legend I want, but then I'm not sure how to make a DynamicMap that is linked to both of those streams, and responds to clicks on both marker shapes.

Again, ultimately what I want is a Point plot with two marker shapes (based on type), colored by value, that responds to clicks by pulling up a timeseries plot to the right.

Thanks for any help!


Solution

  • You can use the rename() method of Stream to change the stream name, and then use two arguments of the tap_station() function to receive the two streams, Here is the full code:

    import pandas as pd 
    import holoviews as hv 
    import panel as pn 
    from holoviews import streams
    import numpy as np
    
    hv.extension('bokeh')
    
    df = pd.DataFrame(data = {'id':['a', 'b', 'c', 'd'], 'type':['d', 'd', 'h', 'h'], 'value':[1,2,3,4], 'x':range(4), 'y':range(4)})
    
    points = hv.Points(data=df, kdims=['x', 'y'], vdims = ['id', 'type', 'value']).redim.range(value=(0,None))
    
    options = hv.opts.Points(size = 10, color = 'value', tools = ['hover', 'tap'], 
                             marker = hv.dim("type").categorize({'d': 'square', 'h':'circle'}), show_legend=True)
    
    df_d = df[df['type'] == 'd']
    df_h = df[df['type'] == 'h']
    
    d_points = hv.Points(data=df_d, kdims=['x', 'y'], vdims = ['id', 'type', 'value'], label = 'd').redim.range(value=(0,None)).opts(marker = 's')
    h_points = hv.Points(data=df_h, kdims=['x', 'y'], vdims = ['id', 'type', 'value'], label = 'h').redim.range(value=(0,None)).opts(marker = 'o')
    points = d_points * h_points
    
    # rename the streams
    stream_d = hv.streams.Selection1D(source=d_points).rename(index="index_d")
    stream_h = hv.streams.Selection1D(source=h_points).rename(index="index_h")
    
    df_a = pd.DataFrame(data = {'id':['a']*5, 'hour':range(5), 'value':np.random.random(5)})
    df_b = pd.DataFrame(data = {'id':['b']*5, 'hour':range(5), 'value':np.random.random(5)})
    df_c = pd.DataFrame(data = {'id':['c']*10, 'hour':range(10), 'value':np.random.random(10)})
    df_d = pd.DataFrame(data = {'id':['d']*10, 'hour':range(10), 'value':np.random.random(10)})
    df_ts = pd.concat([df_a, df_b, df_c, df_d])
    df_ts = df_ts.set_index(['id', 'hour'])
    
    empty = hv.Curve(df_ts.loc['a']).opts(visible = False)
    
    # receive the two streams by different argument name
    def tap_station(index_d, index_h):
        if not index_d and not index_h:
            return empty
        elif index_d:
            id = df_d.iloc[index_d[0]]['id']
        elif index_h:
            id = df_h.iloc[index_h[0]]['id']
        
        return hv.Curve(df_ts.loc[id], label = str(id)).redim.range(value=(0,None))
    
    # pass the two streams to DynamicMap
    ts_curve = hv.DynamicMap(tap_station, kdims=[], streams=[stream_d, stream_h]).opts(framewise=True, show_grid=True)
    
    pn.Row(points.opts(options), ts_curve)