DEV Community

Fanilo Andrianasolo
Fanilo Andrianasolo

Posted on • Updated on

Streamlit components - Scatterplot with selection using Plotly.js

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":"", ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.

Resources

Top comments (2)

Collapse
 
biocomsoftware profile image
BioComSoftware

Thanks for the tutorial. The code in the repo works well.

Question; Is there a way to make the Plotly chart persistent (save the state?)

Currently, everytime you select data on the chart (and it is passed back to python) the Streamlit page is run again, re-creating the chart. So - if for example you had zoomed into a particular area on the chart, or drawn lines on the chart - all of that disappears after selecting any data**(the data itself is cached, but interactive changes to the chart disappear).

I found this but, two problems.

  1. My understanding is that classes are now deprecated.
  2. I don't know enough React yet to implement your code as a stateful class.

Anyway you can update your code to remember the state of the char?

Collapse
 
andfanilo profile image
Fanilo Andrianasolo • Edited

Hi! Hope you are doing well :)

  • you can stick to class-based React, even if it's deprecated I don't think there are plans to remove it immediately
  • it's really odd that the component is recreated if you have a key argument on your my_component call, can you ensure it's there?
  • If you're using the functional version in this tutorial, then you'll need to use the useState hook to preserve data reactjs.org/docs/hooks-state.html. You can put this above the useEffect line, and do something like (I have not tested this, there may be some errors to the gist! Plus, if you're using Typescript you may have to use the Plotly specific types on the useState specification for the compiler to not cry)
const [state, setState] = useState({data: [], layout: {}, frames: [], config: {}});
...
    return (
      <Plot
        data={state.data}
        layout={state.layout}
        frames={state.frames}
        config={state.config}
        onInitialized={(s) => setState(s)}
        onUpdate={(s) => setState(s)}
      />
    );
Enter fullscreen mode Exit fullscreen mode

Hope this helps, good luck
Fanilo