Creating an overview plot of another one using to_image()

So that a user does not get lost in my huge plot, I want to create a smaller one that displays a frame of the view in my original plot. To save memory, I want to achieve this by creating an image of the original plot and use this as background for the overview plot. This works quite well except for the fact, that coordinates of my view window frame and the actual frame in my original plot are a bit shifted due to the fact, that .to_image() also puts the plot’s heatmap, legend and axis into the output image. I’m searching for a way, to exclude these but there does not seem to be a direct option. Another way would be to fiddle with xref, yref, x, y, sizex, sizey but so far I could not find a way to make it work in such a way that the overview and the original plot align nicely.
I’ve created a minimal working example and hope that someone might look into the code and is keen to play a bit with it.

from dash import Dash, html, dcc, callback
from dash.dependencies import Output, Input
from dash.exceptions import PreventUpdate
import dash_bootstrap_components as dbc
import plotly.express as px
import plotly.graph_objects as go
import numpy as np
from base64 import b64encode


app = Dash()

app.layout = html.Div([
    dcc.Graph(id='fig-main'),
    dcc.Graph(id='fig-overview'),
    dcc.Store(id='fig-main-props'),
    dbc.Button(id='btn-new-data', children='new data')
])


@callback(
    Output('fig-main', 'figure'),
    Output('fig-main-props', 'data'),
    Input('btn-new-data', 'n_clicks'),
    prevent_inital_call=True
)
def _create_new_figure(n):
    if n:
        # create figure
        x = np.random.sample(150)
        y = np.random.sample(150)
        colors = np.random.randint(0, 3, 150)
        fig = px.scatter(x=x, y=y, color=colors)
        # create img from figure
        img_bytes = fig.to_image(format='png', scale=1)
        encoding = b64encode(img_bytes).decode()
        # get x/y ranges from figure
        xrange = fig.full_figure_for_development().layout.xaxis.range
        yrange = fig.full_figure_for_development().layout.yaxis.range
        return fig, {'img': encoding, 'x': xrange, 'y': yrange}

    raise PreventUpdate


@callback(
    Output('fig-overview', 'figure'),
    Input('fig-main', 'relayoutData'),
    Input('fig-main-props', 'data'),
    prevent_inital_call=True
)
def _show_overview_figure(main_figure_relayout, main_figure_props):
    if main_figure_relayout and main_figure_props:
        # get img and ranges from main figure
        main_figure_img = main_figure_props['img']
        main_figure_x_range = main_figure_props['x']
        main_figure_y_range = main_figure_props['y']

        # get zoom coordinates from main figure
        if 'xaxis.range[0]' in main_figure_relayout and 'yaxis.range[0]' in main_figure_relayout:
            xmin_view, xmax_view = main_figure_relayout['xaxis.range[0]'], main_figure_relayout['xaxis.range[1]']
            ymin_view, ymax_view = main_figure_relayout['yaxis.range[0]'], main_figure_relayout['yaxis.range[1]']
        else:
            xmin_view, xmax_view = np.inf, -np.inf
            ymin_view, ymax_view = np.inf, -np.inf

        # create overview figure
        fig_overview = go.Figure()
        # paste background img from main figure
        fig_overview.add_layout_image(dict(source='data:image/png;base64,{}'.format(main_figure_img),
                                           xref='x',
                                           yref='y',
                                           x=main_figure_x_range[0],
                                           y=main_figure_y_range[1],
                                           sizex=abs(main_figure_x_range[0])+main_figure_x_range[1],
                                           sizey=abs(main_figure_y_range[0])+main_figure_y_range[1],
                                           sizing='stretch',
                                           layer='below')
                                      )
        fig_overview.update_layout(template='plotly_white')
        # set its boundaries
        fig_overview.update_xaxes(visible=False, fixedrange=True, range=main_figure_x_range)
        fig_overview.update_yaxes(visible=False, fixedrange=True, range=main_figure_y_range)
        # set some props
        fig_overview.update_layout(showlegend=False)
        fig_overview.update_layout(margin=dict(l=0, r=0, t=0, b=0))
        # and draw overview window
        fig_overview.add_shape(name='1', type='rect',
                               x0=xmin_view, x1=xmax_view, y0=ymin_view, y1=ymax_view,
                               line=dict(color='black', width=3))
        return fig_overview

    raise PreventUpdate


if __name__ == '__main__':
    app.run_server(debug=True)

#fig-main {
    height: 40vh;
}

#fig-overview {
    width: 30vw;
}

Hi, out of curiosity, how did your check if your approach actually uses less memory? What would be the alternative to your current approach?

The alternative would be to create a figure from graph objects.
Something like:

fig_overview = go.Figure(main_figure)

where main_figure comes from

Input('fig-main', 'figure'),

So instead of an empty figure with the background image of the original, here I would create a copy figure from the original.
When fig-main is considerably huge, I’m more or less creating it twice with this approach. I thought that the byte representation of an image of a figure is less costly to ship around in a dcc.Store container then this. However I’m not too sure and would be very open to different ideas on how to achieve what I’m looking for.

Right now (as I undestand) you are adding an annotation to an existing figure. You use the axis ranges for the size of the annotation. That said, there are a few things that come into my mind:

  • if you are worried about data transfer between client and server you could consider using clientside callbacks.
  • why don’t you just restirct the axis ranges?

Hi @luggie,

as I am trying to get better in JS, I created a possible solution for your answer using clientside callbacks. I’m sure there is room for improvement but it does the job. It even moves the annotation if the user moves the original figure (pan).

One known issue is that if the user decides to pan in the overwie figure, something gets mixed up and the size of the annotation is not correct anymore.

zoom

You can find the code here:

mred cscb

2 Likes

Thank you so much! Guess it make sense to use js when working in web dev :smiley:

One known issue is that if the user decides to pan in the overwie figure, something gets mixed up and the size of the annotation is not correct anymore.

This can be easily solved with:

dcc.Graph(id='fig-overview', config={'displayModeBar': False})

@luggie, yes it makes sense using JS, but it’s hard to find the time to learn :wink:

EDIT:

I just wanted to mention, that my solution might not be bullet proof.

Make sure you disable also the zooming by scrolling by adding this to the dcc.Graph()

config={‘scrollZoom’:False}

I found a better way:

config={'staticPlot': True}

1 Like

My current implementation in js:

   set_fig_overview: function(re_layout, initial_range, fig) {
       // create new figure
       let newFig = JSON.parse(JSON.stringify(fig));
       // if we are not in the welcome screen
       if (newFig.layout.xaxis) {
           // get coordinates for display frame
           let x;
           let y;
           if (re_layout == null) {
               x = initial_range.x;
               y = initial_range.y;
           } else {
               if ("xaxis.range[0]" in re_layout) {
                   x = [re_layout["xaxis.range[0]"], re_layout["xaxis.range[1]"]];
                   y = [re_layout["yaxis.range[0]"], re_layout["yaxis.range[1]"]];
               } else {
                   x = initial_range.x;
                   y = initial_range.y;
               }
           }
           // set the axis ranges to the initial values (zoomed in)
           newFig.layout.xaxis.range = initial_range.x.map(function(n){return n*0.6;});
           newFig.layout.yaxis.range = initial_range.y.map(function(n){return n*0.6;});
           // hide legend, axis and add margin
           newFig.layout.legend.visible = false
           newFig.layout.xaxis.visible = false
           newFig.layout.yaxis.visible = false
           newFig['layout']['showlegend'] = false;
           newFig['layout']['margin'] = {l: 0, r: 0, b: 0, t: 0};
           // add rectangular shape for display frame
           let display_frame = {
               'editable': true,
               'xref': 'x',
               'yref': 'y',
               'layer': 'above',
               'opacity': 1,
               'line': {
                   'color': '#123456',
                   'width': 3,
                   'dash': 'solid'
               },
               'fillcolor': 'rgba(1, 0, 0, 0.1)',
               'fillrule': 'evenodd',
               'type': 'rect',
               'x0': x[0],
               'y0': y[0],
               'x1': x[1],
               'y1': y[1]
           }
           if (newFig.layout.shapes) {
               newFig['layout']['shapes'].push(display_frame);
           } else {
               newFig['layout']['shapes'] = [display_frame];
           }
           //import {Plotly} from "./plotly.js";

           return newFig;
       } else {
           return window.dash_clientside.no_update;
       }
    }
app.clientside_callback(
    ClientsideFunction(namespace='clientside', function_name='set_fig_overview'),
    Output('fig-overview', 'figure'),
    Input('fig', 'relayoutData'),
    Input('initial-figure-range', 'data'),
    State('fig', 'figure'),
    prevent_initial_call=True
)

...

 @callback(
            Output('fig', 'figure'),
            Output('initial-figure-range', 'data'),
            Input(...),
)
def callback_create_figure(...):           
   fig = create_figure(...)
    xrange = fig.full_figure_for_development().layout.xaxis.range
    yrange = fig.full_figure_for_development().layout.yaxis.range

   return fig, {'x': xrange, 'y': yrange}