Skip to content

Commit

Permalink
feat: bokeh support
Browse files Browse the repository at this point in the history
  • Loading branch information
rileythai committed Feb 7, 2025
1 parent 9763283 commit 9272987
Show file tree
Hide file tree
Showing 6 changed files with 281 additions and 0 deletions.
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>
73 changes: 73 additions & 0 deletions solara/lab/components/figurebokeh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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


def FigureBokeh(
fig,
dependencies=None,
light_theme: str | Theme = "light_minimal",
dark_theme: str | Theme = "dark_minimal",
):
# NOTE: no docstring because not a component.
loaded = solara.use_reactive(False)
dark = solara.lab.use_dark_effective()
fig_key = solara.use_uuid4([])
output_notebook(hide_banner=True)
BokehLoaded(loaded=loaded.value, on_loaded=loaded.set)
if loaded.value:
# TODO: there's an error with deletion on the doc. do we need to modify the underlying class?
fig_element = BokehModel.element(model=fig).key(fig_key)

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
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)
if place == "right":
fig_model.hold_render = False
setattr(fig_model, place, attr[length:])
return

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])
return fig_element
else:
# NOTE: we don't return this as to not break effect callbacks outside this function
with solara.Card(margin=0, elevation=0):
# the card expands to fit space
with solara.Row(justify="center"):
solara.SpinnerSolara(size="200px")
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

# 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.provide_cross_filter()
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")
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
redirect = "/apps/scatter-bokeh"

Page = True
48 changes: 48 additions & 0 deletions solara/website/pages/documentation/examples/visualization/bokeh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""# 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() as main:
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])
return main

0 comments on commit 9272987

Please sign in to comment.