diff --git a/e2e_playwright/bokeh_chart_basics.py b/e2e_playwright/bokeh_chart_basics.py index 875698e..ae0eae9 100644 --- a/e2e_playwright/bokeh_chart_basics.py +++ b/e2e_playwright/bokeh_chart_basics.py @@ -12,11 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +import numpy as np +import pandas as pd import streamlit as st from bokeh.plotting import figure from chart_types import CHART_TYPES -import numpy as np -import pandas as pd + from streamlit_bokeh import streamlit_bokeh np.random.seed(0) @@ -188,9 +189,9 @@ def lorenz(xyz, t): line_width=1.5, ) elif chart == "linear_cmap": - from numpy.random import standard_normal from bokeh.transform import linear_cmap from bokeh.util.hex import hexbin + from numpy.random import standard_normal x = standard_normal(50000) y = standard_normal(50000) @@ -279,6 +280,6 @@ def lorenz(xyz, t): p.legend.location = "top_left" p.legend.orientation = "horizontal" -streamlit_bokeh(p, use_container_width=False) +streamlit_bokeh(p, use_container_width=False, key="chart_1") -streamlit_bokeh(p, use_container_width=True) +streamlit_bokeh(p, use_container_width=True, key="chart_2") diff --git a/streamlit_bokeh/__init__.py b/streamlit_bokeh/__init__.py index 1ac96b5..80d0d98 100644 --- a/streamlit_bokeh/__init__.py +++ b/streamlit_bokeh/__init__.py @@ -12,14 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. +import importlib.metadata import json import os +import re from typing import TYPE_CHECKING -import streamlit.components.v1 as components -import importlib.metadata + import bokeh +import streamlit as st from bokeh.embed import json_item +if TYPE_CHECKING: + from bokeh.plotting.figure import Figure + + # Create a _RELEASE constant. We'll set this to False while we're developing # the component, and True when we're ready to package and distribute it. # (This is, of course, optional - there are innumerable ways to manage your @@ -27,38 +33,65 @@ _DEV = os.environ.get("DEV", False) _RELEASE = not _DEV -# Declare a Streamlit component. `declare_component` returns a function -# that is used to create instances of the component. We're naming this -# function "_component_func", with an underscore prefix, because we don't want -# to expose it directly to users. Instead, we will create a custom wrapper -# function, below, that will serve as our component's public API. - -# It's worth noting that this call to `declare_component` is the -# *only thing* you need to do to create the binding between Streamlit and -# your component frontend. Everything else we do in this file is simply a -# best practice. - -if not _RELEASE: - _component_func = components.declare_component( - # We give the component a simple, descriptive name ("streamlit_bokeh" - # does not fit this bill, so please choose something better for your - # own component :) - "streamlit_bokeh", - # Pass `url` here to tell Streamlit that the component will be served - # by the local dev server that you run via `npm run start`. - # (This is useful while your component is in development.) - url="http://localhost:3001", + +def _version_ge(a: str, b: str) -> bool: + """ + Return True if version string a is greater than or equal to b. + + The comparison extracts up to three numeric components from each version + string (major, minor, patch) and compares them as integer tuples. + Non-numeric suffixes (for example, 'rc1', 'dev') are ignored. + + Parameters + ---------- + a : str + The left-hand version string. + b : str + The right-hand version string to compare against. + + Returns + ------- + bool + True if a >= b, otherwise False. + """ + + def parse(v: str) -> tuple[int, int, int]: + nums = [int(x) for x in re.findall(r"\d+", v)[:3]] + while len(nums) < 3: + nums.append(0) + return nums[0], nums[1], nums[2] + + return parse(a) >= parse(b) + + +_STREAMLIT_VERSION = importlib.metadata.version("streamlit") + +# If streamlit version is >= 1.51.0 use Custom Component v2 API, otherwise use +# Custom Component v1 API +# _IS_USING_CCV2 = _version_ge(_STREAMLIT_VERSION, "1.51.0") +# Temporarily setting this to False, will be updated in next PR. +_IS_USING_CCV2 = False + +# Version-gated component registration +if _IS_USING_CCV2: + _component_func = st.components.v2.component( + "streamlit-bokeh.streamlit_bokeh", + js="v2/index-*.mjs", + html="
", ) else: - # When we're distributing a production version of the component, we'll - # replace the `url` param with `path`, and point it to the component's - # build directory: - parent_dir = os.path.dirname(os.path.abspath(__file__)) - build_dir = os.path.join(parent_dir, "frontend/build") - _component_func = components.declare_component("streamlit_bokeh", path=build_dir) + if not _RELEASE: + _component_func = st.components.v1.declare_component( + "streamlit_bokeh", + url="http://localhost:3001", + ) + else: + parent_dir = os.path.dirname(os.path.abspath(__file__)) + build_dir = os.path.join(parent_dir, "frontend/build") + _component_func = st.components.v1.declare_component( + "streamlit_bokeh", path=build_dir + ) -if TYPE_CHECKING: - from bokeh.plotting.figure import Figure __version__ = importlib.metadata.version("streamlit_bokeh") REQUIRED_BOKEH_VERSION = "3.8.0" @@ -112,14 +145,28 @@ def streamlit_bokeh( f"{REQUIRED_BOKEH_VERSION}` to install the correct version." ) - # Call through to our private component function. Arguments we pass here - # will be sent to the frontend, where they'll be available in an "args" - # dictionary. - _component_func( - figure=json.dumps(json_item(figure)), - use_container_width=use_container_width, - bokeh_theme=theme, - key=key, - ) + if _IS_USING_CCV2: + # Call through to our private component function. + _component_func( + key=key, + data={ + "figure": json.dumps(json_item(figure)), + "bokeh_theme": theme, + "use_container_width": use_container_width, + }, + isolate_styles=False, + ) + + return None + else: + # Call through to our private component function. Arguments we pass here + # will be sent to the frontend, where they'll be available in an "args" + # dictionary. + _component_func( + figure=json.dumps(json_item(figure)), + use_container_width=use_container_width, + bokeh_theme=theme, + key=key, + ) - return None + return None diff --git a/streamlit_bokeh/frontend/src/v2/index.test.ts b/streamlit_bokeh/frontend/src/v2/index.test.ts new file mode 100644 index 0000000..e96d9c5 --- /dev/null +++ b/streamlit_bokeh/frontend/src/v2/index.test.ts @@ -0,0 +1,140 @@ +/** + * Copyright (c) Snowflake Inc. (2025) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { beforeEach, describe, expect, test } from "vitest" + +import { + getChartDataGenerator, + getChartDimensions, + setChartThemeGenerator, +} from "./index" +import { MinimalStreamlitTheme } from "./streamlit-theme" + +describe("getChartDataGenerator", () => { + let getChartData: (figure: string) => { + data: object | null + hasChanged: boolean + } + + beforeEach(() => { + getChartData = getChartDataGenerator() + }) + + test("should return parsed data and hasChanged true on first call", () => { + const figure = JSON.stringify({ key: "value" }) + const result = getChartData(figure) + + expect(result).toEqual({ data: { key: "value" }, hasChanged: true }) + }) + + test("should return hasChanged false for the same figure", () => { + const figure = JSON.stringify({ key: "value" }) + getChartData(figure) + const result = getChartData(figure) + + expect(result).toEqual({ data: { key: "value" }, hasChanged: false }) + }) + + test("should return hasChanged true for a different figure", () => { + getChartData(JSON.stringify({ key: "value" })) + const newFigure = JSON.stringify({ key: "newValue" }) + const result = getChartData(newFigure) + + expect(result).toEqual({ data: { key: "newValue" }, hasChanged: true }) + }) +}) + +// Unit tests for setChartThemeGenerator +describe("setChartThemeGenerator", () => { + let setChartTheme: ( + newTheme: string, + newAppTheme: MinimalStreamlitTheme + ) => boolean + + beforeEach(() => { + setChartTheme = setChartThemeGenerator() + }) + + test("should apply the theme when theme changes", () => { + const newTheme = "dark" + const newAppTheme: MinimalStreamlitTheme = { + textColor: "white", + backgroundColor: "black", + secondaryBackgroundColor: "gray", + font: "Source Pro", + } + const result = setChartTheme(newTheme, newAppTheme) + const { use_theme: useTheme } = + global.window.Bokeh.require("core/properties") + + expect(result).toBe(true) + expect(useTheme).toHaveBeenCalled() + }) + + test("should not reapply the theme if it's the same", () => { + const newTheme = "dark" + const newAppTheme: MinimalStreamlitTheme = { + textColor: "white", + backgroundColor: "black", + secondaryBackgroundColor: "gray", + font: "Source Pro", + } + setChartTheme(newTheme, newAppTheme) + const result = setChartTheme(newTheme, newAppTheme) + + expect(result).toBe(false) + }) + + test("should apply Streamlit theme when appropriate", () => { + const newTheme = "streamlit" + const newAppTheme: MinimalStreamlitTheme = { + textColor: "white", + backgroundColor: "black", + secondaryBackgroundColor: "gray", + font: "Source Pro", + } + const result = setChartTheme(newTheme, newAppTheme) + + expect(result).toBe(true) + }) +}) + +describe("getChartDimensions", () => { + test("should return default dimensions when no width/height attributes are provided", () => { + const plot = { attributes: {} } + const result = getChartDimensions(plot, false, document.documentElement) + expect(result).toEqual({ width: 400, height: 350 }) + }) + + test("should return provided dimensions when width/height attributes are set", () => { + const plot = { attributes: { width: 800, height: 400 } } + const result = getChartDimensions(plot, false, document.documentElement) + expect(result).toEqual({ width: 800, height: 400 }) + }) + + test("should calculate new dimensions based on container width", () => { + Object.defineProperty(document.documentElement, "clientWidth", { + configurable: true, + writable: true, + value: 1200, // Set the desired value + }) + + const plot = { attributes: { width: 800, height: 400 } } + const result = getChartDimensions(plot, true, document.documentElement) + expect(result.width).toBe(1200) + expect(result.height).toBeCloseTo(600) + }) +}) diff --git a/streamlit_bokeh/frontend/src/v2/index.ts b/streamlit_bokeh/frontend/src/v2/index.ts index 7fd6d9f..d991af2 100644 --- a/streamlit_bokeh/frontend/src/v2/index.ts +++ b/streamlit_bokeh/frontend/src/v2/index.ts @@ -14,6 +14,261 @@ * limitations under the License. */ -export default function () { - // This is a placeholder. +import { MinimalStreamlitTheme, streamlitTheme } from "./streamlit-theme" + +import { + ComponentArgs, + StreamlitThemeCssProperties, +} from "@streamlit/component-v2-lib" +import { loadBokehGlobally } from "./loaders" + +declare global { + interface Window { + Bokeh: any + } +} + +interface Dimensions { + width: number + height: number +} + +// These values come from Bokeh's default values +// See https://github.com/bokeh/bokeh/blob/3.8.0/bokehjs/src/lib/models/plots/plot.ts#L217 +const DEFAULT_WIDTH = 400 // px +const DEFAULT_HEIGHT = 350 // px + +/** + * This function is a memoized function that returns the chart data + * if the figure is the same as the last time it was called. + */ +export const getChartDataGenerator = () => { + let savedFigure: string | null = null + let savedChartData: object | null = null + + return (figure: string) => { + if (figure !== savedFigure) { + savedFigure = figure + savedChartData = JSON.parse(figure) + + return { data: savedChartData, hasChanged: true } + } + + return { data: savedChartData, hasChanged: false } + } +} + +export const setChartThemeGenerator = () => { + let currentTheme: string | null = null + let appTheme: string | null = null + + return (newTheme: string, newAppTheme: MinimalStreamlitTheme) => { + let themeChanged = false + const renderedAppTheme = JSON.stringify(newAppTheme) + + // The theme of the app changes if the theme provided by the component + // has changed or, we are using the streamlit theme and the theme of the + // app has changed (light mode to dark mode to custom theme) + if ( + newTheme !== currentTheme || + (currentTheme === "streamlit" && appTheme !== renderedAppTheme) + ) { + currentTheme = newTheme + appTheme = renderedAppTheme + + const { use_theme } = window.Bokeh.require("core/properties") + + if ( + currentTheme === "streamlit" || + !(currentTheme in window.Bokeh.Themes) + ) { + use_theme(streamlitTheme(newAppTheme)) + themeChanged = true + } else { + use_theme(window.Bokeh.Themes[currentTheme]) + themeChanged = true + } + } + + return themeChanged + } +} + +export function getChartDimensions( + plot: any, + useContainerWidth: boolean, + parentElement: HTMLElement +): Dimensions { + const originalWidth: number = plot.attributes.width ?? DEFAULT_WIDTH + const originalHeight: number = plot.attributes.height ?? DEFAULT_HEIGHT + + let width: number = originalWidth + let height: number = originalHeight + + if (useContainerWidth) { + // Use the width without a scrollbar to ensure the width always + // looks good. + width = parentElement.clientWidth + height = (width / originalWidth) * originalHeight + } + + return { width, height } +} + +function removeAllChildNodes(element: Node): void { + while (element.lastChild) { + element.lastChild.remove() + } +} + +async function updateChart( + data: any, + useContainerWidth: boolean = false, + chart: HTMLDivElement, + parentElement: HTMLElement, + key: string +) { + /** + * When you create a bokeh chart in your python script, you can specify + * the width: p = figure(title="simple line example", x_axis_label="x", y_axis_label="y", plot_width=200); + * In that case, the json object will contain an attribute called + * plot_width (or plot_height) inside the plot reference. + * If that values are missing, we can set that values to make the chart responsive. + * + * Note that the figure is the first element in roots array. + */ + const plot = data?.doc?.roots?.[0] + + if (plot) { + const { width, height } = getChartDimensions( + plot, + useContainerWidth, + parentElement + ) + + if (width > 0) { + plot.attributes.width = width + } + if (height > 0) { + plot.attributes.height = height + } + } + + removeAllChildNodes(chart) + await window.Bokeh.embed.embed_item(data, key) +} + +interface ComponentData { + figure: string + use_container_width: boolean + bokeh_theme: string + key: string +} + +const getOrCreateChart = ( + container: HTMLDivElement, + key: string +): HTMLDivElement => { + const chart = container.querySelector(`#${key}`) + + if (!chart) { + const newChart = document.createElement("div") + newChart.id = key + container.appendChild(newChart) + return newChart + } + + return chart +} + +/** + * Component-scoped state keyed by the host element to support multiple + * instances. + */ +type ComponentState = { + initialized: boolean + setChartTheme: ReturnType + getChartData: ReturnType +} + +const componentState = new WeakMap() + +const getOrCreateInstanceState = ( + host: HTMLElement | ShadowRoot +): ComponentState => { + let state = componentState.get(host) + + if (!state) { + state = { + initialized: false, + setChartTheme: setChartThemeGenerator(), + getChartData: getChartDataGenerator(), + } + componentState.set(host, state) + } + + return state +} + +const getCssPropertyValue = ( + property: keyof StreamlitThemeCssProperties, + container: HTMLElement +) => { + const style = getComputedStyle(container) + return style.getPropertyValue(property)?.trim() } + +const bokehComponent = async (component: ComponentArgs<{}, ComponentData>) => { + const { parentElement, data, key } = component + const { + figure, + bokeh_theme: bokehTheme, + use_container_width: useContainerWidth, + } = data + + const state = getOrCreateInstanceState(parentElement) + + if (!state.initialized) { + await loadBokehGlobally() + state.initialized = true + } + + // Component-specific theme getter and setter to avoid state leakage between + // instances. + const { setChartTheme, getChartData } = state + + const container = + parentElement.querySelector(".stBokehContainer") + + if (!container) { + throw new Error("Container not found") + } + + const chart = getOrCreateChart(container, key) + + const { data: chartData, hasChanged } = getChartData(figure) + const themeChanged = setChartTheme(bokehTheme, { + backgroundColor: getCssPropertyValue("--st-background-color", container), + secondaryBackgroundColor: getCssPropertyValue( + "--st-secondary-background-color", + container + ), + textColor: getCssPropertyValue("--st-text-color", container), + font: getCssPropertyValue("--st-font", container), + }) + + // NOTE: Each script run forces Bokeh to provide different ids for their + // elements. For that reason, this will always update the chart. + // The only exception would be if the same info is sent down from the frontend + // only. It shouldn't happen, but it's a safeguard. + if (hasChanged || themeChanged) { + await updateChart(chartData, useContainerWidth, chart, container, key) + } + + return () => { + // Cleanup the instance state + componentState.delete(parentElement) + } +} + +export default bokehComponent diff --git a/streamlit_bokeh/frontend/src/v2/loaders.ts b/streamlit_bokeh/frontend/src/v2/loaders.ts new file mode 100644 index 0000000..d8cab2d --- /dev/null +++ b/streamlit_bokeh/frontend/src/v2/loaders.ts @@ -0,0 +1,194 @@ +/** + * Copyright (c) Snowflake Inc. (2025) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const BOKEH_PUBLIC = "../bokeh/" + +/** + * Resolves an asset reference (relative path or absolute URL) to an absolute + * URL string. + * + * Why: + * - When bundling with Vite and using `?url&no-inline`, imports produce URLs + * that are relative to this module. `new URL(rel, import.meta.url).href` + * turns these into absolute URLs that work reliably in Shadow DOM, iframes, + * and different base paths. + * - If callers provide an already absolute URL (e.g., CDN override), we keep it + * as-is. + * + * @param relativeOrUrl - A relative asset path emitted by the bundler or an + * absolute URL. + * @returns Absolute URL string to the asset that can be used in DOM elements. + */ +function resolveAssetUrl(relativeOrUrl: string): string { + try { + return new URL(relativeOrUrl, import.meta.url).href + } catch (e) { + return relativeOrUrl + } +} + +// Global, module-scoped caches to ensure scripts are only appended once +const scriptLoadPromises = new Map>() +let bokehLoadPromise: Promise | null = null + +/** + * Finds an existing