Graphics

Interactivity

Basics

To handle events on plot, pass boolean arguments for click, dblclick, hover, and brush. Their values will be accessed via combination of output plot ID and event type.

  • click: the value will be accessed via input.{id}_click(), where {id} is replaced with ID of the plot. The input value will be a dictionary with x and y elements indicating the mouse position.
  • dblclick: the value will be accessed via input.{id}_dblclick(), where {id} is replaced with ID of the plot. The input value will be a dictionary with x and y elements indicating the mouse position.
  • hover: the value will be accessed via input.{id}_hover(), where {id} is replaced with ID of the plot. The input value will be a dictionary with x and y elements indicating the mouse position.
  • brush: the value will be accessed via input.{id}_brush(), where {id} is replaced with ID of the plot. The value will be a dictionary with xmin, xmax, ymin, and ymax elements indicating the brush area.
examples/action-graphics/plot-click/app.py
from shiny import App, render, ui, req
from matplotlib import pyplot as plt
from pydataset import data

mtcars = data("mtcars")

app_ui = ui.page_fluid(
    ui.output_plot("plot", click=True),
    ui.output_text_verbatim("info"),
)

def server(input, output, session):
    @render.plot
    def plot():
        res = plt.scatter(mtcars['wt'], mtcars['mpg'])
        return res
    
    @render.text
    def info():
        req(input.plot_click())
        x = round(input.plot_click()['x'], 2)
        y = round(input.plot_click()['y'], 2)
        return f"[{x}, {y}]"

app = App(app_ui, server)
Note

req() is used to make sure that the app does not do anything before the first click. This will be more discussed in later chapters.

Warning

It did not work well with plot created by using {plotnine} package; the mouse position in input value did not align with actual event. For example, a snapshot below shows that I dragged a region for [2, 3] on x-axis and [20, 30] on y-axis, but the input values were read as [1.7, 2.9] for x-axis and [21.9, 30.6] for y-axis.

Input value for {plotline} plots does not align with actual user input on app.

Clicking

Prints data points near the click.

examples/action-graphics/near-points/app.py
from shiny import App, render, ui, req
from matplotlib import pyplot as plt
from pydataset import data
import numpy as np
from sklearn.metrics import pairwise_distances

mtcars = data("mtcars")

# near_points(mtcars, {'x': 3.5, 'y': 15}, 'wt', 'mpg')
def near_points(df, position, xvar, yvar, threshold=20):
    points = df[[xvar, yvar]].copy().to_numpy()
    click = np.array([[position['x'], position['y']]])

    # Convert data into different scale
    # For now, put some logic
    # TO DO: revise with correct formula to convert it to pixel scale
    min_xvar = min([position['domain']['left'], position['domain']['right']])
    max_xvar = max([position['domain']['left'], position['domain']['right']])
    min_yvar = min([position['domain']['bottom'], position['domain']['top']])
    max_yvar = max([position['domain']['bottom'], position['domain']['top']])

    range_x = position['range']['right'] - position['range']['left']
    range_y = position['range']['top'] - position['range']['bottom']

    points[:,0] = position['range']['left'] + (points[:,0] - min_xvar) / (max_xvar - min_xvar) * range_x
    click[:,0] = position['range']['left'] + (click[:,0] - min_xvar) / (max_xvar - min_xvar) * range_x
    points[:,1] = position['range']['bottom'] + (points[:,1] - min_yvar) / (max_yvar - min_yvar) * range_y
    click[:,1] = position['range']['bottom'] + (click[:,1] - min_yvar) / (max_yvar - min_yvar) * range_y
    
    # Find points within threshold
    # For now, just list in a sequence of appearance in data
    # TO DO: sort by distance from closest to farthest
    near = pairwise_distances(points, click) < threshold
    return df[near]


app_ui = ui.page_fluid(
    ui.output_plot("plot", click=True),
    ui.output_table("data"),
)

def server(input, output, session):
    @render.plot
    def plot():
        res = plt.scatter(mtcars['wt'], mtcars['mpg'])
        return res
    
    @render.table
    def data():
        req(input.plot_click())
        return near_points(mtcars, input.plot_click(), xvar='wt', yvar='mpg')

app = App(app_ui, server)
Caution

I could not find a function in Shiny for Python that is corresponding to nearPoints() in Shiny for R, so I implemented my own function near_points(). It does not behave exactly same to the R function, and I hope to find a better implementation.

Warning

In my experiment, the table frequently disappears immediately after appearing on a screen, and I do not know the exact reason.

Brushing

examples/action-graphics/brushing/app.py
from shiny import App, render, ui, req
from matplotlib import pyplot as plt
from pydataset import data

mtcars = data("mtcars")

# brushed_points(mtcars, {'xmin': 2, 'xmax': 3, 'ymin': 20, 'ymax': 30}, 'wt', 'mpg')
def brushed_points(df, position, xvar, yvar):
    in_region = ((df[xvar] > position['xmin']) 
                 & (df[xvar] < position['xmax'])
                 & (df[yvar] > position['ymin'])
                 & (df[yvar] < position['ymax']))
    
    return df[in_region]


app_ui = ui.page_fluid(
    ui.output_plot("plot", brush=True),
    ui.output_table("data"),
)

def server(input, output, session):
    @render.plot
    def plot():
        res = plt.scatter(mtcars['wt'], mtcars['mpg'])
        return res
    
    @render.table
    def data():
        req(input.plot_brush())
        return brushed_points(mtcars, input.plot_brush(), xvar='wt', yvar='mpg')

app = App(app_ui, server)
Caution

I could not find a function in Shiny for Python that is corresponding to brushedPoints() in Shiny for R, so I implemented my own function brushed_points().

Modifying the plot

Resizing point size in a scatter plot

examples/action-graphics/resizing/app.py
from shiny import App, ui, render, reactive
import pandas as pd
import numpy as np
from sklearn.metrics import pairwise_distances
from matplotlib import pyplot as plt

np.random.seed(1014)
df = pd.DataFrame({
    'x': np.random.normal(size=100),
    'y': np.random.normal(size=100)
})

def compute_distance(df, position, xvar, yvar):
    points = df[[xvar, yvar]].copy().to_numpy()
    click = np.array([[position['x'], position['y']]])

    # Compute distance
    dist = pairwise_distances(points, click)

    return dist

app_ui = ui.page_fluid(
    ui.output_plot("plot", click=True),
)

def server(input, output, session):
    dist = reactive.value([1 for _ in range(df.shape[0])])

    @reactive.effect
    @reactive.event(input.plot_click)
    def _():
        dist.set(compute_distance(df, input.plot_click(), xvar='x', yvar='y'))
    
    @render.plot
    def plot():
        size = np.minimum(np.power(dist.get(), 5), 300)
        res = plt.scatter(df['x'], df['y'], s=size)
        return res

app = App(app_ui, server)
Note

R example in Mastering Shiny book used R function nearPoints(), which is a built-in function in {shiny} R package, to compute distance between mouse click position and every each point in the plot in pixel scale. Because Shiny for Python does not have equivalent built-in function, I revised an example to compute the distance in original data scale and use a derived value from the distance as a size of each point.

Changing point color

from shiny import App, render, ui, reactive
from matplotlib import pyplot as plt
from pydataset import data
import pandas as pd

mtcars = data("mtcars")

# brushed_points(mtcars, {'xmin': 2, 'xmax': 3, 'ymin': 20, 'ymax': 30}, 'wt', 'mpg')
def brushed_points(df, position, xvar, yvar):
    in_region = ((df[xvar] > position['xmin']) 
                 & (df[xvar] < position['xmax'])
                 & (df[yvar] > position['ymin'])
                 & (df[yvar] < position['ymax']))
    
    return df[in_region], in_region


app_ui = ui.page_fluid(
    ui.output_plot("plot", brush=True, dblclick=True),
)

def server(input, output, session):
    selected = reactive.value([False for _ in range(mtcars.shape[0])])

    @reactive.effect
    @reactive.event(input.plot_brush)
    def _():
        _, brushed = brushed_points(mtcars, input.plot_brush(), xvar='wt', yvar='mpg')
        selected.set(brushed | selected.get())
    
    @reactive.effect
    @reactive.event(input.plot_dblclick)
    def _():
        selected.set([False for _ in range(mtcars.shape[0])])

    @render.plot
    def plot():
        color = pd.Categorical(selected(), categories=[True, False])
        fig, ax = plt.subplots()
        res = ax.scatter(mtcars['wt'], mtcars['mpg'], c=color, label=color)
        # produce a legend with the unique colors from the scatter
        legend1 = ax.legend(*res.legend_elements(), loc="lower left", title="Classes")
        ax.add_artist(legend1)        
        return res

app = App(app_ui, server)
Note

I slightly modified brushed_points() function that I used in a previous brushing examples, to return not only data points but also binary indicator whether each row in original data was selected or not in a current brushing event.

Dynamic height and width

Dynamically resizing plot size: height and width.

I could not exactly reproduce an example that exists in Mastering Shiny book for R. Let me show my code first, and let me explain more details.

examples/action-graphics/dynamic-height-width/app.py
from shiny import App, ui, render, reactive
import matplotlib.pyplot as plt
from numpy.random import normal

app_ui = ui.page_fluid(
    ui.input_slider("height", "height", min=100, max=500, value=250),
    ui.input_slider("width", "width", min=100, max=500, value=250),
    ui.output_ui("plot_container"),
)

def server(input, output, session):
    @render.plot
    def plot():
        return plt.scatter(normal(size=20), normal(size=20))

    @render.ui
    def plot_container():
        return ui.output_plot("plot", 
                              height=f"{input.height()}px", 
                              width=f"{input.width()}px")

app = App(app_ui, server)

I followed an approach that was proposed by Vincent Nijs. It uses dynamic UI, ui.output_ui() and render.ui(), which will be discussed in later chapters.

The noticable difference between this app and R app presented in Mastering Shiny book is that, this app will keep refreshing the plot and regenerate the data when changing a plot size, while the original R app that uses width and height arguments in renderPlot() does not regenerate the data and only changes the plot’s appearance.

Warning

This app will keep refreshing the plot and regenerate the data when changing a plot size.

Important

@render.plot(width=input.width(), height=input.height()) returns an error message "_send_error_response: No current reactive context". @render.plot(width=input.width, height=input.height) returns an error message "TypeError: unsupported operand type(s) for /: 'Value' and 'float'". See relevant issue here.

Images

examples/action-graphics/puppy/app.py
from shiny import App, ui, render
from shiny.types import ImgData
import pandas as pd

puppies = pd.DataFrame({
    'breed': ["corgi", "labrador", "spaniel"],
    'id': ["eoqnr8ikwFE", "KCdYn0xu2fU", "TzjMd7i5WQI"],
    'author': ["alvannee", "shaneguymon", "_redo_"],
})

app_ui = ui.page_fluid(
    ui.input_select("id", "Pick a breed", 
                    choices=dict(zip(puppies['id'], puppies['breed']))),
    ui.output_ui("source"),
    ui.output_image("photo"),
)

def server(input, output, session):
    @render.image
    def photo():
        img: ImgData = {"src": f"puppy-photos/{input.id()}.jpg", 
                        "width": "500px", "height": "650px"}
        return img
    
    @render.ui
    def source():
        info = puppies[puppies['id'] == input.id()]
        res = ui.HTML(f"""<p>
            <a href='https://unsplash.com/photos/{info['id'].squeeze()}'>original</a> by
            <a href='https://unsplash.com/@{info['author'].squeeze()}'>{info['author'].squeeze()}</a>
            </p>""")
        
        return res

app = App(app_ui, server)

ImgData has additional attributes like alt and style. See here.

Note

When I run the app on my interactive development environment, directory puppy-photos exists in a root directory of the project. It may not be an ideal configuration for deployment. An example on the Shiny for Python website set a directory of image path to be relative to a directory that app.py file exists.

from pathlib import Path

dir = Path(__file__).resolve().parent