dash-core-components icon indicating copy to clipboard operation
dash-core-components copied to clipboard

partial update of dcc.Graph.figure with LiveGraph composite object

Open emmanuelle opened this issue 4 years ago • 6 comments

(probably this feature would not be implemented in dcc directly, but I'm posting this feature request here because this is the closest repo)

At the moment, when users want to update a small part of a plotly figure in a dcc.Graph in their app, they have two choices:

  • either have the whole dcc.Graph.figure as Output, which can result in passing large objects over the network and poor performance for traces with a lot of data
  • update only a dcc.Store, and update the dcc.Graph in a clientside callback. This is fast, but this means that users have to write Javascript, which is a shame for a coding pattern which is quite common.

We could instead provide an object which would have:

  • two component members: a Store (corresponding to a patch) and a Graph
  • an app member
  • and a clientside callback updating the Graph with the Store which would be attached to the app when creating the object

The user-facing API could look like

import dash_core_components as dcc
import dash_html_components as html
import dash
from dash.dependencies import Input, Output, State
import plotly.express as px
from dash_partial_update import LiveGraph
import numpy as np

app = dash.Dash(__name__)
fig = px.imshow(np.random.randint(255, size=(1000, 1000, 3)).astype(np.uint8))

# define app to attach clientside callbacks to it
graph, upstream_graph = LiveGraph(app=app)
graph.figure = fig

app.layout = html.Div([
    graph,
    upstream_graph,
    dcc.Dropdown(id='dropdown', values=['red', 'blue', 'yellow'])
    ])

@app.callback(
    Output(upstream_graph.id, 'patch'),
    [Input('dropdown', 'values')]
)
def update_dropdown(value):
    patch = dict(layout=dict(newshape=dict(color=value)))
    return patch

It would not be possible to target the dcc.Graph.figure as Output since it would already be used in an internal callback, but figure or selectedData could still be used as Input or State. Documentation should be written to illustrate these different cases.

Probably this component should be implemented as a standalone package as a first step, but I'm hoping that the dcc devs can chime in here and make suggestions :-).

This development will probably be part of the CZI project on image processing.

emmanuelle avatar Nov 10 '20 18:11 emmanuelle

Having given this some though, after a nudge from @alexcjohnson, I'm 99% sure that no new component is needed here, and instead just a (fairly complex) "deep merge" clientside callback and a dcc.Store.

nicolaskruchten avatar Nov 18 '20 14:11 nicolaskruchten

Here's a working version of the approach outlined above:

import dash
from jupyter_dash import JupyterDash
import dash_html_components as html
import dash_core_components as dcc
from dash.dependencies import Input, Output
import plotly.express as px
import plotly.graph_objects as go

figure = px.scatter(px.data.iris(), x="sepal_length", y="sepal_width")

app = JupyterDash(__name__)

app.layout = html.Div(children = [
  dcc.Store(id="figstore", data=figure),
  dcc.Store(id="patchstore", data={}),
  dcc.Store(id="patchstore2", data={}),
  dcc.Dropdown(id="color", value="red",
              options=[{"label":x, "value":x} for x in ["red", "blue"]]),
  dcc.Dropdown(id="linewidth", value=1,
              options=[{"label":x, "value":x} for x in [0,1,2,3]]),
  dcc.Graph(id="graph")
])

deep_merge = """
function batchAssign(patches) {
    function recursiveAssign(input, patch){
        var outputR = Object(input);
        for (var key in patch) {
            if(outputR[key] && typeof patch[key] == "object") {
                outputR[key] = recursiveAssign(outputR[key], patch[key])
            }
            else {
                outputR[key] = patch[key];
            }
        }
        return outputR;
    }

    return Array.prototype.reduce.call(arguments, recursiveAssign, {});
}
"""

app.clientside_callback(
    deep_merge,
    Output('graph', 'figure'),
    [Input('figstore', 'data'), 
     Input('patchstore', 'data'), 
     Input('patchstore2', 'data')]
)

@app.callback(Output('patchstore', 'data'),[Input('color', 'value')])
def cb(color):
    return go.Figure(go.Scatter(marker_line_color=color))

@app.callback(Output('patchstore2', 'data'),[Input('linewidth', 'value')])
def cb(width):
    return go.Figure(go.Scatter(marker_line_width=width))

app.run_server(mode="jupyterlab")

nicolaskruchten avatar Nov 18 '20 20:11 nicolaskruchten

@alexcjohnson mentions that actually, we could build a "combiner" component that uses wildcard props like patch-* :)

nicolaskruchten avatar Nov 20 '20 15:11 nicolaskruchten

(EDITED since I had introduced a bug in a previous attempt)

Really cool example! Here it is with a change of layout (annotations don't persist because of https://github.com/plotly/dash-core-components/issues/879 but this is a known issue)

from jupyter_dash import JupyterDash
import dash_html_components as html
import dash_core_components as dcc
from dash.dependencies import Input, Output
import plotly.express as px
import plotly.graph_objects as go

figure = px.scatter(px.data.iris(), x="sepal_length", y="sepal_width")

app = JupyterDash(__name__)

app.layout = html.Div(children = [
  dcc.Store(id="figstore", data=figure),
  dcc.Store(id="patchstore", data={}),
  dcc.Store(id="patchstore2", data={}),
  dcc.Store(id="patchstore3", data={}),
  dcc.Dropdown(id="color", value="red",
              options=[{"label":x, "value":x} for x in ["red", "blue"]]),
  dcc.Dropdown(id="linewidth", value=1,
              options=[{"label":x, "value":x} for x in [0,1,2,3]]),
  dcc.Dropdown(id="dragmode", value='zoom',
              options=[{"label":x, "value":x} for x in ['zoom', 'drawrect', 'drawcircle']]),  
  dcc.Graph(id="graph")
])

deep_merge = """
function batchAssign(patches) {
    function recursiveAssign(input, patch){
        var outputR = Object(input);
        for (var key in patch) {
            if(outputR[key] && typeof patch[key] == "object") {
                outputR[key] = recursiveAssign(outputR[key], patch[key])
            }
            else {
                outputR[key] = patch[key];
            }
        }
        return outputR;
    }

    return Array.prototype.reduce.call(arguments, recursiveAssign, {});
}
"""

app.clientside_callback(
    deep_merge,
    Output('graph', 'figure'),
    [Input('figstore', 'data'), 
     Input('patchstore', 'data'), 
     Input('patchstore2', 'data'),
     Input('patchstore3', 'data')]
)

@app.callback(Output('patchstore', 'data'),[Input('color', 'value')])
def cb(color):
    return go.Figure(go.Scatter(marker_line_color=color), layout_template='none')

@app.callback(Output('patchstore2', 'data'),[Input('linewidth', 'value')])
def cb(width):
    return go.Figure(go.Scatter(marker_line_width=width))

@app.callback(Output('patchstore3', 'data'),[Input('dragmode', 'value')])
def cb(dragmode):
    fig = go.Figure(go.Scatter())
    fig.update_layout(dragmode=dragmode, template='none')
    return fig

app.run_server(mode="inline")

emmanuelle avatar Nov 20 '20 16:11 emmanuelle

Since html.Div already has some wildcard props I implemented the idea using wildcard props, is this what you had in mind @nicolaskruchten ?

import dash
from jupyter_dash import JupyterDash
import dash_html_components as html
import dash_core_components as dcc
from dash.dependencies import Input, Output
import plotly.express as px
import plotly.graph_objects as go

figure = px.scatter(px.data.iris(), x="sepal_length", y="sepal_width")

app = JupyterDash(__name__)

app.layout = html.Div(children = [
  html.Div(id="patchstore", 
           **{'data-figure':figure, 'data-patch1':{}, 'data-patch2':{}, 'data-patch3':{}}),
  dcc.Dropdown(id="color", value="red",
              options=[{"label":x, "value":x} for x in ["red", "blue"]]),
  dcc.Dropdown(id="linewidth", value=1,
              options=[{"label":x, "value":x} for x in [0,1,2,3]]),
  dcc.Dropdown(id="dragmode", value='zoom',
              options=[{"label":x, "value":x} for x in ['zoom', 'drawrect', 'drawcircle']]),  
  dcc.Graph(id="graph")
])

deep_merge = """
function batchAssign(patches) {
    function recursiveAssign(input, patch){
        var outputR = Object(input);
        for (var key in patch) {
            if(outputR[key] && typeof patch[key] == "object") {
                outputR[key] = recursiveAssign(outputR[key], patch[key])
            }
            else {
                outputR[key] = patch[key];
            }
        }
        return outputR;
    }

    return Array.prototype.reduce.call(arguments, recursiveAssign, {});
}
"""

app.clientside_callback(
    deep_merge,
    Output('graph', 'figure'),
    [Input('patchstore', 'data-figure'), 
     Input('patchstore', 'data-patch1'),
     Input('patchstore', 'data-patch2'),
     Input('patchstore', 'data-patch3')]
)

@app.callback(Output('patchstore', 'data-patch1'),[Input('color', 'value')])
def cb(color):
    return go.Figure(go.Scatter(marker_line_color=color), layout_template='none')

@app.callback(Output('patchstore', 'data-patch2'),[Input('linewidth', 'value')])
def cb(width):
    return go.Figure(go.Scatter(marker_line_width=width))

@app.callback(Output('patchstore', 'data-patch3'),[Input('dragmode', 'value')])
def cb(dragmode):
    fig = go.Figure(go.Scatter())
    fig.update_layout(dragmode=dragmode, template='none')
    return fig

app.run_server(mode="inline")

emmanuelle avatar Dec 01 '20 17:12 emmanuelle

Yes exactly - only of course the combiner component would have the merge logic built in, so instead of the deep_merge callback you'd just have a clientside identity / pipe callback connecting the combiner's output prop (result?) to graph.figure

One consequence of this approach, which may or may not be desirable (and is NOT the case with the deep_merge callback approach @emmanuelle used above): none of these callbacks would be part of the same callback chain since the connection would be made internally by the component. This might mean that we'd fire the final result->graph.figure callback once per patch, if several patches were provided simultaneously by different callbacks, rather than waiting for them all to arrive, but I think we could avoid that by having the combiner look at its loading state and only do the combining when it's done loading. But also it would be possible to use this component to hide a circular callback dependency - leading to a potential infinite loop that would be hard for the renderer to detect. I'm sure some users would be happy to use this as a generic workaround when they need a circular dependency, but it would be dangerous in general.

alexcjohnson avatar Dec 02 '20 03:12 alexcjohnson