diff --git a/solara/lab/components/__init__.py b/solara/lab/components/__init__.py index 30808840f..7a90c6371 100644 --- a/solara/lab/components/__init__.py +++ b/solara/lab/components/__init__.py @@ -1,5 +1,6 @@ from .chat import ChatBox, ChatInput, ChatMessage # noqa: F401 from .confirmation_dialog import ConfirmationDialog # noqa: F401 +from .figurebokeh import FigureBokeh # noqa: F401 from .input_date import InputDate, InputDateRange # noqa: F401 from .input_time import InputTime as InputTime from .menu import ClickMenu, ContextMenu, Menu # noqa: F401 F403 diff --git a/solara/lab/components/bokehloaded.vue b/solara/lab/components/bokehloaded.vue new file mode 100644 index 000000000..643c48494 --- /dev/null +++ b/solara/lab/components/bokehloaded.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/solara/lab/components/figurebokeh.py b/solara/lab/components/figurebokeh.py new file mode 100644 index 000000000..e55ec9dd3 --- /dev/null +++ b/solara/lab/components/figurebokeh.py @@ -0,0 +1,98 @@ +from typing import Callable + +import solara +from solara.components.component_vue import component_vue +from bokeh.io import output_notebook +from bokeh.models import Plot +from bokeh.plotting import figure +from bokeh.themes import Theme +from jupyter_bokeh import BokehModel + + +@component_vue("bokehloaded.vue") +def BokehLoaded(loaded: bool, on_loaded: Callable[[bool], None]): + pass + + +@solara.component +def FigureBokeh( + fig, + dependencies=None, + light_theme: str | Theme = "light_minimal", + dark_theme: str | Theme = "dark_minimal", +): + """ + Display a Bokeh figure or Plot. + + ## Example + + ```solara + import solara + import solara.lab + from bokeh.plotting import figure + + @solara.component + def Page(): + p = figure(width=600, height=400) + p.line(x=[1, 2, 3, 4, 5], y=[2, 4, 2, 7, 9]) + + return solara.lab.FigureBokeh(p) + ``` + + For performance reasons, you might want to pass in a list of dependencies that indicate when + the figure changed, to avoid re-rendering it on every render. + + ## Arguments + + * fig: `Plot` or `figure` object to display. + * dependencies: List of dependencies to watch for changes, if None, will rerender when `fig` is changed. + * light_theme: The name or `bokeh.themes.Theme` object to use for light mode. Defaults to `"light_minimal"`. + * dark_theme: The name or `bokeh.themes.Theme` object to use for dark mode. Defaults to `"dark_minimal"`. + """ + loaded = solara.use_reactive(False) + dark = solara.lab.use_dark_effective() + output_notebook(hide_banner=True) + BokehLoaded(loaded=loaded.value, on_loaded=loaded.set) + + # TODO: there's an error with deletion on the doc. do we need to modify the underlying class? + fig_element = BokehModel.element(model=fig) + + def update_data(): + fig_widget: BokehModel = solara.get_widget(fig_element) + fig_model: Plot | figure = fig_widget._model # base class for figure + if fig != fig_model: # don't run through on first startup + # pause until all updates complete + with fig_model.hold(render=True): + # extend renderer set and cull previous + length = len(fig_model.renderers) + fig_model.renderers.extend(fig.renderers) + fig_model.renderers = fig_model.renderers[length:] + + # similarly update plot layout properties + places = ["above", "below", "center", "left", "right"] + for place in places: + attr = getattr(fig_model, place) + newattr = getattr(fig, place) + length = len(attr) + attr.extend(newattr) + setattr(fig_model, place, attr[length:]) + + def update_theme(): + # NOTE: using bokeh.io.curdoc and this _document prop will point to the same object + fig_widget: BokehModel = solara.get_widget(fig_element) + if dark: + fig_widget._document.theme = dark_theme + else: + fig_widget._document.theme = light_theme + + solara.use_effect(update_data, dependencies or fig) + solara.use_effect(update_theme, [dark, loaded.value]) + + if loaded.value: + return fig_element + else: + # NOTE: the returned object will be a v.Sheet until Bokeh is loaded + # BUG: this will show the JS error temporarily before loading + with solara.Card(margin=0, elevation=0): + with solara.Row(justify="center"): + solara.SpinnerSolara(size="200px") diff --git a/solara/website/pages/__init__.py b/solara/website/pages/__init__.py index fc1b0377d..4cf27f460 100644 --- a/solara/website/pages/__init__.py +++ b/solara/website/pages/__init__.py @@ -11,7 +11,6 @@ route_order = ["/", "showcase", "documentation", "apps", "contact", "changelog", "roadmap", "pricing", "our_team", "careers", "about", "scale_ipywidgets"] - _redirects = { "/docs": "/documentation/getting_started/introduction", "/docs/installing": "/documentation/getting_started/installing", @@ -118,6 +117,8 @@ "/documentation/examples/fullscreen/multipage": "/apps/multipage", "/examples/fullscreen/scatter": "apps/scatter", "/documentation/examples/fullscreen/scatter": "/apps/scatter", + "/examples/fullscreen/scatter-bokeh": "/apps/scatter-bokeh", + "/documentation/examples/fullscreen/scatter_bokeh": "/apps/scatter-bokeh", "/examples/fullscreen/scrolling": "/apps/scrolling", "/documentation/examples/fullscreen/scrolling": "/apps/scrolling", "/examples/fullscreen/tutorial-streamlit": "/apps/tutorial-streamlit", @@ -211,7 +212,6 @@ "/api/title": "/documentation/components/page/title", } - server._redirects = _redirects autorouting._redirects = _redirects diff --git a/solara/website/pages/apps/scatter-bokeh.py b/solara/website/pages/apps/scatter-bokeh.py new file mode 100644 index 000000000..5adbb40a1 --- /dev/null +++ b/solara/website/pages/apps/scatter-bokeh.py @@ -0,0 +1,129 @@ +import pathlib +import sys + +from typing import Optional, cast + +import vaex +import vaex.datasets + +import solara +import solara.lab +from bokeh.models import ColumnDataSource +from bokeh.plotting import figure +from bokeh.transform import linear_cmap, factor_cmap + +github_url = solara.util.github_url(__file__) +if sys.platform != "emscripten": + pycafe_url = solara.util.pycafe_url(path=pathlib.Path(__file__), requirements=["vaex", "bokeh"]) +else: + pycafe_url = None + +df_sample = vaex.datasets.titanic() + + +class State: + color = solara.reactive(cast(Optional[str], None)) + x = solara.reactive(cast(Optional[str], None)) + y = solara.reactive(cast(Optional[str], None)) + df = solara.reactive(cast(Optional[vaex.DataFrame], None)) + + @staticmethod + def load_sample(): + State.x.value = "age" + State.y.value = "fare" + State.color.value = "body" + State.df.value = df_sample + + @staticmethod + def reset(): + State.df.value = None + + +@solara.component +def Page(): + df = State.df.value + selected, on_selected = solara.use_state({"x": [0, 0]}) # noqa: SH101 + solara.provide_cross_filter() + + # the PivotTable will set this cross filter + filter, _ = solara.use_cross_filter(id(df), name="scatter") + + # only apply the filter if the filter or dataframe changes + def filter_df(): + if (filter is not None) and (df is not None): + return df[filter] + else: + return df + + dff = solara.use_memo(filter_df, dependencies=[df, filter]) + + with solara.AppBar(): + solara.lab.ThemeToggle() + with solara.Sidebar(): + with solara.Card("Controls", margin=0, elevation=0): + with solara.Column(): + with solara.Row(): + solara.Button("Sample dataset", color="primary", text=True, outlined=True, on_click=State.load_sample, disabled=df is not None) + solara.Button("Clear dataset", color="primary", text=True, outlined=True, on_click=State.reset) + + if df is not None: + columns = df.get_column_names() + solara.Select("Column x", values=columns, value=State.x) + solara.Select("Column y", values=columns, value=State.y) + solara.Select("Color", values=columns, value=State.color) + + solara.PivotTable(df, ["pclass"], ["sex"], selected=selected, on_selected=on_selected) + + if dff is not None: + source = ColumnDataSource( + data={ + "x": dff[State.x.value].values, + "y": dff[State.y.value].values, + "z": dff[State.color.value].values, + } + ) + if State.x.value and State.y.value: + p = figure(x_axis_label=State.x.value, y_axis_label=State.y.value, width_policy="max", height=700) + + # add a scatter, colorbar, and mapper + color_expr = dff[State.color.value] + if (color_expr.dtype == "string") or (color_expr.dtype == "bool"): + mapper = factor_cmap + factors = color_expr.unique() + try: + factors.remove(None) + except ValueError: + pass + args = dict(palette=f"Viridis{min(11, max(3, color_expr.nunique()))}", factors=factors) + else: + mapper = linear_cmap + args = dict(palette="Viridis256", low=color_expr.min()[()], high=color_expr.max()[()]) + + s = p.scatter(source=source, x="x", y="y", size=12, fill_color=mapper(field_name="z", **args)) + p.add_layout(s.construct_color_bar(title=State.color.value, label_standoff=6, padding=5, border_line_color=None), "right") + + solara.lab.FigureBokeh(p, dark_theme="carbon") + + else: + solara.Warning("Select x and y columns") + + else: + solara.Info("No data loaded, click on the sample dataset button to load a sample dataset, or upload a file.") + + with solara.Column(style={"max-width": "400px"}): + solara.Button(label="View source", icon_name="mdi-github-circle", attributes={"href": github_url, "target": "_blank"}, text=True, outlined=True) + if sys.platform != "emscripten": + solara.Button( + label="Edit this example live on py.cafe", + icon_name="mdi-coffee-to-go-outline", + attributes={"href": pycafe_url, "target": "_blank"}, + text=True, + outlined=True, + ) + + +@solara.component +def Layout(children): + route, routes = solara.use_route() + dark_effective = solara.lab.use_dark_effective() + return solara.AppLayout(children=children, toolbar_dark=dark_effective, color=None) # if dark_effective else "primary") diff --git a/solara/website/pages/documentation/components/lab/figurebokeh.py b/solara/website/pages/documentation/components/lab/figurebokeh.py new file mode 100644 index 000000000..d489ee331 --- /dev/null +++ b/solara/website/pages/documentation/components/lab/figurebokeh.py @@ -0,0 +1,16 @@ +""" +# FigureBokeh + +Display a Bokeh figure. + +""" + +import solara +from solara.website.components import NoPage +from solara.website.utils import apidoc + +title = "FigureBokeh" + +__doc__ += apidoc(solara.lab.components.figurebokeh.FigureBokeh.f) # type: ignore + +Page = NoPage diff --git a/solara/website/pages/documentation/examples/fullscreen/scatter_bokeh.py b/solara/website/pages/documentation/examples/fullscreen/scatter_bokeh.py new file mode 100644 index 000000000..da94f5e66 --- /dev/null +++ b/solara/website/pages/documentation/examples/fullscreen/scatter_bokeh.py @@ -0,0 +1,3 @@ +redirect = "/apps/scatter-bokeh" + +Page = True diff --git a/solara/website/pages/documentation/examples/visualization/bokeh.py b/solara/website/pages/documentation/examples/visualization/bokeh.py new file mode 100644 index 000000000..392607c65 --- /dev/null +++ b/solara/website/pages/documentation/examples/visualization/bokeh.py @@ -0,0 +1,47 @@ +"""# Scatter plot using Bokeh + +This example shows how to use Bokeh to create a scatter plot and a select box to do some filtering. + +Inspired by the bokeh documentation. +""" + +from bokeh.models import ColorBar, DataRange1d, LinearColorMapper + +from bokeh.plotting import figure, ColumnDataSource +from bokeh.sampledata import penguins + +import solara + +title = "Scatter plot using Bokeh" + +df = penguins.data + + +@solara.component +def Page(): + all_species = df["species"].unique().tolist() + species = solara.use_reactive(all_species[0]) + with solara.Div(): + solara.Select(label="Species", value=species, values=all_species) + dff = df[df["species"] == species.value] + + source = ColumnDataSource( + data={ + "x": dff["bill_length_mm"].values, + "y": dff["bill_depth_mm"].values, + "z": dff["body_mass_g"].values, + } + ) + + # make a figure + p = figure( + x_range=DataRange1d(), y_range=DataRange1d(), x_axis_label="Bill length [mm]", y_axis_label="Bill depth [mm]", width_policy="max", height=400 + ) + + # add a scatter, colorbar, and mapper + mapper = LinearColorMapper(palette="Viridis256", low=dff["body_mass_g"].min(), high=dff["body_mass_g"].max()) + cb = ColorBar(color_mapper=mapper, title="Body mass [g]") + p.scatter(source=source, x="x", y="y", marker="circle", size=8, fill_color={"field": "z", "transform": mapper}) + p.add_layout(cb, "right") + + solara.lab.FigureBokeh(p, dark_theme="carbon", dependencies=[species])