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])