From 71cadecdc2db58774cb55a4bb0ff45a16d4df3aa Mon Sep 17 00:00:00 2001 From: Ivan Reshetnikov Date: Sun, 3 Jul 2022 23:05:51 +0300 Subject: [PATCH 1/6] Use `streamlit-component-lib`. Fix padding --- streamlit_observable/frontend/package.json | 17 +- .../frontend/public/index.html | 4 +- .../frontend/src/Observable.tsx | 14 +- .../frontend/src/streamlit/ArrowTable.ts | 224 ------------------ .../frontend/src/streamlit/StreamlitReact.tsx | 150 ------------ .../frontend/src/streamlit/index.tsx | 30 --- .../frontend/src/streamlit/streamlit.ts | 198 ---------------- 7 files changed, 15 insertions(+), 622 deletions(-) delete mode 100644 streamlit_observable/frontend/src/streamlit/ArrowTable.ts delete mode 100644 streamlit_observable/frontend/src/streamlit/StreamlitReact.tsx delete mode 100644 streamlit_observable/frontend/src/streamlit/index.tsx delete mode 100644 streamlit_observable/frontend/src/streamlit/streamlit.ts diff --git a/streamlit_observable/frontend/package.json b/streamlit_observable/frontend/package.json index fb628e6..c24ba3c 100644 --- a/streamlit_observable/frontend/package.json +++ b/streamlit_observable/frontend/package.json @@ -4,23 +4,16 @@ "private": true, "dependencies": { "@observablehq/runtime": "^4.7.2", - "@testing-library/jest-dom": "^4.2.4", - "@testing-library/react": "^9.3.2", - "@testing-library/user-event": "^7.1.2", - "@types/hoist-non-react-statics": "^3.3.1", - "@types/jest": "^24.0.0", - "@types/node": "^12.0.0", - "@types/react": "^16.9.0", - "@types/react-dom": "^16.9.0", - "apache-arrow": "^0.17.0", - "bootstrap": "^4.4.1", - "event-target-shim": "^5.0.1", - "hoist-non-react-statics": "^3.3.2", "react": "^16.13.1", "react-dom": "^16.13.1", "react-scripts": "3.4.1", + "streamlit-component-lib": "^1.4.0", "typescript": "~3.7.2" }, + "devDependencies": { + "@types/react": "^16.9.0", + "@types/react-dom": "^16.9.0" + }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", diff --git a/streamlit_observable/frontend/public/index.html b/streamlit_observable/frontend/public/index.html index f0abf81..a458376 100644 --- a/streamlit_observable/frontend/public/index.html +++ b/streamlit_observable/frontend/public/index.html @@ -11,7 +11,7 @@ href="https://cdn.jsdelivr.net/npm/@observablehq/inspector@3/dist/inspector.css" /> - +
- \ No newline at end of file + diff --git a/streamlit_observable/frontend/src/Observable.tsx b/streamlit_observable/frontend/src/Observable.tsx index ea5b3a6..07548c6 100644 --- a/streamlit_observable/frontend/src/Observable.tsx +++ b/streamlit_observable/frontend/src/Observable.tsx @@ -3,7 +3,7 @@ import { withStreamlitConnection, StreamlitComponentBase, Streamlit, -} from "./streamlit" +} from "streamlit-component-lib" import { Runtime, Inspector } from "@observablehq/runtime"; class Observable extends StreamlitComponentBase<{}> { @@ -21,7 +21,9 @@ class Observable extends StreamlitComponentBase<{}> { if (prevArgs.notebook !== this.props.args.notebook) { // TODO handle new notebook } - this.redefineCells(this.main, this.props.args.redefine); + if (this.main) { + this.redefineCells(this.main, this.props.args.redefine); + } } async embedNotebook(notebook: string, targets: string[], observe: string[], hide:string[]) { @@ -32,7 +34,7 @@ class Observable extends StreamlitComponentBase<{}> { const observeSet = new Set(observe); const hideSet = new Set(hide); this.runtime = new Runtime(); - const { default: define } = await eval(`import("https://api.observablehq.com/${notebook}.js?v=3")`); + const { default: define } = await eval(`import("https://api.observablehq.com/${notebook}.js?v=3")`); // eslint-disable-line no-eval this.main = this.runtime.module(define, (name: string) => { if (observeSet.has(name) && !targetSet.has(name)) { const observeValue = this.observeValue; @@ -51,7 +53,7 @@ class Observable extends StreamlitComponentBase<{}> { this.notebookRef.current?.appendChild(el); const i = new Inspector(el); - el.addEventListener('input', e => { + el.addEventListener('input', () => { Streamlit.setFrameHeight(); }) return { @@ -74,7 +76,7 @@ class Observable extends StreamlitComponentBase<{}> { for (const [name, value] of initial) { // @ts-ignore this.observeValue[name] = value - }; + } Streamlit.setComponentValue(this.observeValue); }) } @@ -113,7 +115,7 @@ class Observable extends StreamlitComponentBase<{}> { }}>
{this.props.args.name}
- {this.props.args.notebook} + {this.props.args.notebook}
diff --git a/streamlit_observable/frontend/src/streamlit/ArrowTable.ts b/streamlit_observable/frontend/src/streamlit/ArrowTable.ts deleted file mode 100644 index 9d04287..0000000 --- a/streamlit_observable/frontend/src/streamlit/ArrowTable.ts +++ /dev/null @@ -1,224 +0,0 @@ -/** - * @license - * Copyright 2018-2019 Streamlit Inc. - * - * 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 { Table, Type } from "apache-arrow" - -type CellType = "blank" | "index" | "columns" | "data" - -export interface ArrowDataframeProto { - data: ArrowTableProto - height: string - width: string -} - -export interface ArrowTableProto { - data: Uint8Array - index: Uint8Array - columns: Uint8Array - styler: Styler -} - -interface Cell { - classNames: string - content: string - id?: string - type: CellType -} - -interface Styler { - caption?: string - displayValuesTable: Table - styles?: string - uuid: string -} - -export class ArrowTable { - private readonly dataTable: Table - private readonly indexTable: Table - private readonly columnsTable: Table - private readonly styler?: Styler - - constructor( - dataBuffer: Uint8Array, - indexBuffer: Uint8Array, - columnsBuffer: Uint8Array, - styler?: any - ) { - this.dataTable = Table.from(dataBuffer) - this.indexTable = Table.from(indexBuffer) - this.columnsTable = Table.from(columnsBuffer) - this.styler = styler - ? { - caption: styler.get("caption"), - displayValuesTable: Table.from(styler.get("displayValues")), - styles: styler.get("styles"), - uuid: styler.get("uuid"), - } - : undefined - } - - get rows(): number { - return this.indexTable.length + this.columnsTable.numCols - } - - get columns(): number { - return this.indexTable.numCols + this.columnsTable.length - } - - get headerRows(): number { - return this.rows - this.dataRows - } - - get headerColumns(): number { - return this.columns - this.dataColumns - } - - get dataRows(): number { - return this.dataTable.length - } - - get dataColumns(): number { - return this.dataTable.numCols - } - - get uuid(): string | undefined { - return this.styler && this.styler.uuid - } - - get caption(): string | undefined { - return this.styler && this.styler.caption - } - - get styles(): string | undefined { - return this.styler && this.styler.styles - } - - get table(): Table { - return this.dataTable - } - - get index(): Table { - return this.indexTable - } - - get columnTable(): Table { - return this.columnsTable - } - - public getCell = (rowIndex: number, columnIndex: number): Cell => { - const isBlankCell = - rowIndex < this.headerRows && columnIndex < this.headerColumns - const isIndexCell = - rowIndex >= this.headerRows && columnIndex < this.headerColumns - const isColumnsCell = - rowIndex < this.headerRows && columnIndex >= this.headerColumns - - if (isBlankCell) { - const classNames = ["blank"] - if (columnIndex > 0) { - classNames.push("level" + rowIndex) - } - - return { - type: "blank", - classNames: classNames.join(" "), - content: "", - } - } else if (isColumnsCell) { - const dataColumnIndex = columnIndex - this.headerColumns - const classNames = [ - "col_heading", - "level" + rowIndex, - "col" + dataColumnIndex, - ] - - return { - type: "columns", - classNames: classNames.join(" "), - content: this.getContent(this.columnsTable, dataColumnIndex, rowIndex), - } - } else if (isIndexCell) { - const dataRowIndex = rowIndex - this.headerRows - const classNames = [ - "row_heading", - "level" + columnIndex, - "row" + dataRowIndex, - ] - - return { - type: "index", - id: `T_${this.uuid}level${columnIndex}_row${dataRowIndex}`, - classNames: classNames.join(" "), - content: this.getContent(this.indexTable, dataRowIndex, columnIndex), - } - } else { - const dataRowIndex = rowIndex - this.headerRows - const dataColumnIndex = columnIndex - this.headerColumns - const classNames = [ - "data", - "row" + dataRowIndex, - "col" + dataColumnIndex, - ] - const content = this.styler - ? this.getContent( - this.styler.displayValuesTable, - dataRowIndex, - dataColumnIndex - ) - : this.getContent(this.dataTable, dataRowIndex, dataColumnIndex) - - return { - type: "data", - id: `T_${this.uuid}row${dataRowIndex}_col${dataColumnIndex}`, - classNames: classNames.join(" "), - content, - } - } - } - - public getContent = ( - table: Table, - rowIndex: number, - columnIndex: number - ): any => { - const column = table.getColumnAt(columnIndex) - if (column === null) { - return "" - } - - const columnTypeId = this.getColumnTypeId(table, columnIndex) - switch (columnTypeId) { - case Type.Timestamp: { - return this.nanosToDate(column.get(rowIndex)) - } - default: { - return column.get(rowIndex) - } - } - } - - /** - * Returns apache-arrow specific typeId of column. - */ - private getColumnTypeId(table: Table, columnIndex: number): Type { - return table.schema.fields[columnIndex].type.typeId - } - - private nanosToDate(nanos: number): Date { - return new Date(nanos / 1e6) - } -} diff --git a/streamlit_observable/frontend/src/streamlit/StreamlitReact.tsx b/streamlit_observable/frontend/src/streamlit/StreamlitReact.tsx deleted file mode 100644 index c3dc26a..0000000 --- a/streamlit_observable/frontend/src/streamlit/StreamlitReact.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import hoistNonReactStatics from "hoist-non-react-statics" -import React, { ReactNode } from "react" -import { RenderData, Streamlit } from "./streamlit" - -/** - * Props passed to custom Streamlit components. - */ -export interface ComponentProps { - /** Named dictionary of arguments passed from Python. */ - args: any - - /** The component's width. */ - width: number - - /** - * True if the component should be disabled. - * All components get disabled while the app is being re-run, - * and become re-enabled when the re-run has finished. - */ - disabled: boolean -} - -/** - * Optional Streamlit React-based component base class. - * - * You are not required to extend this base class to create a Streamlit - * component. If you decide not to extend it, you should implement the - * `componentDidMount` and `componentDidUpdate` functions in your own class, - * so that your plugin properly resizes. - */ -export class StreamlitComponentBase extends React.PureComponent< - ComponentProps, - S - > { - public componentDidMount(): void { - // After we're rendered for the first time, tell Streamlit that our height - // has changed. - Streamlit.setFrameHeight() - } - - public componentDidUpdate(): void { - // After we're updated, tell Streamlit that our height may have changed. - Streamlit.setFrameHeight() - } -} - -/** - * Wrapper for React-based Streamlit components. - * - * Bootstraps the communication interface between Streamlit and the component. - */ -export function withStreamlitConnection( - WrappedComponent: React.ComponentType -): React.ComponentType { - interface WrapperProps { } - - interface WrapperState { - renderData?: RenderData - componentError?: Error - } - - class ComponentWrapper extends React.PureComponent< - WrapperProps, - WrapperState - > { - public constructor(props: WrapperProps) { - super(props) - this.state = { - renderData: undefined, - componentError: undefined, - } - } - - /** - * Error boundary function. This will be called if our wrapped - * component throws an error. We store the caught error in our state, - * and display it in the next render(). - */ - public static getDerivedStateFromError = ( - error: Error - ): Partial => { - return { componentError: error } - } - - public componentDidMount = (): void => { - // Set up event listeners, and signal to Streamlit that we're ready. - // We won't render the component until we receive the first RENDER_EVENT. - Streamlit.events.addEventListener( - Streamlit.RENDER_EVENT, - this.onRenderEvent - ) - Streamlit.setComponentReady() - } - - public componentDidUpdate = (prevProps: any): void => { - // If our child threw an error, we display it in render(). In this - // case, the child won't be mounted and therefore won't call - // `setFrameHeight` on its own. We do it here so that the rendered - // error will be visible. - if (this.state.componentError != null) { - Streamlit.setFrameHeight() - } - } - - public componentWillUnmount = (): void => { - Streamlit.events.removeEventListener( - Streamlit.RENDER_EVENT, - this.onRenderEvent - ) - } - - /** - * Streamlit is telling this component to redraw. - * We save the render data in State, so that it can be passed to the - * component in our own render() function. - */ - private onRenderEvent = (event: Event): void => { - // Update our state with the newest render data - const renderEvent = event as CustomEvent - this.setState({ renderData: renderEvent.detail }) - } - - public render = (): ReactNode => { - // If our wrapped component threw an error, display it. - if (this.state.componentError != null) { - return ( -
-

Component Error

- {this.state.componentError.message} -
- ) - } - - // Don't render until we've gotten our first RENDER_EVENT from Streamlit. - if (this.state.renderData == null) { - return null - } - - return ( - - ) - } - } - - return hoistNonReactStatics(ComponentWrapper, WrappedComponent) -} diff --git a/streamlit_observable/frontend/src/streamlit/index.tsx b/streamlit_observable/frontend/src/streamlit/index.tsx deleted file mode 100644 index 290e4a0..0000000 --- a/streamlit_observable/frontend/src/streamlit/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @license - * Copyright 2018-2020 Streamlit Inc. - * - * 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. - */ - -// Workaround for type-only exports: -// https://stackoverflow.com/questions/53728230/cannot-re-export-a-type-when-using-the-isolatedmodules-with-ts-3-2-2 -import { ComponentProps as ComponentProps_ } from "./StreamlitReact" -import { RenderData as RenderData_ } from "./streamlit" - -export { - StreamlitComponentBase, - withStreamlitConnection, -} from "./StreamlitReact" -export { ArrowTable } from "./ArrowTable" -export { Streamlit } from "./streamlit" -export type ComponentProps = ComponentProps_ -export type RenderData = RenderData_ diff --git a/streamlit_observable/frontend/src/streamlit/streamlit.ts b/streamlit_observable/frontend/src/streamlit/streamlit.ts deleted file mode 100644 index 7e77b4d..0000000 --- a/streamlit_observable/frontend/src/streamlit/streamlit.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * @license - * Copyright 2018-2020 Streamlit Inc. - * - * 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. - */ - -// Safari doesn't support the EventTarget class, so we use a shim. -import { EventTarget } from "event-target-shim" -import { ArrowDataframeProto, ArrowTable } from "./ArrowTable" - -/** Data sent in the custom Streamlit render event. */ -export interface RenderData { - args: any - disabled: boolean -} - -/** Messages from Component -> Streamlit */ -enum ComponentMessageType { - // A component sends this message when it's ready to receive messages - // from Streamlit. Streamlit won't send any messages until it gets this. - // Data: { apiVersion: number } - COMPONENT_READY = "streamlit:componentReady", - - // The component has a new widget value. Send it back to Streamlit, which - // will then re-run the app. - // Data: { value: any } - SET_COMPONENT_VALUE = "streamlit:setComponentValue", - - // The component has a new height for its iframe. - // Data: { height: number } - SET_FRAME_HEIGHT = "streamlit:setFrameHeight", -} - -/** - * Streamlit communication API. - * - * Components can send data to Streamlit via the functions defined here, - * and receive data from Streamlit via the `events` property. - */ -export class Streamlit { - /** - * The Streamlit component API version we're targetting. - * There's currently only 1! - */ - public static readonly API_VERSION = 1 - - public static readonly RENDER_EVENT = "streamlit:render" - - /** Dispatches events received from Streamlit. */ - public static readonly events = new EventTarget() - - private static registeredMessageListener = false - private static lastFrameHeight?: number - - /** - * Tell Streamlit that the component is ready to start receiving data. - * Streamlit will defer emitting RENDER events until it receives the - * COMPONENT_READY message. - */ - public static setComponentReady = (): void => { - if (!Streamlit.registeredMessageListener) { - // Register for message events if we haven't already - window.addEventListener("message", Streamlit.onMessageEvent) - Streamlit.registeredMessageListener = true - } - - Streamlit.sendBackMsg(ComponentMessageType.COMPONENT_READY, { - apiVersion: Streamlit.API_VERSION, - }) - } - - /** - * Report the component's height to Streamlit. - * This should be called every time the component changes its DOM - that is, - * when it's first loaded, and any time it updates. - */ - public static setFrameHeight = (height?: number): void => { - if (height === undefined) { - // `height` is optional. If undefined, it defaults to scrollHeight, - // which is the entire height of the element minus its border, - // scrollbar, and margin. - height = document.body.scrollHeight + 10; - } - - if (height === Streamlit.lastFrameHeight) { - // Don't bother updating if our height hasn't changed. - return - } - - Streamlit.lastFrameHeight = height - Streamlit.sendBackMsg(ComponentMessageType.SET_FRAME_HEIGHT, { height }) - } - - /** - * Set the component's value. This value will be returned to the Python - * script, and the script will be re-run. - * - * For example: - * - * JavaScript: - * Streamlit.setComponentValue("ahoy!") - * - * Python: - * value = st.my_component(...) - * st.write(value) # -> "ahoy!" - * - * The value must be serializable into JSON. - */ - public static setComponentValue = (value: any): void => { - Streamlit.sendBackMsg(ComponentMessageType.SET_COMPONENT_VALUE, { value }) - } - - /** Receive a ForwardMsg from the Streamlit app */ - private static onMessageEvent = (event: MessageEvent): void => { - const type = event.data["type"] - switch (type) { - case Streamlit.RENDER_EVENT: - Streamlit.onRenderMessage(event.data) - break - } - } - - /** - * Handle an untyped Streamlit render event and redispatch it as a - * StreamlitRenderEvent. - */ - private static onRenderMessage = (data: any): void => { - let args = data["args"] - if (args == null) { - console.error( - `Got null args in onRenderMessage. This should never happen` - ) - args = {} - } - - // Parse our dataframe arguments with arrow, and merge them into our args dict - const dataframeArgs = - data["dfs"] && data["dfs"].length > 0 - ? Streamlit.argsDataframeToObject(data["dfs"]) - : {} - - args = { - ...args, - ...dataframeArgs, - } - - const disabled = Boolean(data["disabled"]) - - // Dispatch a render event! - const eventData = { disabled, args } - const event = new CustomEvent(Streamlit.RENDER_EVENT, { - detail: eventData, - }) - Streamlit.events.dispatchEvent(event) - } - - private static argsDataframeToObject = ( - argsDataframe: ArgsDataframe[] - ): object => { - const argsDataframeArrow = argsDataframe.map( - ({ key, value }: ArgsDataframe) => [key, Streamlit.toArrowTable(value)] - ) - return Object.fromEntries(argsDataframeArrow) - } - - private static toArrowTable = (df: ArrowDataframeProto): ArrowTable => { - const { data, index, columns } = df.data - return new ArrowTable(data, index, columns) - } - - /** Post a message to the Streamlit app. */ - private static sendBackMsg = (type: string, data?: any): void => { - window.parent.postMessage( - { - isStreamlitMessage: true, - type: type, - ...data, - }, - "*" - ) - } -} - -interface ArgsDataframe { - key: string - value: ArrowDataframeProto -} From 7c0a717cd84768587adb4e479bc74dd32c31d25e Mon Sep 17 00:00:00 2001 From: Ivan Reshetnikov Date: Sun, 3 Jul 2022 23:10:04 +0300 Subject: [PATCH 2/6] Don't send empty value to streamlit in `componentDidMount` --- streamlit_observable/frontend/src/Observable.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/streamlit_observable/frontend/src/Observable.tsx b/streamlit_observable/frontend/src/Observable.tsx index 07548c6..4ed36fc 100644 --- a/streamlit_observable/frontend/src/Observable.tsx +++ b/streamlit_observable/frontend/src/Observable.tsx @@ -90,7 +90,6 @@ class Observable extends StreamlitComponentBase<{}> { } componentDidMount() { const { notebook, targets = [], observe = [], redefine = {} , hide=[]} = this.props.args; - Streamlit.setComponentValue(this.observeValue); this.embedNotebook(notebook, targets, observe, hide).then(() => { this.redefineCells(this.main, redefine); }); From 6a3054d5e1766448abdad549e4938fde95d72c07 Mon Sep 17 00:00:00 2001 From: Ivan Reshetnikov Date: Sun, 3 Jul 2022 23:43:11 +0300 Subject: [PATCH 3/6] Add `debounce` option --- README.md | 3 ++- streamlit_observable/__init__.py | 8 ++++-- .../frontend/src/Observable.tsx | 25 ++++++++++++++----- .../frontend/src/debounceExecution.ts | 13 ++++++++++ 4 files changed, 40 insertions(+), 9 deletions(-) create mode 100644 streamlit_observable/frontend/src/debounceExecution.ts diff --git a/README.md b/README.md index e2c7bd9..e71d919 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ Embed an Observable notebook into the Streamlit app. If any cells are passed int - `observe`: An optional list of cell names to observe. When those cells are updated in the Observable notebook, the new values will be sent back to Streamlit as part of the return value. Keep in mind, there is a serialization process from going from Observable notebook JavaScript -> Streamlit Python (JSON serializing). - `redefine`: An optional dict of cell names and values used to redefine in the embeded notebook. Keep in mind, there is a serialization process from going from Streamlit Python -> Observable notebook JavaScript (JSON serializing). - `hide`: An optional list of cell names that will not be rendered in the DOM of the embed. Useful for side-effect logic cells, like `mouse` in https://observablehq.com/@mbostock/eyes. +- `debounce`: An optional delay in milliseconds. Observed values don't change until a delay after the last update is less than the option value. ## Caveats @@ -138,4 +139,4 @@ I haven't tried this, but I expect that if you try loading 1GB+ of data into a b ### You'll need to fork a lot -Most Observable notebooks are built with only other Observable users in mind. Meaning, a lot of cells are exposed as custom Objects, Dates, functions, or classes, all of which you can't control very well in Python land. So, you may need to fork the notebook you want in Observable, make changes to make it a little friendlier, then publish/enable link-sharing to access in Streamlit. Thankfully, this is pretty quick to do on Observable once you get the hang of it, but it does take extra time. \ No newline at end of file +Most Observable notebooks are built with only other Observable users in mind. Meaning, a lot of cells are exposed as custom Objects, Dates, functions, or classes, all of which you can't control very well in Python land. So, you may need to fork the notebook you want in Observable, make changes to make it a little friendlier, then publish/enable link-sharing to access in Streamlit. Thankfully, this is pretty quick to do on Observable once you get the hang of it, but it does take extra time. diff --git a/streamlit_observable/__init__.py b/streamlit_observable/__init__.py index b012050..bb1963f 100644 --- a/streamlit_observable/__init__.py +++ b/streamlit_observable/__init__.py @@ -14,7 +14,7 @@ _component_func = components.declare_component("observable", path=build_dir) -def observable(key, notebook, targets=None, redefine={}, observe=[], hide=[]): +def observable(key, notebook, targets=None, redefine={}, observe=[], hide=[], debounce=None): """Create a new instance of "observable". Parameters @@ -37,8 +37,12 @@ def observable(key, notebook, targets=None, redefine={}, observe=[], hide=[]): redefine, the values are what they will be redefined as. Keep in mind, there is a serialization process from Streamlit Python -> frontend JavaScript. hide: list or None - An option list of strings that are the names of cells that will be embeded, + An optional list of strings that are the names of cells that will be embeded, but won't be rendered to the DOM. + debounce: float or None + An optional delay in milliseconds. + Observed values don't change + until a delay after the last update is less than the option value. Returns ------- dict diff --git a/streamlit_observable/frontend/src/Observable.tsx b/streamlit_observable/frontend/src/Observable.tsx index 4ed36fc..d94201f 100644 --- a/streamlit_observable/frontend/src/Observable.tsx +++ b/streamlit_observable/frontend/src/Observable.tsx @@ -5,12 +5,14 @@ import { Streamlit, } from "streamlit-component-lib" import { Runtime, Inspector } from "@observablehq/runtime"; +import debounceExecution from "./debounceExecution" class Observable extends StreamlitComponentBase<{}> { public observeValue = {}; private notebookRef = React.createRef(); private runtime: any = null; private main: any = null; + private debounceUpdate: any = null; componentWillUnmount() { this.runtime?.dispose(); @@ -21,15 +23,19 @@ class Observable extends StreamlitComponentBase<{}> { if (prevArgs.notebook !== this.props.args.notebook) { // TODO handle new notebook } + if (prevArgs.debounce !== this.props.args.debounce) { + this.setupDebounceUpdate(this.props.args.debounce) + } if (this.main) { this.redefineCells(this.main, this.props.args.redefine); } } - async embedNotebook(notebook: string, targets: string[], observe: string[], hide:string[]) { + async embedNotebook(notebook: string, targets: string[], observe: string[], hide: string[], debounce: number) { if (this.runtime) { this.runtime.dispose(); } + this.setupDebounceUpdate(debounce) const targetSet = new Set(targets); const observeSet = new Set(observe); const hideSet = new Set(hide); @@ -42,8 +48,7 @@ class Observable extends StreamlitComponentBase<{}> { fulfilled: (value: any) => { //@ts-ignore observeValue[name] = value; - //@ts-ignore - Streamlit.setComponentValue(observeValue); + this.debounceUpdate() } } } @@ -77,11 +82,19 @@ class Observable extends StreamlitComponentBase<{}> { // @ts-ignore this.observeValue[name] = value } - Streamlit.setComponentValue(this.observeValue); + this.debounceUpdate() }) } } + private setupDebounceUpdate(debounce: any) { + if (debounce) { + this.debounceUpdate = debounceExecution(() => Streamlit.setComponentValue(this.observeValue), debounce) + } else { + this.debounceUpdate = () => Streamlit.setComponentValue(this.observeValue) + } + } + redefineCells(main: any, redefine = {}) { for (let cell in redefine) { //@ts-ignore @@ -89,8 +102,8 @@ class Observable extends StreamlitComponentBase<{}> { } } componentDidMount() { - const { notebook, targets = [], observe = [], redefine = {} , hide=[]} = this.props.args; - this.embedNotebook(notebook, targets, observe, hide).then(() => { + const { notebook, targets = [], observe = [], redefine = {} , hide = [], debounce = 0 } = this.props.args; + this.embedNotebook(notebook, targets, observe, hide, debounce).then(() => { this.redefineCells(this.main, redefine); }); diff --git a/streamlit_observable/frontend/src/debounceExecution.ts b/streamlit_observable/frontend/src/debounceExecution.ts new file mode 100644 index 0000000..82179ec --- /dev/null +++ b/streamlit_observable/frontend/src/debounceExecution.ts @@ -0,0 +1,13 @@ +export default function debounceExecution(func: any, wait: any) { + let timeout: any + + return function executedFunction(...args: any[]) { + const later = () => { + clearTimeout(timeout) + func(...args) + } + + clearTimeout(timeout) + timeout = setTimeout(later, wait) + } +} From e2d38d2e6276d7ac84c362d9fde373826a545e47 Mon Sep 17 00:00:00 2001 From: Ivan Reshetnikov Date: Mon, 4 Jul 2022 10:39:18 +0300 Subject: [PATCH 4/6] Update package version from 0.0.8 to 0.1.0 --- setup.py | 2 +- streamlit_observable/__init__.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index bbff5a7..6ec25ba 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ def get_long_description(): setuptools.setup( name="streamlit-observable", - version="0.0.8", + version="0.1.0", author="Alex Garcia", author_email="alexsebastian.garcia@gmail.com", description="A Streamlit component for embedding Observable notebooks in Streamlit Apps", diff --git a/streamlit_observable/__init__.py b/streamlit_observable/__init__.py index bb1963f..2874ec7 100644 --- a/streamlit_observable/__init__.py +++ b/streamlit_observable/__init__.py @@ -58,7 +58,8 @@ def observable(key, notebook, targets=None, redefine={}, observe=[], hide=[], de redefine=redefine, hide=hide, key=key, - name=key + name=key, + debounce=debounce ) if component_value is None: From 1395f9ff55d99670c6a533601c0ebcd141f830e6 Mon Sep 17 00:00:00 2001 From: Ivan Reshetnikov Date: Mon, 4 Jul 2022 13:00:10 +0300 Subject: [PATCH 5/6] Use `ResizeObserver` to adjust component height --- .../frontend/src/Observable.tsx | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/streamlit_observable/frontend/src/Observable.tsx b/streamlit_observable/frontend/src/Observable.tsx index d94201f..965cdeb 100644 --- a/streamlit_observable/frontend/src/Observable.tsx +++ b/streamlit_observable/frontend/src/Observable.tsx @@ -58,23 +58,37 @@ class Observable extends StreamlitComponentBase<{}> { this.notebookRef.current?.appendChild(el); const i = new Inspector(el); - el.addEventListener('input', () => { - Streamlit.setFrameHeight(); - }) - return { - pending() { - i.pending(); - Streamlit.setFrameHeight(); - }, - fulfilled(value: any) { - i.fulfilled(value); + + const ResizeObserver = (window as any).ResizeObserver + if (ResizeObserver) { + const resizeObserver = new ResizeObserver(() => { Streamlit.setFrameHeight(); - }, - rejected(error: any) { - i.rejected(error); + }) + resizeObserver.observe(el) + return { + fulfilled(value: any) { + i.fulfilled(value); + } + } + } else { + el.addEventListener('input', () => { Streamlit.setFrameHeight(); - }, - }; + }) + return { + pending() { + i.pending(); + Streamlit.setFrameHeight(); + }, + fulfilled(value: any) { + i.fulfilled(value); + Streamlit.setFrameHeight(); + }, + rejected(error: any) { + i.rejected(error); + Streamlit.setFrameHeight(); + }, + }; + } }); if (observeSet.size > 0) { Promise.all(Array.from(observeSet).map(async name => [name, await this.main.value(name)])).then(initial => { From 974802e17f2554a537ac66aa61c13d0d3138f4d7 Mon Sep 17 00:00:00 2001 From: Ivan Reshetnikov Date: Sat, 16 Jul 2022 12:29:33 +0300 Subject: [PATCH 6/6] Leave old resizing algorithm in addition to `ResizeObserver` --- .../frontend/src/Observable.tsx | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/streamlit_observable/frontend/src/Observable.tsx b/streamlit_observable/frontend/src/Observable.tsx index 965cdeb..eb516e1 100644 --- a/streamlit_observable/frontend/src/Observable.tsx +++ b/streamlit_observable/frontend/src/Observable.tsx @@ -65,30 +65,24 @@ class Observable extends StreamlitComponentBase<{}> { Streamlit.setFrameHeight(); }) resizeObserver.observe(el) - return { - fulfilled(value: any) { - i.fulfilled(value); - } - } - } else { - el.addEventListener('input', () => { - Streamlit.setFrameHeight(); - }) - return { - pending() { - i.pending(); - Streamlit.setFrameHeight(); - }, - fulfilled(value: any) { - i.fulfilled(value); - Streamlit.setFrameHeight(); - }, - rejected(error: any) { - i.rejected(error); - Streamlit.setFrameHeight(); - }, - }; } + el.addEventListener('input', () => { + Streamlit.setFrameHeight(); + }) + return { + pending() { + i.pending(); + Streamlit.setFrameHeight(); + }, + fulfilled(value: any) { + i.fulfilled(value); + Streamlit.setFrameHeight(); + }, + rejected(error: any) { + i.rejected(error); + Streamlit.setFrameHeight(); + }, + }; }); if (observeSet.size > 0) { Promise.all(Array.from(observeSet).map(async name => [name, await this.main.value(name)])).then(initial => {