Skip to content
1 change: 1 addition & 0 deletions solara/lab/components/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
27 changes: 27 additions & 0 deletions solara/lab/components/bokehloaded.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<template>
<div v-if="!loaded">
<div class="loading-text"></div>
</div>
</template>

<script>
module.exports = {
mounted() {
const check = () => {
if (window.Bokeh) {
this.loaded = true;
return;
}
setTimeout(check, 100);
};
check();
},
};
</script>

<style>
.loading-text {
margin-top: 10px;
font-size: 16px;
}
</style>
158 changes: 158 additions & 0 deletions solara/lab/components/figurebokeh.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions solara/website/pages/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -211,7 +212,6 @@
"/api/title": "/documentation/components/page/title",
}


server._redirects = _redirects
autorouting._redirects = _redirects

Expand Down
129 changes: 129 additions & 0 deletions solara/website/pages/apps/scatter-bokeh.py
Original file line number Diff line number Diff line change
@@ -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")
16 changes: 16 additions & 0 deletions solara/website/pages/documentation/components/lab/figurebokeh.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
redirect = "/apps/scatter-bokeh"

Page = True
Loading