1

I built an RShiny dashboard with plotly-r using the palmer penguins dataset so that when I click on a bar chart segment, it uses that event data to filter a dataset.

On Hover:
enter image description here

On Click:
enter image description here

I wanted to build a similar dashboard using Shiny for Python, but haven't been able to get the customdata to work that way it did in R and I cannot figure out how to handle a click event.

I've been able to the establish the customdata object to capture the categorical data I want for each bar segment, but I wasn't able to find an event_click() function in plotly for python similar to that of plotly-r.

Can someone please tell me how I can get this functionality working? Presently this code runs the shiny for python dashboard, but doesn't respond to any sort of click events anywhere on the bar chart.

I think I probably need help in the @render_widget part of the server function. Here is my Python Source Code:

# Load data and compute static values
from shiny import App, reactive, render, ui
from shinywidgets import output_widget, render_widget, render_plotly
from plotnine import ggplot, aes, geom_bar
import plotly.graph_objects as go
import palmerpenguins
from plotly.callbacks import Points, InputDeviceState
points, state = Points(), InputDeviceState()


df_penguins = palmerpenguins.load_penguins()

dict_category = {'species':'Species','island':'Island','sex':'Gender'}

def filter_shelf():
    return ui.card(
        ui.card_header(
            "Filters", 
            align="center",
        ),
        # Gender Filter
        ui.input_checkbox_group(
            'sex_filter', 
            label='Gender', 
            choices={value:(value.capitalize() if (type(value)==str) else value) for value in df_penguins['sex'].unique()},
            selected=list(df_penguins['sex'].unique()),
        ),

        # Species Filter
        ui.input_checkbox_group(
            'species_filter', 
            label='Species', 
            choices=list(df_penguins['species'].unique()),
            selected=list(df_penguins['species'].unique()),
        ),

        # Island Filter
        ui.input_checkbox_group(
            'island_filter', 
            label='Island', 
            choices=list(df_penguins['island'].unique()),
            selected=list(df_penguins['island'].unique()),
        ),
    )

def parameter_shelf():
    return ui.card(
        ui.card_header(
            'Parameters',
            align='center',
        ),

        # Category Selector
        ui.input_radio_buttons(
            'category', 
            label = 'View Penguins by:', 
            choices=dict_category,
            selected = 'species',
        ),
    ),

app_ui = ui.page_fluid(
    ui.h1("Palmer Penguins Analysis"),
    ui.layout_sidebar(
        # Left Sidebar
        ui.sidebar(
            filter_shelf(),
            parameter_shelf(),
            width=250,
        ),
        
        # Main Panel
        ui.card( # Plot
            ui.card_header(ui.output_text('chart_title')),
            output_widget('penguin_plot'),
        ),
        ui.card( # Table
            ui.card_header(ui.output_text('total_rows')),
            ui.column(
                12, #width
                ui.output_table('table_view'),
                style="height:300px; overflow-y: scroll"
            )
        )
    ),
)

def server (input, output, session):
    
    @reactive.calc
    def category():
        '''This function caches the appropriate Capitalized form of the selected category'''
        return dict_category[input.category()]

    # Dynamic Chart Title
    @render.text
    def chart_title():
        return "Number of Palmer Penguins by Year, colored by "+category()


    @reactive.calc
    def df_filtered_stage1():
        '''This function caches the filtered datframe based on selections in the view'''
        return df_penguins[
            (df_penguins['species'].isin(input.species_filter())) &
            (df_penguins['island'].isin(input.island_filter())) &
            (df_penguins['sex'].isin(input.sex_filter()))]

    @reactive.calc
    def df_filtered_stage2():
        df_filtered_st2 = df_filtered_stage1() 
        # Eventually add additional filters on dataset from segments selected on the visual
        return df_filtered_st2 
    
    @reactive.calc
    def df_summarized():
        return df_filtered_stage2().groupby(['year',input.category()], as_index=False).count().rename({'body_mass_g':"count"},axis=1)[['year',input.category(),'count']]

    @reactive.calc
    def filter_fn():
        print("Clicked!") # This never gets called
        
    @render_widget
    def penguin_plot():
        df_plot = df_summarized()
        bar_columns = list(df_plot['year'].unique()) # x axis column labels
        bar_segments = list(df_plot[input.category()].unique()) # bar segment category labels
        data = [go.Bar(name=segment, x=bar_columns,y=list(df_plot[df_plot[input.category()]==segment]['count'].values), customdata=[input.category()], customdatasrc='A') for segment in bar_segments]
        fig = go.Figure(data)
        fig.update_layout(barmode="stack")
        fig = go.FigureWidget(fig)

        ##### TRYING TO CAPTURE CLICK EVENT ON A BAR SEGMENT HERE #####
        fig.data[0].on_click(
            filter_fn
            )
        return fig
    
    @render.text
    def total_rows():
        return "Total Rows: "+str(df_filtered_stage1().shape[0])

    @render.table
    def table_view():
        df_this=df_summarized()
        return df_filtered_stage1()

app = App(app_ui, server)

It looks like I can only capture click events by the trace. I'm wondering if there is a better way than what I'm doing above because fig.data has 3 bar traces during runtime when viewing by "Species" (Gentoo, Chinstrap, and Adelie) and it seems that each bar trace is what gets an on_click() method.

1 Answer 1

1

Below is a variant where I changed several parts, here are the most important ones:

  • In your example filter_fn never gets invoked because it does not depend on any reactive expression. Shiny has no need to call it.

    You can do it like this: We define a reactive.value called filter which contains the filter information from the on_click event (filter = reactive.value({})) and the function

    def setClickedFilterValues(trace, points, selector):
      if not points.point_inds:
          return
      filter.set({"Year": points.xs, "Category": points.trace_name})
    

    which is set to be the on_click event on every trace:

    for trace in fig.data:
      trace.on_click(setClickedFilterValues)
    

    The if clause in the function checks whether you are on the clicked trace, if not, stop. filter then contains the right values. An important point here is that the function does not get a reactive decorator like @reactive.calc. This is not needed because we only update the value.

  • I modified df_filtered_stage2() to take account on filter, this calculates the data frame for the output displayed below on the app.

    @reactive.calc
    def df_filtered_stage2():
      if filter.get() == {}:
          return df_filtered_stage1()
      df_filtered_st2 = df_filtered_stage1()[
          (df_filtered_stage1()['year'].isin(filter.get()['Year'])) &
          (df_filtered_stage1()[input.category()].isin([filter.get()['Category']]))
      ] 
      return df_filtered_st2 
    
  • Similar to above and your R app one can implement the on_hover event, this is included below.

It looks like this:

enter image description here

# Load data and compute static values
from shiny import App, reactive, render, ui
from shinywidgets import output_widget, render_widget, render_plotly
from plotnine import ggplot, aes, geom_bar
import plotly.graph_objects as go
import palmerpenguins
from plotly.callbacks import Points, InputDeviceState
points, state = Points(), InputDeviceState()


df_penguins = palmerpenguins.load_penguins()

dict_category = {'species':'Species','island':'Island','sex':'Gender'}

def filter_shelf():
    return ui.card(
        ui.card_header(
            "Filters", 
            align="center",
        ),
        # Gender Filter
        ui.input_checkbox_group(
            'sex_filter', 
            label='Gender', 
            choices={value:(value.capitalize() if (type(value)==str) else value) for value in df_penguins['sex'].unique()},
            selected=list(df_penguins['sex'].unique()),
        ),

        # Species Filter
        ui.input_checkbox_group(
            'species_filter', 
            label='Species', 
            choices=list(df_penguins['species'].unique()),
            selected=list(df_penguins['species'].unique()),
        ),

        # Island Filter
        ui.input_checkbox_group(
            'island_filter', 
            label='Island', 
            choices=list(df_penguins['island'].unique()),
            selected=list(df_penguins['island'].unique()),
        ),
    )

def parameter_shelf():
    return ui.card(
        ui.card_header(
            'Parameters',
            align='center',
        ),

        # Category Selector
        ui.input_radio_buttons(
            'category', 
            label = 'View Penguins by:', 
            choices=dict_category,
            selected = 'species',
        ),
    ),

app_ui = ui.page_fluid(
    ui.h1("Palmer Penguins Analysis"),
    ui.layout_sidebar(
        # Left Sidebar
        ui.sidebar(
            filter_shelf(),
            parameter_shelf(),
            width=250,
        ),
        
        # Main Panel
        ui.card( # Plot
            ui.card_header(ui.output_text('chart_title')),
            output_widget('penguin_plot'),
        ),
        ui.output_text_verbatim('hoverInfoOutput'),
        ui.card( # Table
            ui.card_header(ui.output_text('total_rows')),
            ui.column(
                12, #width
                ui.output_table('table_view'),
                style="height:300px; overflow-y: scroll"
            )
        )
    ),
)
   

def server (input, output, session):
    
    filter = reactive.value({})
    hoverInfo = reactive.value({})
    
    @reactive.calc
    def category():
        '''This function caches the appropriate Capitalized form of the selected category'''
        return dict_category[input.category()]

    # Dynamic Chart Title
    @render.text
    def chart_title():
        return "Number of Palmer Penguins by Year, colored by "+category()


    @reactive.calc
    def df_filtered_stage1():
        '''This function caches the filtered datframe based on selections in the view'''
        return df_penguins[
            (df_penguins['species'].isin(input.species_filter())) &
            (df_penguins['island'].isin(input.island_filter())) &
            (df_penguins['sex'].isin(input.sex_filter()))]

    @reactive.calc
    def df_filtered_stage2():
        if filter.get() == {}:
            return df_filtered_stage1()
        df_filtered_st2 = df_filtered_stage1()[
            (df_filtered_stage1()['year'].isin(filter.get()['Year'])) &
            (df_filtered_stage1()[input.category()].isin([filter.get()['Category']]))
        ] 
        return df_filtered_st2 
    
    @reactive.calc
    def df_summarized():
        return df_filtered_stage1().groupby(['year',input.category()], as_index=False).count().rename({'body_mass_g':"count"},axis=1)[['year',input.category(),'count']] 

    def setClickedFilterValues(trace, points, selector):
        if not points.point_inds:
            return
        filter.set({"Year": points.xs, "Category": points.trace_name})
        
    def setHoverValues(trace, points, selector):
        if not points.point_inds:
            return
        hoverInfo.set(points)
    
    @render_widget
    def penguin_plot():
        df_plot = df_summarized()
        bar_columns = list(df_plot['year'].unique()) # x axis column labels
        bar_segments = list(df_plot[input.category()].unique()) # bar segment category labels
        data = [go.Bar(name=segment, x=bar_columns,y=list(df_plot[df_plot[input.category()]==segment]['count'].values), customdata=[input.category()], customdatasrc='A') for segment in bar_segments]
        fig = go.Figure(data)
        fig.update_layout(barmode="stack")
        fig = go.FigureWidget(fig)
        
        for trace in fig.data:
            trace.on_click(setClickedFilterValues)
            trace.on_hover(setHoverValues)
        
        return fig
    
    @render.text
    def hoverInfoOutput():
        return hoverInfo.get()
    
    @render.text
    def total_rows():
        return "Total Rows: "+str(df_filtered_stage2().shape[0])

    @render.table
    def table_view():
        #df_this=df_summarized()
        return df_filtered_stage2()

app = App(app_ui, server)
4
  • You got it working, thank you! I'm wondering if you can clear up a likely fundamental question I have about the non-reactive function setClickedFilterValues(). I initially tried something like it from the plotly documentation: plotly.com/python/click-events but I wasn't sure how to get the reactivity to transfer over to Shiny. I was getting errors around having a function that accepts 3 arguments (trace, points, selector), but none were provided. How come your line of code: trace.on_click(setClickedFilterValues) implicitly knows about the trace, points, and selector objects? Commented Jul 7 at 18:40
  • Concerning your follow-up question I think I have to take a deeper look into the source code, I will do this and tell you ...
    – Jan
    Commented Jul 7 at 18:51
  • I appreciate it! FWIW, even when I pulled out the lines of code: from plotly.callbacks import Points, InputDeviceState points, state = Points(), InputDeviceState() the trace.on_click() still seemed to understand the points, and selector objects even though when I dropped a breakpoint on that line, neither of those objects were defined in the local or global scope. I confirmed this by trying to access them in the debug console. I suppose trace is just the trace instance passing itself to setClickedFilterValues(trace, points, selector)? Commented Jul 7 at 19:35
  • Yes, this will most likely be the case, though I currently can't confirm it in the source code. I may have a look later, but e.g. here you see the definition of on_click, which only registers the callback. One can investigate further how the _click_callbacks are defined, but I think the traces will be similar as the Points.
    – Jan
    Commented Jul 7 at 19:45

Not the answer you're looking for? Browse other questions tagged or ask your own question.