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..7d19c4c15 --- /dev/null +++ b/solara/lab/components/figurebokeh.py @@ -0,0 +1,158 @@ +from typing import Callable + +from bokeh.core.serialization import DeserializationError +from bokeh.io import curdoc +from bokeh.models import Plot +from bokeh.plotting import figure +from bokeh.themes import Theme +from jupyter_bokeh import BokehModel + +import solara +from solara.components.component_vue import component_vue + + +class SafeBokehModel(BokehModel): + """BokehModel with the upstream teardown faults patched out. + + Both bugs are in jupyter_bokeh 4.1.0 and are patched here rather than in a + fork + """ + + def close(self) -> None: + """Detaches document callbacks only while still registered. + + We have the explicit cleanup callback + solara cleans the widget, but + ipywidgets calls close() again from gc (via __del__), which raises KeyError + for an already deleted object + """ + # skips the BokehModel.close and goes straight to the ipywidgets teardown + super(BokehModel, self).close() + document = self._document + if document is not None: + registry = getattr(document.callbacks, "_change_callbacks", {}) + if self in registry: # only remove if we are in the registry + document.remove_on_change(self) + + def _sync_model(self, _model, content, _buffers) -> None: + """Drops frontend events that fail to deserialize. + + An event can reference a model already removed or replaced server side, + which otherwise raises DeserializationError from ipywidgets + """ + try: + super()._sync_model(_model, content, _buffers) + except DeserializationError: + return + + +@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"`. + + ## Pitfalls + - Bokeh does not support an axis type change, so changing axes via high-level interface `x_axis_type`, etc doesnt work. + - log workaround: update `x_scale` and/or `y_scale` of figure via a `use_effect` + - categorical workaround: scale/map data server-side + update tickers directly, see the scatter-bokeh.py example + - JS-side selection events on a `Tool` will fail to link to attributes of any `bokeh.model` if it is regenerated with each render. + - Occurs on `bokeh.model.tools` instances with a callback like `CustomJS(args=dict(object=object))`, such as `object.selected.indices` or similar from a `ColumnDataSource` upon a selection. + - Workaround: `use_memo` models with callbacks to exclude from regeneration, then use `use_effect` or similar for attribute updates. + + """ + # state variables + loaded = solara.use_reactive(False) + dark = solara.lab.use_dark_effective() + BokehLoaded(loaded=loaded.value, on_loaded=loaded.set) + + fig_element = BokehModel.element(model=fig) # main object + + 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]) + + def cleanup_widget(): + # explicitly adds a teardown cleanup callback for the widget on unmount + # the comm and document callbacks are detached manually, rather than on garbage collection + def cleanup(): + try: + fig_widget: BokehModel = solara.get_widget(fig_element) + except Exception: + return + if isinstance(fig_widget, BokehModel): + # close() detaches the doc callbacks + shuts the comm + fig_widget.close() + + return cleanup + + solara.use_effect(cleanup_widget, dependencies=[]) + + def set_init_theme(): + # this might or might not do something + curdoc().theme = dark_theme if dark else light_theme + + solara.use_memo(set_init_theme, dependencies=[]) + + if loaded.value: + return fig_element diff --git a/solara/website/pages/__init__.py b/solara/website/pages/__init__.py index e766dca25..bac68e0d3 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])