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