posit-dev / py-shiny

Shiny for Python
https://shiny.posit.co/py/
MIT License
1.26k stars 75 forks source link

Set plot height dynamically using a function passed to @render.plot? #504

Open vnijs opened 1 year ago

vnijs commented 1 year ago

R-shiny has an option to specify a function that can be used to set the height of the plot window. I expected something similar to work for py-shiny with @render.plot as below. However, that returns an error (try_render_matplotlib() got multiple values for argument 'height')

Questions: Is there a way to set plot window height and width dynamically in py-shiny?

If this is a question better posted to posit community, please let me know.

@render.plot(height = my_function)
output$a_plot <- renderPlot({
...
}, height = my_function)
jcheng5 commented 1 year ago

Unless I'm reading this wrong, I don't think you can currently set the width/height of the plot from the server side at all--it always looks at the width/height of the DOM element. Any chance you're up for taking a stab at a PR?

vnijs commented 1 year ago

I see. Is it not possible to translate what you already have working for R-shiny?

jcheng5 commented 1 year ago

It totally is possible, it’s not a big change, we just haven’t done it yet. I just thought you might not want to wait, in which case you could do it yourself. If you don’t feel up to it, that’s fine, we’ll try to get to it soon!

vnijs commented 1 year ago

@jcheng5 Building apps using the great tools the shiny team creates is a sufficient challenge for me for now :) Still early days, but if you think an app would be of interest to add to an example gallery, let me know. First one is linked below.

https://github.com/vnijs/pyrsm/blob/main/pyrsm/radiant/regress.py

schloerke commented 1 year ago

@vnijs

Does dynamic UI work for your use case? When necessary, the UI output could be re-rendered and can contain new height/width values. Then, a new plot will automatically be rendered when the UI output component is visible.

If parameter is adopted, I take it the height function would also need to be reactive?


Would having the plot fill the available vertical space work? We have layout capabilities similar to https://rstudio.github.io/bslib/articles/filling.html#filling-the-window coming to py-shiny.

vnijs commented 1 year ago

@schloerke I hadn't thought about dynamic ui. Is there any "cost" to re-rendering the ui elements instead of only changing the height setting? In my apps there could be 5-10 different types of plots the user would select from a drop down. See screenshots below. The first plot is fine and a fixed format but the second could have 20-30 different subplots depending on what the user selects.

Automatic height detection could be really interesting. Not sure if you mean the settings adapt to the size of the plot or if the size of the plot is adapted to the available space. I'd be interested in the former. I played around with streamlet a bit recently (super simple to use btw) and it seems to have that functionality built in by default (see example file linked below). This saves the app developer from having to manage plot dimensions and I liked that feature a lot. Would this work well together with sidebar and navbar layouts?

https://github.com/vnijs/pyrsm/blob/main/pyrsm/streamlet/streamlet-logistic-app.py

image

image

schloerke commented 1 year ago

@vnijs I misunderstood the original request. Agreed, the height variable (and others) should be able to be reactive.


I took the morning and rewrote your pyrsm streamlet-logistic-app.py into a Shiny application.

Code

File: app.py ```py from __future__ import annotations from pathlib import Path import textwrap from shiny import ui, Inputs, Outputs, Session, App, render, reactive, req # from shiny.types import FileInfo import pandas as pd import pyrsm as rsm import io from contextlib import redirect_stdout from datetime import datetime app_ui = ui.page_fluid( # shinyswatch.theme.darkly(), ui.layout_sidebar( ui.panel_sidebar( ui.input_file( "file", "Upload file", accept=[".csv", ".xlsx", ".pkl"], ), ui.output_ui("file_notice"), ui.output_ui("df_select"), ), ui.panel_main( ui.navset_tab_card( ui.nav( "Data", ui.output_table("df_table"), ui.output_ui("df_description"), ), ui.nav( "Summary", ui.output_text_verbatim("lr_summary"), ui.output_text_verbatim("lr_code"), ), ui.nav( "Plot", ui.output_ui("lr_plot_container"), ui.output_text_verbatim("lr_plot_code"), ), ), ), # fillable=True, ), ) def get_df(file_path): file_name = Path(file_path).name fname = file_name.split(".")[0] extension = file_name.split(".")[-1].upper() if extension == "CSV": df = pd.read_csv(file_path) code = f"""{fname} = pd.read_csv({file_name})""" elif extension in ["XLS", "XLSX"]: df = pd.read_excel(file_path, engine="openpyxl") code = f"""{fname} = pd.read_excel("{file_name}", engine="openpyxl")""" elif extension in ["PKL", "PICKLE"]: df = pd.read_pickle(file_path) code = f"""{fname} = pd.read_pickle("{file_name}")""" return df, fname, code # @st.cache_resource def logistic_regression(df, X, y): return rsm.logistic(dataset=df, rvar=y, evar=X) # @st.cache_data def logistic_summary(_lr): out = io.StringIO() with redirect_stdout(out): _lr.summary() dt_string = datetime.now().strftime("%d/%m/%Y %H:%M:%S") out.write(f"Date and time: {dt_string}") # st.write(f"Date and time: {dt_string}") return out.getvalue() # @st.cache_data def logistic_plot(_lr, plots="or"): return _lr.plot(plots=plots) def server(input: Inputs, output: Outputs, session: Session): @output @render.ui def file_notice(): if not input.file(): return "Upload a .csv, .xlsx or .pkl file to get started" @reactive.Calc def df_info(): req(input.file()) return get_df(input.file()[0]["datapath"]) @reactive.Calc def dt(): return df_info()[0] @reactive.Calc def fname(): return df_info()[1] @reactive.Calc def code(): return df_info()[2] #### Uncomment next sections to use when developing locally w/ local `diamonds.csv` data # @reactive.Calc # def dt(): # df = pd.read_csv( # Path(__file__).parent / "diamonds.csv", # nrows=100, # ) # return df # @reactive.Calc # def fname(): # return "diamonds" # @reactive.Calc # def code(): # return ( # f"""{fname()} = pd.read_csv("{Path(__file__).parent / "diamonds.csv"}")""" # ) @output @render.ui def df_select(): req(dt() is not None) df_cols = dt().columns.tolist() return ui.TagList( ui.input_selectize( "rvar", "Response variable:", df_cols, selected=df_cols[0], ), ui.input_selectize( "evar", "Explanatory variables:", df_cols[1:], selected=df_cols[1], multiple=True, ), ui.input_selectize( "plot_type", "Plot type:", { "or": "Odds ratio", "pred": "Prediction", "vimp": "Variable important", }, ), ) @reactive.Effect def _(): req(dt() is not None, input.rvar()) df_cols = dt().columns.tolist() rvar = input.rvar() with reactive.isolate(): evar = input.evar() if rvar not in evar: return new_evar = [val for val in evar if val != rvar] df_cols.remove(rvar) ui.update_selectize("evar", choices=df_cols, selected=new_evar) @output @render.table def df_table(): return dt() @output @render.ui def df_description(): if not hasattr(dt(), "description"): return None return dt().description @reactive.Calc def lr(): req(dt() is not None, input.evar(), input.rvar()) return logistic_regression(dt(), input.evar(), input.rvar()) @output @render.text def lr_summary(): return logistic_summary(lr()) @output @render.text def lr_code(): req(code(), fname(), input.rvar(), input.evar()) return textwrap.dedent( f"""\ {code()} lr = rsm.logistic(dataset={fname()}, rvar="{input.rvar()}", evar={input.evar()}) lr.summary()\ """ ) @output @render.ui def lr_plot_container(): req(lr(), input.plot_type(), input.evar()) height = 400 if input.plot_type() == "pred": height = 200 * int((len(input.evar()) + 1) / 2) elif input.plot_type() == "or": height = 40 * (len(lr().fitted.params) + 2) return (ui.output_plot("lr_plot", height=f"{height}px"),) @output @render.plot def lr_plot(): req(lr(), input.plot_type()) return logistic_plot(lr(), plots=input.plot_type()) @output @render.text def lr_plot_code(): req(code(), fname(), input.rvar(), input.evar(), input.plot_type()) return textwrap.dedent( f"""\ {code()} lr = rsm.logistic(dataset={fname()}, rvar="{input.rvar()}", evar={input.evar()}) lr.plot(plots="{input.plot_type()}")\ """ ) app = App(app_ui, server=server) ```

Approach

Have the Plot tab (Tab 3) contain a dynamic UI that resolves to a plot output with statically defined height. When ever we want to recalculate the height, we recalculate a new container that has a new plot output component with altered specs. This approach isn't noticeably slower as the plot needs to be replotted with a the new height value.

Notes:

schloerke commented 1 year ago

@vnijs

I hadn't thought about dynamic ui. Is there any "cost" to re-rendering the ui elements instead of only changing the height setting?

Until a proper fix is made, it will add a extra round trip on the WebSocket: to send the updated container contents and respond that a plot is visible and ready to be calculated. Then the standard plotting update will occur.

Automatic height detection could be really interesting. Not sure if you mean the settings adapt to the size of the plot or if the size of the plot is adapted to the available space. I'd be interested in the former.

It is the latter option: The size of the plot is adapted to the available space. Shiny does not inspect your plots and make judgement calls on how they should be displayed.

I played around with streamlet a bit recently (super simple to use btw) and it seems to have that functionality built in by default (see example file linked below). This saves the app developer from having to manage plot dimensions and I liked that feature a lot.

I did not see how your height was being dynamically set by streamlit. Can you point me to the lines of code? Thank you!

Would this work well together with sidebar and navbar layouts?

Assuming we're talking about Filling Layouts working with sidebars and different layouts... Yes! I have really enjoyed using them during development. Hoping to get the into py-shiny's ui within the month.

vnijs commented 1 year ago

@schloerke Thanks a lot for the responses and the shiny version of the code! If you are able to run the streamlet app you should see that it sets the height automatically. See also the video linked below. The formatting/sizing of the plots is exactly how I'd want them and streamlet seems to set this automatically without the need for the user/app developer to set parameters.

https://youtu.be/VG1hd7egfZg

vnijs commented 1 year ago

BTW I really liked the "code" output object that streamlet has. The syntax highlighting isn't that great but still better than plain black text and it also has an easy to use "copy-to-clipboard" option which is nice. See screenshot below.

image

schloerke commented 1 year ago

Thank you for the video!

It is interesting to see how (at render time) streamlit captures the current width and allows the height to grow naturally up to a max height.

Shiny (typically) fixes the plot container height and allows the plot container to expand its width. Given the final container size, the plot is rendered.

Yup... still curious how the plot height is determined within streamlit.

jcheng5 commented 1 year ago

In matplotlib it looks like the figure itself can have opinions about its width/height (fig.get_figwidth/fig.get_figheight), that is in inches but there's also fig.get_dpi. That is certainly useful information to Shiny, for aspect ratio purposes if nothing else!

vnijs commented 1 year ago

That works really well @jcheng5! Combining your suggestion with @schloerke dynamic ui example above I came to the following, where make_plot contains the plotting code you want to run. The only thing I would still like to fix is that when the browser window is narrow, the plot "breaks" out of the ui.column frame I'm using.

@reactive.Calc
def gen_plot():
    make_plot(...)
    fig = plt.gcf() # get current plot
    width, height = fig.get_size_inches()  # Get the size in inches
    return fig, width * 96, height * 96

@output(id="plot")
@render.plot
def plot():
    plots = input.plots()
    if plots != "None":
        return gen_plot()[0]

@output(id="plot_container")
@render.ui
def plot_container():
    req(...)
    width, height = gen_plot()[1:]
    return ui.output_plot("plot", height=f"{height}px", width=f"{width}px")

image

youngroklee-ml commented 9 months ago

Hello, I appreciate great discussions above regarding this issue. Has there been any update to dynamically set width and/or height parameter values to the render.plot() function, e.g., @render.plot(width=input.width(), height=input.height())?

I am trying to reproduce R example for Dynamic Height and Width in Hadley Wickham's , which has a live demo at https://hadley.shinyapps.io/ms-resize/ . When adjusting plot size, it changes only the plot size, not the underlying random data points generated inside renderPlot()'s expr argument.

I created the following app per @vnijs 's example above, but it still behave differently from original R example, because with the dynamic UI, random number generation inside the render function runs everytime when the width or height is changed.

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)