DEV Community

loading...

Streamlit components - Scatterplot with selection using Plotly.js

andfanilo profile image Fanilo Andrianasolo Updated on ・9 min read

Streamlit Components are out ! This opens a lot of new possibilities as it makes it easy to integrate any interactive Javascript library into Streamlit, and send data back and forth between a Python script and a React component.

Let's build a quick example around it to help quickstart an answer to a forum question about Plotly cross-interactive filtering : can we build a scatterplot where selected points are sent back into Python ? We will solve this by creating a Plotly.js component with lasso selection and communicate selected points to Python.

Alt Text

While this tutorial is accessible to anyone with no JS/React experience, I do skip on some important frontend/React concepts. I have a longer tutorial here that addresses them but don't hesitate to check the React website if you want to go deeper.

Prerequisite : How does plotly work ?

A lot of Data Scientists use plotly.py to build interactive charts. plotly.py is actually a Python wrapper for plotly.js. Plotly figures are represented by a JSON specification which the Python client generates for you that plotly.js can consume to render a graph.

If you know Altair from the Vega ecosystem, Altair works as a Python wrapper for Vega-lite in the exact same way plotly.py is a Python wrapper for plotly.js.

You can retrieve the JSON specification from a Figure in Python :

import plotly.express as px
fig = px.line(x=["a","b","c"], y=[1,3,2], title="sample figure")
fig.to_json()

# renders a somewhat big JSON spec
# {"data":[{"hovertemplate":"x=%{x}<br>y=%{y}<extra></extra>","legendgroup":"","line":{"color":"#636efa","dash":"solid"},"mode":"lines","name":"", ...

and copy this JSON spec in a Plotly.js element to see the rendered result. Here is a Codepen for demonstration.

It's important to notice that a lot of JS charting libraries work by passing a JSON specification to them. Take a look at the documentation for echarts, Chart.js, Vega-lite, Victory ...
The takeaway here is this process can be replicated for integrating other charting libraries !

Our first task will be to replicate the streamlit.plotly_charts by passing the JSON representation of the plotly Figure to the Javascript library for rendering.

Setup

Before pursuing this tutorial, you will need Python/Node.js installed on your system.

First clone the streamlit component-template, then move the template folder to your usual workspace.

git clone https://github.com/streamlit/component-template
cp -r component-template/template ~/streamlit-custom-plotly

From now on we assume every following shell command is done from the streamlit-custom-plotly folder.

To pursue this, we need 2 terminals : one will run the Streamlit server, the other one a dev server containing the Javascript component.

  • In a terminal, install the frontend dependencies and then run the component server.
cd my_component/frontend
npm install    # Initialize the project and install npm dependencies
npm run start  # Start the Webpack dev server
  • In another terminal, create a Python environment with Streamlit ≥ 0.63 and then run the __init__.py script
conda create -n streamlit-custom python=3 streamlit  # Create Conda env (or other Python environment manager)
conda activate streamlit-custom  # Activate environment
streamlit run my_component/__init__.py  # Run Streamlit app

You should see the Streamlit Hello world of Custom Components opening in a new browser :

Alt Text

Step 1 - "Hello world"

Head to your favorite code editor to edit the frontend code and only render "Hello world". Remove everything in my_component/frontend/src/MyComponent.tsx and paste the following code to render a single Hello world block :

import React, { useEffect } from "react"
import { withStreamlitConnection, Streamlit } from "./streamlit"

function MyComponent() {
  useEffect(() => Streamlit.setFrameHeight())

  return <p>Hello world</p>
}

export default withStreamlitConnection(MyComponent)

We should also clean the running Streamlit in my_component/__init__.py :

import streamlit as st
import streamlit.components.v1 as components

_component_func = components.declare_component(
    "my_component",
    url="http://localhost:3001",
)

def my_component():
    return _component_func()

st.subheader("My Component")
my_component()

We're in Streamlit world, so livereload is enabled everywhere and the update in your browser should be instant.

Alt Text

If we want to understand things a bit, on the Python side we have a wrapper my_component() function which calls a private _component_func(). This private function returned by components.declare_component accesses frontend resources from the url http://localhost:3001, the other running dev server delivering the frontend component !

On the Javascript side, we define a functional component MyComponent which returns a single block with "Hello world" inside. This is the output served by the dev server which Streamlit retrieves and renders in the browser.

We also make use of a React hook useEffect which runs an anonymous callback function after the component has rendered to compute the height of the render, then update the height of the iframe containing the component. If you omit that function, your component will have a height of 0 and be invisible to the eye, but it will actually be there if you inspect the page source code with your browser's devtools.

Step 2 - Bidirectional communication between Python and React

The _component_func in my_component/__init__.py manages the call to the frontend web server to fetch the component. Any argument passed through this function is JSON-serialized to the React component. For example a Python Dict is passed as a JSON object to the dev web server and available in our frontend counterpart.

Time to add some parameters to the call :

import streamlit as st
import streamlit.components.v1 as components

_component_func = components.declare_component(
    "my_component",
    url="http://localhost:3001",
)

def my_component():
    return _component_func(test="world") # <-- add some parameters in the call

st.subheader("My Component")
my_component()

And retrieve them on the React side in my_component/frontend/src/MyComponent.tsx

import React, { useEffect } from "react"
import { withStreamlitConnection, Streamlit, ComponentProps } from "./streamlit"

// Your function has arguments now !
function MyComponent(props: ComponentProps) {
  useEffect(() => Streamlit.setFrameHeight())

  // Paramters from _component_func are stored in props.args
  return <p>Hello {props.args.test}</p>
}

export default withStreamlitConnection(MyComponent)

Livereload in your browser should make the update already visible !

Alt Text

Here we set up data communication from Python to Javascript. To send data from Javascript to Python, we use the Streamlit.setComponentValue() method, the value will then be stored as the return value for _component_func :

import React, { useEffect } from "react"
import { withStreamlitConnection, Streamlit, ComponentProps } from "./streamlit"

function MyComponent(props: ComponentProps) {
  useEffect(() => Streamlit.setFrameHeight())
  useEffect(() => Streamlit.setComponentValue(42)) // return value to Python after the component has rendered

  return <p>Hello {props.args.test}</p>
}

export default withStreamlitConnection(MyComponent)

Then on the Python side :

import streamlit as st
import streamlit.components.v1 as components

_component_func = components.declare_component(
    "my_component",
    url="http://localhost:3001",
)

def my_component():
    return _component_func(test="test") # value from Streamlit.setComponentValue is now returned !

st.subheader("My Component")
v = my_component()
st.write(v)

Alt Text

PS : you may see for a brief moment, while the component is being rendered, that v = None . You can change the default value returned by _component_func with the default parameter : return _component_func(test="test", default=42)

Step 3 - Static Plotly.js plot

Time to spice things up, let's pass the JSON representation of our Plotly graph and render it with react-plotly.

First install the frontend dependencies :

cd my_component/frontend  # make sure you are running this from the frontend folder !
npm install react-plotly.js plotly.js @types/react-plotly.js

Test the installation using the react-plotly quickstart code in my_component/frontend/src/MyComponent.tsx :

import React, { useEffect } from "react"
import { withStreamlitConnection, Streamlit, ComponentProps } from "./streamlit"
import Plot from "react-plotly.js"  // new dependency 

function MyComponent(props: ComponentProps) {
  useEffect(() => Streamlit.setFrameHeight())

  // we just changed the return value of the functional component to the one from https://plotly.com/javascript/react/#quick-start
  return (
    <Plot
      data={[
        {
          x: [1, 2, 3],
          y: [2, 6, 3],
          type: "scatter",
          mode: "lines+markers",
          marker: { color: "red" },
        },
        { type: "bar", x: [1, 2, 3], y: [2, 5, 3] },
      ]}
      layout={{ width: 400, height: 400, title: "A Fancy Plot" }}
    />
  )
}

export default withStreamlitConnection(MyComponent)

Be greeted by your plotly.js plot !

Alt Text

NB : I got a FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory there, which I solved with a NODE_OPTIONS variable https://stackoverflow.com/questions/53230823/fatal-error-ineffective-mark-compacts-near-heap-limit-allocation-failed-javas.

Now let's pass the Python figure and extract the JSON specification in the wrapper to push to the JS side. In my_component/__init__.py :

import random
import plotly.express as px  
import streamlit as st
import streamlit.components.v1 as components

_component_func = components.declare_component(
    "my_component",
    url="http://localhost:3001",
)

def my_component(fig):
    return _component_func(spec=fig.to_json(), default=42)

st.subheader("My Component")
fig = px.scatter(x=random.sample(range(100), 50), y=random.sample(range(100), 50), title="My fancy plot")
v = my_component(fig)
st.write(v)

and then get it back on the Javascript side my_component/frontend/src/MyComponent.tsx

import React, { useEffect } from "react"
import { withStreamlitConnection, Streamlit, ComponentProps } from "./streamlit"
import Plot from "react-plotly.js"

function MyComponent(props: ComponentProps) {
  useEffect(() => Streamlit.setFrameHeight())
  useEffect(() => Streamlit.setComponentValue(42))

  const { data, layout, frames, config } = JSON.parse(props.args.spec)

  return (
    <Plot
      data={data}
      layout={layout}
      frames={frames}
      config={config}
    />
  )
}

export default withStreamlitConnection(MyComponent)

Your plotly express plot should appear now :

Alt Text

Why did we pass the JSON as a string to reparse it on the JS side ? I actually initially used fig.to_dict to pass a Dict in _component_func. It should then get serialized directly as a Javascript object, but it actually resulted in Error serializing numpy.ndarray, requiring converting the numpy array as Python lists so they can be considered JSON-serializable. I think there is a plotly.utils.PlotlyJSONEncoder that does it for you but haven't tested it...

If you rerender the app, Streamlit will recreate the plot from scratch with new data points. We can put the data in cache instead for our example. _component_func also has a key parameter which prevents the component from remounting from scratch when the Streamlit app rerenders :

import random
import plotly.express as px  
import streamlit as st
import streamlit.components.v1 as components

_component_func = components.declare_component(
    "my_component",
    url="http://localhost:3001",
)

def my_component(fig):
    # add key to _component_func so component is not destroyed/rebuilt on rerender
    return _component_func(spec=fig.to_json(), default=42, key="key")

# data in cache
@st.cache
def random_data():
    return random.sample(range(100), 50), random.sample(range(100), 50)

st.subheader("My Component")
x, y = random_data()
fig = px.scatter(x=x, y=y, title="My fancy plot")
v = my_component(fig)
st.write(v)

Step 4 - Selection in Plotly.js

How do we bind callback functions to lasso selection in plotly.js ? Our interest is in using Streamlit.setComponentValue() inside this callback to return the selected data points back to Streamlit.

The page shows the callback event should be listened on plotly_selected , which has its own prop onSelected in react-plotly. Define a handler to get back the information on selected points :

import React, { useEffect } from "react"
import { withStreamlitConnection, Streamlit, ComponentProps } from "./streamlit"
import Plot from "react-plotly.js"

function MyComponent(props: ComponentProps) {
  useEffect(() => Streamlit.setFrameHeight())

  const handleSelected = function (eventData: any) {
    Streamlit.setComponentValue(
      eventData.points.map((p: any) => {
        return { index: p.pointIndex, x: p.x, y: p.y }
      })
    )
  }

  const { data, layout, frames, config } = JSON.parse(props.args.spec)

  return (
    <Plot
      data={data}
      layout={layout}
      frames={frames}
      config={config}
      onSelected={handleSelected}
    />
  )
}

export default withStreamlitConnection(MyComponent)

You could process the returned points in your Python wrapper to only return index or coordinates, depending on your usecase :

import random
import plotly.express as px  
import streamlit as st
import streamlit.components.v1 as components

_component_func = components.declare_component(
    "my_component",
    url="http://localhost:3001",
)

def my_component(fig):
    points = _component_func(spec=fig.to_json(), default=[], key="key")
    return points

@st.cache
def random_data():
    return random.sample(range(100), 50), random.sample(range(100), 50)

st.subheader("My Component")
x, y = random_data()
fig = px.scatter(x=x, y=y, title="My fancy plot")
v = my_component(fig)
st.write(v)

You can now play with your plotly plot and get back selected data in Python :

Alt Text

There are still some problems here, for example there's no callback when unselecting the points using the onDeselect prop, I'll let that as an exercise :).

Conclusion

Hope this small tutorial will help you start your new interactive charting library in Streamlit. You can quickly create very specific custom charts if you don't want to bother with a generic wrapper implementation, don't hesitate to play with this ! The plotly.js page even shows a cross-filter in multiple plots in the same component, so you can do your data manipulation in Python and extra visualization in Plotly.js.

Discussion

pic
Editor guide