├── MANIFEST.in ├── streamlit_observable ├── frontend │ ├── src │ │ ├── types.d.ts │ │ ├── react-app-env.d.ts │ │ ├── index.tsx │ │ ├── streamlit │ │ │ ├── index.tsx │ │ │ ├── StreamlitReact.tsx │ │ │ ├── ArrowTable.ts │ │ │ └── streamlit.ts │ │ └── Observable.tsx │ ├── .prettierrc │ ├── .env │ ├── tsconfig.json │ ├── public │ │ └── index.html │ └── package.json └── __init__.py ├── Makefile ├── setup.py ├── LICENSE ├── .gitignore └── README.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include streamlit_observable/frontend/build * 2 | -------------------------------------------------------------------------------- /streamlit_observable/frontend/src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@observablehq/runtime'; -------------------------------------------------------------------------------- /streamlit_observable/frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /streamlit_observable/frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": false, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY= frontend wheels 2 | 3 | frontend: 4 | npm run build --prefix streamlit_observable/frontend 5 | 6 | wheels: 7 | python setup.py sdist bdist_wheel 8 | 9 | upload: 10 | echo "python -m twine upload dist/*" -------------------------------------------------------------------------------- /streamlit_observable/frontend/.env: -------------------------------------------------------------------------------- 1 | # Run the component's dev server on :3001 2 | # (The Streamlit dev server already runs on :3000) 3 | PORT=3001 4 | 5 | # Don't automatically open the web browser on `npm run start`. 6 | BROWSER=none 7 | -------------------------------------------------------------------------------- /streamlit_observable/frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import Observable from "./Observable" 4 | 5 | ReactDOM.render( 6 | 7 | 8 | , 9 | document.getElementById("root") 10 | ) 11 | -------------------------------------------------------------------------------- /streamlit_observable/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react" 17 | }, 18 | "include": ["src"] 19 | } 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | from os import path 3 | 4 | def get_long_description(): 5 | with open( 6 | path.join(path.dirname(path.abspath(__file__)), "README.md"), 7 | encoding="utf8", 8 | ) as f: 9 | return f.read() 10 | 11 | setuptools.setup( 12 | name="streamlit-observable", 13 | version="0.0.8", 14 | author="Alex Garcia", 15 | author_email="alexsebastian.garcia@gmail.com", 16 | description="A Streamlit component for embedding Observable notebooks in Streamlit Apps", 17 | long_description=get_long_description(), 18 | long_description_content_type="text/markdown", 19 | url="https://github.com/asg017/streamlit-observable", 20 | packages=setuptools.find_packages(), 21 | include_package_data=True, 22 | classifiers=[], 23 | python_requires=">=3.6", 24 | install_requires=[ 25 | "streamlit >= 0.63", 26 | ], 27 | ) 28 | -------------------------------------------------------------------------------- /streamlit_observable/frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Streamlit Component 6 | 7 | 8 | 9 | 10 | 12 | 13 | 14 | 15 | 16 |
17 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 The Python Packaging Authority 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /streamlit_observable/frontend/src/streamlit/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018-2020 Streamlit Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | // Workaround for type-only exports: 19 | // https://stackoverflow.com/questions/53728230/cannot-re-export-a-type-when-using-the-isolatedmodules-with-ts-3-2-2 20 | import { ComponentProps as ComponentProps_ } from "./StreamlitReact" 21 | import { RenderData as RenderData_ } from "./streamlit" 22 | 23 | export { 24 | StreamlitComponentBase, 25 | withStreamlitConnection, 26 | } from "./StreamlitReact" 27 | export { ArrowTable } from "./ArrowTable" 28 | export { Streamlit } from "./streamlit" 29 | export type ComponentProps = ComponentProps_ 30 | export type RenderData = RenderData_ 31 | -------------------------------------------------------------------------------- /streamlit_observable/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "streamlit_component_template", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@observablehq/runtime": "^4.7.2", 7 | "@testing-library/jest-dom": "^4.2.4", 8 | "@testing-library/react": "^9.3.2", 9 | "@testing-library/user-event": "^7.1.2", 10 | "@types/hoist-non-react-statics": "^3.3.1", 11 | "@types/jest": "^24.0.0", 12 | "@types/node": "^12.0.0", 13 | "@types/react": "^16.9.0", 14 | "@types/react-dom": "^16.9.0", 15 | "apache-arrow": "^0.17.0", 16 | "bootstrap": "^4.4.1", 17 | "event-target-shim": "^5.0.1", 18 | "hoist-non-react-statics": "^3.3.2", 19 | "react": "^16.13.1", 20 | "react-dom": "^16.13.1", 21 | "react-scripts": "3.4.1", 22 | "typescript": "~3.7.2" 23 | }, 24 | "scripts": { 25 | "start": "react-scripts start", 26 | "build": "react-scripts build", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "eslintConfig": { 31 | "extends": "react-app" 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | }, 45 | "homepage": "." 46 | } 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ######################################################################## 2 | # Python - https://github.com/github/gitignore/blob/master/Python.gitignore 3 | ######################################################################## 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # Distribution / packaging 10 | build/ 11 | dist/ 12 | eggs/ 13 | .eggs/ 14 | *.egg-info/ 15 | *.egg 16 | 17 | # Unit test / coverage reports 18 | .coverage 19 | .coverage\.* 20 | .pytest_cache/ 21 | .mypy_cache/ 22 | test-reports 23 | 24 | # Test fixtures 25 | cffi_bin 26 | 27 | # Pyenv Stuff 28 | .python-version 29 | 30 | ######################################################################## 31 | # OSX - https://github.com/github/gitignore/blob/master/Global/macOS.gitignore 32 | ######################################################################## 33 | .DS_Store 34 | .DocumentRevisions-V100 35 | .fseventsd 36 | .Spotlight-V100 37 | .TemporaryItems 38 | .Trashes 39 | .VolumeIcon.icns 40 | .com.apple.timemachine.donotpresent 41 | 42 | ######################################################################## 43 | # node - https://github.com/github/gitignore/blob/master/Node.gitignore 44 | ######################################################################## 45 | # Logs 46 | npm-debug.log* 47 | yarn-debug.log* 48 | yarn-error.log* 49 | 50 | # Dependency directories 51 | node_modules/ 52 | 53 | # Coverage directory used by tools like istanbul 54 | coverage/ 55 | 56 | # Lockfiles 57 | yarn.lock 58 | package-lock.json 59 | 60 | ######################################################################## 61 | # JetBrains 62 | ######################################################################## 63 | .idea 64 | 65 | ######################################################################## 66 | # VSCode 67 | ######################################################################## 68 | .vscode/ 69 | -------------------------------------------------------------------------------- /streamlit_observable/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import streamlit.components.v1 as components 3 | 4 | _RELEASE = True 5 | 6 | if not _RELEASE: 7 | _component_func = components.declare_component( 8 | "observable", 9 | url="http://localhost:3001", 10 | ) 11 | else: 12 | parent_dir = os.path.dirname(os.path.abspath(__file__)) 13 | build_dir = os.path.join(parent_dir, "frontend", "build") 14 | _component_func = components.declare_component("observable", path=build_dir) 15 | 16 | 17 | def observable(key, notebook, targets=None, redefine={}, observe=[], hide=[]): 18 | """Create a new instance of "observable". 19 | 20 | Parameters 21 | ---------- 22 | key: str 23 | A unique string used to avoid constant re-renders to the iframe. 24 | notebook: str 25 | The observablehq.com notebook id to embed. Ex. "@"d3/bar-chart" 26 | or "d/1f434ef3b0569a00" 27 | targets: list or None 28 | An optional list of strings that are the name of the cells to embed. 29 | By default, the entire notebook, including unnamed cells, will be embeded. 30 | observe: list or None 31 | An optional list of strings that are the name of cells to observe. 32 | Whenever these cells change value or become fulfilled, the value will 33 | be passed back into Streamlit as part of the return value. 34 | redefine: dict or None 35 | An optional dict containing the cells you wish to redefine and the values 36 | you wish to redefine them as. The keys are the cell names you want to 37 | redefine, the values are what they will be redefined as. Keep in mind, 38 | there is a serialization process from Streamlit Python -> frontend JavaScript. 39 | hide: list or None 40 | An option list of strings that are the names of cells that will be embeded, 41 | but won't be rendered to the DOM. 42 | Returns 43 | ------- 44 | dict 45 | An object containing the live observed values. If the observe parameter is 46 | empty, then the dict will be empty. The keys are the name of the cell that 47 | is observe, the values are the values of the cells. 48 | 49 | """ 50 | component_value = _component_func( 51 | notebook=notebook, 52 | targets=targets, 53 | observe=observe, 54 | redefine=redefine, 55 | hide=hide, 56 | key=key, 57 | name=key 58 | ) 59 | 60 | if component_value is None: 61 | return {} 62 | 63 | return component_value 64 | 65 | 66 | if not _RELEASE: 67 | observers = observable("World Tour!", 68 | notebook="@d3/world-tour", 69 | targets=["canvas"], 70 | observe=["name"] 71 | ) 72 | 73 | name = observers.get("name") 74 | 75 | st.write(f"Current country: ** *{name}* **") 76 | -------------------------------------------------------------------------------- /streamlit_observable/frontend/src/Observable.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react" 2 | import { 3 | withStreamlitConnection, 4 | StreamlitComponentBase, 5 | Streamlit, 6 | } from "./streamlit" 7 | import { Runtime, Inspector } from "@observablehq/runtime"; 8 | 9 | class Observable extends StreamlitComponentBase<{}> { 10 | public observeValue = {}; 11 | private notebookRef = React.createRef(); 12 | private runtime: any = null; 13 | private main: any = null; 14 | 15 | componentWillUnmount() { 16 | this.runtime?.dispose(); 17 | } 18 | // @ts-ignore 19 | public componentDidUpdate(prevProps: any) { 20 | const { args: prevArgs } = prevProps; 21 | if (prevArgs.notebook !== this.props.args.notebook) { 22 | // TODO handle new notebook 23 | } 24 | this.redefineCells(this.main, this.props.args.redefine); 25 | } 26 | 27 | async embedNotebook(notebook: string, targets: string[], observe: string[], hide:string[]) { 28 | if (this.runtime) { 29 | this.runtime.dispose(); 30 | } 31 | const targetSet = new Set(targets); 32 | const observeSet = new Set(observe); 33 | const hideSet = new Set(hide); 34 | this.runtime = new Runtime(); 35 | const { default: define } = await eval(`import("https://api.observablehq.com/${notebook}.js?v=3")`); 36 | this.main = this.runtime.module(define, (name: string) => { 37 | if (observeSet.has(name) && !targetSet.has(name)) { 38 | const observeValue = this.observeValue; 39 | return { 40 | fulfilled: (value: any) => { 41 | //@ts-ignore 42 | observeValue[name] = value; 43 | //@ts-ignore 44 | Streamlit.setComponentValue(observeValue); 45 | } 46 | } 47 | } 48 | if (targetSet.size > 0 && !targetSet.has(name)) return; 49 | if(hideSet.has(name)) return true; 50 | const el = document.createElement('div'); 51 | this.notebookRef.current?.appendChild(el); 52 | 53 | const i = new Inspector(el); 54 | el.addEventListener('input', e => { 55 | Streamlit.setFrameHeight(); 56 | }) 57 | return { 58 | pending() { 59 | i.pending(); 60 | Streamlit.setFrameHeight(); 61 | }, 62 | fulfilled(value: any) { 63 | i.fulfilled(value); 64 | Streamlit.setFrameHeight(); 65 | }, 66 | rejected(error: any) { 67 | i.rejected(error); 68 | Streamlit.setFrameHeight(); 69 | }, 70 | }; 71 | }); 72 | if (observeSet.size > 0) { 73 | Promise.all(Array.from(observeSet).map(async name => [name, await this.main.value(name)])).then(initial => { 74 | for (const [name, value] of initial) { 75 | // @ts-ignore 76 | this.observeValue[name] = value 77 | }; 78 | Streamlit.setComponentValue(this.observeValue); 79 | }) 80 | } 81 | } 82 | 83 | redefineCells(main: any, redefine = {}) { 84 | for (let cell in redefine) { 85 | //@ts-ignore 86 | main.redefine(cell, redefine[cell]); 87 | } 88 | } 89 | componentDidMount() { 90 | const { notebook, targets = [], observe = [], redefine = {} , hide=[]} = this.props.args; 91 | Streamlit.setComponentValue(this.observeValue); 92 | this.embedNotebook(notebook, targets, observe, hide).then(() => { 93 | this.redefineCells(this.main, redefine); 94 | }); 95 | 96 | } 97 | 98 | public render = (): ReactNode => { 99 | return ( 100 |
101 |
102 |
103 |
104 |
105 | 106 |
114 |
{this.props.args.name}
115 | 118 |
119 |
120 |
121 | ) 122 | } 123 | } 124 | 125 | export default withStreamlitConnection(Observable) 126 | -------------------------------------------------------------------------------- /streamlit_observable/frontend/src/streamlit/StreamlitReact.tsx: -------------------------------------------------------------------------------- 1 | import hoistNonReactStatics from "hoist-non-react-statics" 2 | import React, { ReactNode } from "react" 3 | import { RenderData, Streamlit } from "./streamlit" 4 | 5 | /** 6 | * Props passed to custom Streamlit components. 7 | */ 8 | export interface ComponentProps { 9 | /** Named dictionary of arguments passed from Python. */ 10 | args: any 11 | 12 | /** The component's width. */ 13 | width: number 14 | 15 | /** 16 | * True if the component should be disabled. 17 | * All components get disabled while the app is being re-run, 18 | * and become re-enabled when the re-run has finished. 19 | */ 20 | disabled: boolean 21 | } 22 | 23 | /** 24 | * Optional Streamlit React-based component base class. 25 | * 26 | * You are not required to extend this base class to create a Streamlit 27 | * component. If you decide not to extend it, you should implement the 28 | * `componentDidMount` and `componentDidUpdate` functions in your own class, 29 | * so that your plugin properly resizes. 30 | */ 31 | export class StreamlitComponentBase extends React.PureComponent< 32 | ComponentProps, 33 | S 34 | > { 35 | public componentDidMount(): void { 36 | // After we're rendered for the first time, tell Streamlit that our height 37 | // has changed. 38 | Streamlit.setFrameHeight() 39 | } 40 | 41 | public componentDidUpdate(): void { 42 | // After we're updated, tell Streamlit that our height may have changed. 43 | Streamlit.setFrameHeight() 44 | } 45 | } 46 | 47 | /** 48 | * Wrapper for React-based Streamlit components. 49 | * 50 | * Bootstraps the communication interface between Streamlit and the component. 51 | */ 52 | export function withStreamlitConnection( 53 | WrappedComponent: React.ComponentType 54 | ): React.ComponentType { 55 | interface WrapperProps { } 56 | 57 | interface WrapperState { 58 | renderData?: RenderData 59 | componentError?: Error 60 | } 61 | 62 | class ComponentWrapper extends React.PureComponent< 63 | WrapperProps, 64 | WrapperState 65 | > { 66 | public constructor(props: WrapperProps) { 67 | super(props) 68 | this.state = { 69 | renderData: undefined, 70 | componentError: undefined, 71 | } 72 | } 73 | 74 | /** 75 | * Error boundary function. This will be called if our wrapped 76 | * component throws an error. We store the caught error in our state, 77 | * and display it in the next render(). 78 | */ 79 | public static getDerivedStateFromError = ( 80 | error: Error 81 | ): Partial => { 82 | return { componentError: error } 83 | } 84 | 85 | public componentDidMount = (): void => { 86 | // Set up event listeners, and signal to Streamlit that we're ready. 87 | // We won't render the component until we receive the first RENDER_EVENT. 88 | Streamlit.events.addEventListener( 89 | Streamlit.RENDER_EVENT, 90 | this.onRenderEvent 91 | ) 92 | Streamlit.setComponentReady() 93 | } 94 | 95 | public componentDidUpdate = (prevProps: any): void => { 96 | // If our child threw an error, we display it in render(). In this 97 | // case, the child won't be mounted and therefore won't call 98 | // `setFrameHeight` on its own. We do it here so that the rendered 99 | // error will be visible. 100 | if (this.state.componentError != null) { 101 | Streamlit.setFrameHeight() 102 | } 103 | } 104 | 105 | public componentWillUnmount = (): void => { 106 | Streamlit.events.removeEventListener( 107 | Streamlit.RENDER_EVENT, 108 | this.onRenderEvent 109 | ) 110 | } 111 | 112 | /** 113 | * Streamlit is telling this component to redraw. 114 | * We save the render data in State, so that it can be passed to the 115 | * component in our own render() function. 116 | */ 117 | private onRenderEvent = (event: Event): void => { 118 | // Update our state with the newest render data 119 | const renderEvent = event as CustomEvent 120 | this.setState({ renderData: renderEvent.detail }) 121 | } 122 | 123 | public render = (): ReactNode => { 124 | // If our wrapped component threw an error, display it. 125 | if (this.state.componentError != null) { 126 | return ( 127 |
128 |

Component Error

129 | {this.state.componentError.message} 130 |
131 | ) 132 | } 133 | 134 | // Don't render until we've gotten our first RENDER_EVENT from Streamlit. 135 | if (this.state.renderData == null) { 136 | return null 137 | } 138 | 139 | return ( 140 | 145 | ) 146 | } 147 | } 148 | 149 | return hoistNonReactStatics(ComponentWrapper, WrappedComponent) 150 | } 151 | -------------------------------------------------------------------------------- /streamlit_observable/frontend/src/streamlit/ArrowTable.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018-2019 Streamlit Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | import { Table, Type } from "apache-arrow" 19 | 20 | type CellType = "blank" | "index" | "columns" | "data" 21 | 22 | export interface ArrowDataframeProto { 23 | data: ArrowTableProto 24 | height: string 25 | width: string 26 | } 27 | 28 | export interface ArrowTableProto { 29 | data: Uint8Array 30 | index: Uint8Array 31 | columns: Uint8Array 32 | styler: Styler 33 | } 34 | 35 | interface Cell { 36 | classNames: string 37 | content: string 38 | id?: string 39 | type: CellType 40 | } 41 | 42 | interface Styler { 43 | caption?: string 44 | displayValuesTable: Table 45 | styles?: string 46 | uuid: string 47 | } 48 | 49 | export class ArrowTable { 50 | private readonly dataTable: Table 51 | private readonly indexTable: Table 52 | private readonly columnsTable: Table 53 | private readonly styler?: Styler 54 | 55 | constructor( 56 | dataBuffer: Uint8Array, 57 | indexBuffer: Uint8Array, 58 | columnsBuffer: Uint8Array, 59 | styler?: any 60 | ) { 61 | this.dataTable = Table.from(dataBuffer) 62 | this.indexTable = Table.from(indexBuffer) 63 | this.columnsTable = Table.from(columnsBuffer) 64 | this.styler = styler 65 | ? { 66 | caption: styler.get("caption"), 67 | displayValuesTable: Table.from(styler.get("displayValues")), 68 | styles: styler.get("styles"), 69 | uuid: styler.get("uuid"), 70 | } 71 | : undefined 72 | } 73 | 74 | get rows(): number { 75 | return this.indexTable.length + this.columnsTable.numCols 76 | } 77 | 78 | get columns(): number { 79 | return this.indexTable.numCols + this.columnsTable.length 80 | } 81 | 82 | get headerRows(): number { 83 | return this.rows - this.dataRows 84 | } 85 | 86 | get headerColumns(): number { 87 | return this.columns - this.dataColumns 88 | } 89 | 90 | get dataRows(): number { 91 | return this.dataTable.length 92 | } 93 | 94 | get dataColumns(): number { 95 | return this.dataTable.numCols 96 | } 97 | 98 | get uuid(): string | undefined { 99 | return this.styler && this.styler.uuid 100 | } 101 | 102 | get caption(): string | undefined { 103 | return this.styler && this.styler.caption 104 | } 105 | 106 | get styles(): string | undefined { 107 | return this.styler && this.styler.styles 108 | } 109 | 110 | get table(): Table { 111 | return this.dataTable 112 | } 113 | 114 | get index(): Table { 115 | return this.indexTable 116 | } 117 | 118 | get columnTable(): Table { 119 | return this.columnsTable 120 | } 121 | 122 | public getCell = (rowIndex: number, columnIndex: number): Cell => { 123 | const isBlankCell = 124 | rowIndex < this.headerRows && columnIndex < this.headerColumns 125 | const isIndexCell = 126 | rowIndex >= this.headerRows && columnIndex < this.headerColumns 127 | const isColumnsCell = 128 | rowIndex < this.headerRows && columnIndex >= this.headerColumns 129 | 130 | if (isBlankCell) { 131 | const classNames = ["blank"] 132 | if (columnIndex > 0) { 133 | classNames.push("level" + rowIndex) 134 | } 135 | 136 | return { 137 | type: "blank", 138 | classNames: classNames.join(" "), 139 | content: "", 140 | } 141 | } else if (isColumnsCell) { 142 | const dataColumnIndex = columnIndex - this.headerColumns 143 | const classNames = [ 144 | "col_heading", 145 | "level" + rowIndex, 146 | "col" + dataColumnIndex, 147 | ] 148 | 149 | return { 150 | type: "columns", 151 | classNames: classNames.join(" "), 152 | content: this.getContent(this.columnsTable, dataColumnIndex, rowIndex), 153 | } 154 | } else if (isIndexCell) { 155 | const dataRowIndex = rowIndex - this.headerRows 156 | const classNames = [ 157 | "row_heading", 158 | "level" + columnIndex, 159 | "row" + dataRowIndex, 160 | ] 161 | 162 | return { 163 | type: "index", 164 | id: `T_${this.uuid}level${columnIndex}_row${dataRowIndex}`, 165 | classNames: classNames.join(" "), 166 | content: this.getContent(this.indexTable, dataRowIndex, columnIndex), 167 | } 168 | } else { 169 | const dataRowIndex = rowIndex - this.headerRows 170 | const dataColumnIndex = columnIndex - this.headerColumns 171 | const classNames = [ 172 | "data", 173 | "row" + dataRowIndex, 174 | "col" + dataColumnIndex, 175 | ] 176 | const content = this.styler 177 | ? this.getContent( 178 | this.styler.displayValuesTable, 179 | dataRowIndex, 180 | dataColumnIndex 181 | ) 182 | : this.getContent(this.dataTable, dataRowIndex, dataColumnIndex) 183 | 184 | return { 185 | type: "data", 186 | id: `T_${this.uuid}row${dataRowIndex}_col${dataColumnIndex}`, 187 | classNames: classNames.join(" "), 188 | content, 189 | } 190 | } 191 | } 192 | 193 | public getContent = ( 194 | table: Table, 195 | rowIndex: number, 196 | columnIndex: number 197 | ): any => { 198 | const column = table.getColumnAt(columnIndex) 199 | if (column === null) { 200 | return "" 201 | } 202 | 203 | const columnTypeId = this.getColumnTypeId(table, columnIndex) 204 | switch (columnTypeId) { 205 | case Type.Timestamp: { 206 | return this.nanosToDate(column.get(rowIndex)) 207 | } 208 | default: { 209 | return column.get(rowIndex) 210 | } 211 | } 212 | } 213 | 214 | /** 215 | * Returns apache-arrow specific typeId of column. 216 | */ 217 | private getColumnTypeId(table: Table, columnIndex: number): Type { 218 | return table.schema.fields[columnIndex].type.typeId 219 | } 220 | 221 | private nanosToDate(nanos: number): Date { 222 | return new Date(nanos / 1e6) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /streamlit_observable/frontend/src/streamlit/streamlit.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright 2018-2020 Streamlit Inc. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | // Safari doesn't support the EventTarget class, so we use a shim. 19 | import { EventTarget } from "event-target-shim" 20 | import { ArrowDataframeProto, ArrowTable } from "./ArrowTable" 21 | 22 | /** Data sent in the custom Streamlit render event. */ 23 | export interface RenderData { 24 | args: any 25 | disabled: boolean 26 | } 27 | 28 | /** Messages from Component -> Streamlit */ 29 | enum ComponentMessageType { 30 | // A component sends this message when it's ready to receive messages 31 | // from Streamlit. Streamlit won't send any messages until it gets this. 32 | // Data: { apiVersion: number } 33 | COMPONENT_READY = "streamlit:componentReady", 34 | 35 | // The component has a new widget value. Send it back to Streamlit, which 36 | // will then re-run the app. 37 | // Data: { value: any } 38 | SET_COMPONENT_VALUE = "streamlit:setComponentValue", 39 | 40 | // The component has a new height for its iframe. 41 | // Data: { height: number } 42 | SET_FRAME_HEIGHT = "streamlit:setFrameHeight", 43 | } 44 | 45 | /** 46 | * Streamlit communication API. 47 | * 48 | * Components can send data to Streamlit via the functions defined here, 49 | * and receive data from Streamlit via the `events` property. 50 | */ 51 | export class Streamlit { 52 | /** 53 | * The Streamlit component API version we're targetting. 54 | * There's currently only 1! 55 | */ 56 | public static readonly API_VERSION = 1 57 | 58 | public static readonly RENDER_EVENT = "streamlit:render" 59 | 60 | /** Dispatches events received from Streamlit. */ 61 | public static readonly events = new EventTarget() 62 | 63 | private static registeredMessageListener = false 64 | private static lastFrameHeight?: number 65 | 66 | /** 67 | * Tell Streamlit that the component is ready to start receiving data. 68 | * Streamlit will defer emitting RENDER events until it receives the 69 | * COMPONENT_READY message. 70 | */ 71 | public static setComponentReady = (): void => { 72 | if (!Streamlit.registeredMessageListener) { 73 | // Register for message events if we haven't already 74 | window.addEventListener("message", Streamlit.onMessageEvent) 75 | Streamlit.registeredMessageListener = true 76 | } 77 | 78 | Streamlit.sendBackMsg(ComponentMessageType.COMPONENT_READY, { 79 | apiVersion: Streamlit.API_VERSION, 80 | }) 81 | } 82 | 83 | /** 84 | * Report the component's height to Streamlit. 85 | * This should be called every time the component changes its DOM - that is, 86 | * when it's first loaded, and any time it updates. 87 | */ 88 | public static setFrameHeight = (height?: number): void => { 89 | if (height === undefined) { 90 | // `height` is optional. If undefined, it defaults to scrollHeight, 91 | // which is the entire height of the element minus its border, 92 | // scrollbar, and margin. 93 | height = document.body.scrollHeight + 10; 94 | } 95 | 96 | if (height === Streamlit.lastFrameHeight) { 97 | // Don't bother updating if our height hasn't changed. 98 | return 99 | } 100 | 101 | Streamlit.lastFrameHeight = height 102 | Streamlit.sendBackMsg(ComponentMessageType.SET_FRAME_HEIGHT, { height }) 103 | } 104 | 105 | /** 106 | * Set the component's value. This value will be returned to the Python 107 | * script, and the script will be re-run. 108 | * 109 | * For example: 110 | * 111 | * JavaScript: 112 | * Streamlit.setComponentValue("ahoy!") 113 | * 114 | * Python: 115 | * value = st.my_component(...) 116 | * st.write(value) # -> "ahoy!" 117 | * 118 | * The value must be serializable into JSON. 119 | */ 120 | public static setComponentValue = (value: any): void => { 121 | Streamlit.sendBackMsg(ComponentMessageType.SET_COMPONENT_VALUE, { value }) 122 | } 123 | 124 | /** Receive a ForwardMsg from the Streamlit app */ 125 | private static onMessageEvent = (event: MessageEvent): void => { 126 | const type = event.data["type"] 127 | switch (type) { 128 | case Streamlit.RENDER_EVENT: 129 | Streamlit.onRenderMessage(event.data) 130 | break 131 | } 132 | } 133 | 134 | /** 135 | * Handle an untyped Streamlit render event and redispatch it as a 136 | * StreamlitRenderEvent. 137 | */ 138 | private static onRenderMessage = (data: any): void => { 139 | let args = data["args"] 140 | if (args == null) { 141 | console.error( 142 | `Got null args in onRenderMessage. This should never happen` 143 | ) 144 | args = {} 145 | } 146 | 147 | // Parse our dataframe arguments with arrow, and merge them into our args dict 148 | const dataframeArgs = 149 | data["dfs"] && data["dfs"].length > 0 150 | ? Streamlit.argsDataframeToObject(data["dfs"]) 151 | : {} 152 | 153 | args = { 154 | ...args, 155 | ...dataframeArgs, 156 | } 157 | 158 | const disabled = Boolean(data["disabled"]) 159 | 160 | // Dispatch a render event! 161 | const eventData = { disabled, args } 162 | const event = new CustomEvent(Streamlit.RENDER_EVENT, { 163 | detail: eventData, 164 | }) 165 | Streamlit.events.dispatchEvent(event) 166 | } 167 | 168 | private static argsDataframeToObject = ( 169 | argsDataframe: ArgsDataframe[] 170 | ): object => { 171 | const argsDataframeArrow = argsDataframe.map( 172 | ({ key, value }: ArgsDataframe) => [key, Streamlit.toArrowTable(value)] 173 | ) 174 | return Object.fromEntries(argsDataframeArrow) 175 | } 176 | 177 | private static toArrowTable = (df: ArrowDataframeProto): ArrowTable => { 178 | const { data, index, columns } = df.data 179 | return new ArrowTable(data, index, columns) 180 | } 181 | 182 | /** Post a message to the Streamlit app. */ 183 | private static sendBackMsg = (type: string, data?: any): void => { 184 | window.parent.postMessage( 185 | { 186 | isStreamlitMessage: true, 187 | type: type, 188 | ...data, 189 | }, 190 | "*" 191 | ) 192 | } 193 | } 194 | 195 | interface ArgsDataframe { 196 | key: string 197 | value: ArrowDataframeProto 198 | } 199 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # streamlit-observable 2 | 3 | Embed Observable notebooks into Streamlit apps! 4 | 5 | ## Why tho 6 | 7 | There are hundreds of Observable notebooks at observablehq.com that create beautiful data visualizations, graphs, charts, maps, and animations. With `streamlit-observable`, you can inject your own data, configuration, and styling into these notebooks, and listen to cell value updates for passing data back into Streamlit! 8 | 9 | ## Install 10 | 11 | ```bash 12 | pip install streamlit-observable 13 | ``` 14 | 15 | ## Usage 16 | 17 | Check out [the example Streamlit app](https://streamlit-observable.herokuapp.com/) to see a ton of in-depth examples! Below are some rough code examples to get a rough idea of how to use `streamlit-observable`. 18 | 19 | ### [Embed Bar Chart](https://streamlit-observable.herokuapp.com/?section=Static+Embed%3A+Bar+Chart) 20 | 21 | ```python 22 | import streamlit as st 23 | from streamlit_observable import observable 24 | 25 | a = st.slider("Alex", value=30) 26 | b = st.slider("Brian", value=20) 27 | c = st.slider("Craig", value=50) 28 | 29 | observable("Example Updatable Bar Chart", 30 | notebook="@juba/updatable-bar-chart", 31 | targets=["chart", "draw"], 32 | redefine={ 33 | "data": [ 34 | {"name": "Alex", "value": a}, 35 | {"name": "Brian", "value": b}, 36 | {"name": "Craig", "value": c} 37 | ], 38 | }, 39 | hide=["draw"] 40 | ) 41 | ``` 42 | Rendered output of the above code, showing 3 slides names Alex, Brian and Craig, and a bar chart with three bars named Alex, Brian, and Craig, with heights 30, 20, and 50 respectively. 43 | 44 | ### [Trader Joes Voronoi Map](https://streamlit-observable.herokuapp.com/?section=Static+Embed%3A+Trader+Joes+Voronoi+Map) 45 | ```python 46 | import streamlit as st 47 | from streamlit_observable import observable 48 | 49 | @st.cache 50 | def get_trader_joes(): 51 | # a lot of code... 52 | return df 53 | 54 | df = get_trader_joes() 55 | 56 | observable("Trader Joes Voronoi Map", 57 | notebook="@mbostock/u-s-voronoi-map-o-matic", 58 | targets=["map"], 59 | redefine={ 60 | "data": df[["longitude", "latitude", "name"]].to_dict(orient="records") 61 | } 62 | ) 63 | ``` 64 | 65 | Rendered output of the code above, showing a map of the United States, with points on every Trader Joes's, with a Voronoi map laid on top. 66 | 67 | 68 | ### [Form Input Example](https://streamlit-observable.herokuapp.com/?section=Bi-Directional+Embed%3A+HTML+Form) 69 | ```python 70 | import streamlit as st 71 | from streamlit_observable import observable 72 | 73 | observers = observable("Example form", 74 | notebook="@mbostock/form-input", 75 | targets=["viewof object"], 76 | observe=["object"] 77 | ) 78 | 79 | o = observers.get("object") 80 | 81 | if o is not None: 82 | st.write("message: **'{message}'**, hue: '{hue}', size: '{size}', emojis: '{emojis}'".format( 83 | message=o.get("message"), 84 | hue=o.get("hue"), 85 | size=o.get("size"), 86 | emojis=str(o.get("emojis")) 87 | )) 88 | ``` 89 | 90 | Rendered output of the code above, showing an HTML form with example fields, and a label at the bottom showing the values of the form. 91 | 92 | ### [Selecting U.S. Counties](https://streamlit-observable.herokuapp.com/?section=Bi-Directional+Embed%3A+Selecting+Counties) 93 | ```python 94 | import streamlit as st 95 | from streamlit_observable import observable 96 | 97 | observers = observable("County Brush", 98 | notebook="d/4f9aa5feff9761c9", 99 | targets=["viewof countyCodes"], 100 | observe=["selectedCounties"] 101 | ) 102 | 103 | selectedCounties = observers.get("selectedCounties") 104 | ``` 105 | 106 | Rendered output of the code above, showing a map of the United States that can be brushed to select specific counties. 107 | 108 | 109 | 110 | ## API Reference 111 | 112 | ### observable(key, notebook, _targets_=None, _observe_=[], _redefine_={}, _hide_=[]) 113 | 114 | Embed an Observable notebook into the Streamlit app. If any cells are passed into `observe`, then this will return a dict, where the keys are the name of the cells that are being observed, and the values are the values of those cells. 115 | 116 | - `key`: A unique string used to avoid re-renders of the iframe. This label will appear at the bottom of the embed. 117 | - `notebook`: The observablehq.com notebook id to embed. Ex. "@"d3/bar-chart" or "d/1f434ef3b0569a00" 118 | - `targets`: An optional list of cell names to render in the embeded notebook. By default, all cells, including unnamed cells, will be embeded. 119 | - `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). 120 | - `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). 121 | - `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. 122 | 123 | ## Caveats 124 | 125 | ### Redefining or Observing Cells need to be JSON-serializable 126 | 127 | In order to pass data from Python into an Observable notebook (with `redefine`), it needs to be JSON serializable, usually a `list`, `dict`, string or number. So if you're working with a pandas DataFrame or numpy array, you may need to wrangle it before redefining (usually with something like panda's [`.to_dict()`](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.to_dict.html) or numpy's [`.tolist()`](https://numpy.org/doc/stable/reference/generated/numpy.ndarray.tolist.html)). 128 | 129 | Similarly, when passing data from an Observable notebook back into Streamlit/Python (with `observe`), that data also needs to be JSON serializable. So when passing back [Date objects](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date), [Sets](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set), or other custon objects, you'll first need to represent it in some JSON serializable way, then wrangle it in Python-land to match what you expect. For example, with a Date object, you could convert to to the JSON-friendly Unix Epoch (number) with [.getTime()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/getTime), then read it as a datetime object in Python with [`datetime.fromtimestamp(time / 1000)`](https://docs.python.org/3/library/datetime.html). 130 | 131 | ### Accessing webcam and microphone doesn't work 132 | 133 | Not entirely sure why this is the case, but if someone figures it out, I'd love to see a PR! 134 | 135 | ### Large Data is Hard 136 | 137 | I haven't tried this, but I expect that if you try loading 1GB+ of data into a bar chart, something will break. All the data that you `redefine` will be read in memory in your browser when embeding into the chart, so something might break along the way. If you ever come across this, feel free to open an issue about it! 138 | 139 | ### You'll need to fork a lot 140 | 141 | 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. --------------------------------------------------------------------------------