Open
Description
Description
Screen.Recording.2023-07-21.at.16.16.43.mov
- We have a Dash App with two main callbacks and a container where we can see 3 different cytoscape graphs - only one at a time, the difference between the three graphs is the number of nodes (20, 30, 40)
- Callback 1 saves the current position of the nodes and links (it saves the whole
elements
property) in a dcc.Store. That dcc.Store data property is a dict with one item for each cytoscape graph, so positions for each of the three graphs can be saved at the same time (saving positions of graph 2 doesn't overwrite saved positions for graph 1)
@app.callback(
Output('store', 'data'),
Input('save1, 'n_clicks'),
State('cyto', 'elements'),
State('number', 'value'),
State('store', 'data'),
)
def savemapstate(clicks,elements, number, store):
if clicks is None:
raise PreventUpdate
else:
store[number] = elements
return store
- Callback 2 modifies the
elements
andlayout
properties of a cytoscape graph based on either (1) default value defined as a global variable, if the user clicks 'Reset' (2) saved value
@app.callback(
Output('cyto', 'elements'),
Output('cyto', 'layout'),
Input('update', 'n_clicks'),
Input('reset', 'n_clicks'),
State('number', 'value'),
State('store', 'data'),
prevent_initial_call=True
)
def updatemapstate(click1, click2, number, store):
triggered_id = callback_context.triggered[0]['prop_id'].split('.')[0]
if click1 is None and click2 is None:
raise PreventUpdate
else:
if "update" in triggered_id:
elements = store[number]
layout = {
'name': 'preset',
'fit': True,
}
elif "reset" in triggered_id:
elements = initial_data[number] # initial_data is a global variable (dict)
layout = {
'name': 'concentric',
'fit': True,
'minNodeSpacing': 100,
'avoidOverlap': True,
'startAngle': 50,
}
return elements, layout
- When a user tries to update the
- Sometimes it only happens when the number of nodes is >20; if the panning has been changed (the user has "dragged" the whole graph before saving the positions) the issue is worse (more nodes are displaced) and it can happen with <20 nodes.
- Related issue: Graph nodes flocking to single point #175
- With this app, we can also reproduce this issue: Update cycle broken after callback #159
Code to Reproduce
Env: Python 3.8.12
requirements.txt:
dash-design-kit==1.6.8
dash==2.3.1 # it happens with 2.10.2 too
dash_cytoscape==0.2.0 # it happens with 0.3.0 too
pandas
gunicorn==20.0.4
pandas>=1.1.5
flask==2.2.5
Complete app code:
from dash import Dash, html, dcc, Input, Output, State, callback_context
from dash.exceptions import PreventUpdate
import dash_cytoscape as cyto
import json
import random
app = Dash(__name__)
data1 = [
{'data': {'id': f'{i}', 'label': f'Node {i}'}, 'position': {'x': 100*random.uniform(0,2), 'y': 100*random.uniform(0,2)}}
for i in range(20)] + [
{'data': {'id':f'link1-{i}','source': '1', 'target': f'{i}'}}
for i in range(2,20)
]
data2 = [
{'data': {'id': f'{i}', 'label': f'Node {i}'}, 'position': {'x': 100*random.uniform(0,2), 'y': 100*random.uniform(0,2)}}
for i in range(30)] + [
{'data': {'id':f'link1-{i}','source': '1', 'target': f'{i}'}}
for i in range(2,30)
]
data3 = [
{'data': {'id': f'{i}', 'label': f'Node {i}'}, 'position': {'x': 100*random.uniform(0,2), 'y': 100*random.uniform(0,2)}}
for i in range(40)] + [
{'data': {'id':f'link1-{i}','source': '1', 'target': f'{i}'}}
for i in range(2,40)
]
initial_data = {'1':data1, '2':data2, '3':data3}
app.layout = html.Div([
html.Div([
dcc.Dropdown(['1','2','3'], '1', id='number'),
html.Button(id='save', children='Save'),
html.Button(id='update', children='Update'),
html.Button(id='reset', children='Reset'),
dcc.Store(id='store', data={'1':[], '2':[], '3':[]})
],
style = {'width':'300px'}),
html.Div(
children=cyto.Cytoscape(
id='cyto',
layout={'name': 'concentric',},
panningEnabled=True,
zoom=0.5,
zoomingEnabled=True,
elements=[],
)
)
])
@app.callback(
Output('store', 'data'),
Input('save', 'n_clicks'),
State('cyto', 'elements'),
State('number', 'value'),
State('store', 'data'),
)
def savemapstate(clicks,elements, number, store):
if clicks is None:
raise PreventUpdate
else:
store[number] = elements
return store
@app.callback(
Output('cyto', 'elements'),
Output('cyto', 'layout'),
Input('update', 'n_clicks'),
Input('reset', 'n_clicks'),
State('number', 'value'),
State('store', 'data'),
prevent_initial_call=True
)
def updatemapstate(click1, click2, number, store):
triggered_id = callback_context.triggered[0]['prop_id'].split('.')[0]
if click1 is None and click2 is None:
raise PreventUpdate
else:
if "update" in triggered_id:
elements = store[number]
layout = {
'name': 'preset',
'fit': True,
}
elif "reset" in triggered_id:
elements = initial_data[number]
layout = {
'name': 'concentric',
'fit': True,
'minNodeSpacing': 100,
'avoidOverlap': True,
'startAngle': 50,
}
return elements, layout
if __name__ == '__main__':
app.run_server(debug=True)
Workaround
- Returning in the callback a new cytoscape graph with a new id. If we return a cytoscape with the same id, the issue still happens.
- To keep saving the
elements
(=use them as an Input in a callback) we can use pattern-matching callbacks
from dash import Dash, html, dcc, Input, Output, State, callback_context, ALL
from dash.exceptions import PreventUpdate
import dash_cytoscape as cyto
import json
import random
app = Dash(__name__)
data1 = [
{'data': {'id': f'{i}', 'label': f'Node {i}'}, 'position': {'x': 100*random.uniform(0,2), 'y': 100*random.uniform(0,2)}}
for i in range(20)] + [
{'data': {'id':f'link1-{i}','source': '1', 'target': f'{i}'}}
for i in range(2,20)
]
data2 = [
{'data': {'id': f'{i}', 'label': f'Node {i}'}, 'position': {'x': 100*random.uniform(0,2), 'y': 100*random.uniform(0,2)}}
for i in range(30)] + [
{'data': {'id':f'link1-{i}','source': '1', 'target': f'{i}'}}
for i in range(2,30)
]
data3 = [
{'data': {'id': f'{i}', 'label': f'Node {i}'}, 'position': {'x': 100*random.uniform(0,2), 'y': 100*random.uniform(0,2)}}
for i in range(40)] + [
{'data': {'id':f'link1-{i}','source': '1', 'target': f'{i}'}}
for i in range(2,40)
]
initial_data = {'1':data1, '2':data2, '3':data3}
app.layout = html.Div([
html.Div([
dcc.Dropdown(['1','2','3'], '1', id='number'),
html.Button(id='save', children='Save'),
html.Button(id='update', children='Update'),
html.Button(id='reset', children='Reset'),
dcc.Store(id='store', data={'1':[], '2':[], '3':[]})
],
style = {'width':'300px'}),
html.Div(
id='cyto-card',
children=[],
),
])
@app.callback(
Output('store', 'data'),
Input('save', 'n_clicks'),
State({'type':'cyto', 'index':ALL}, 'elements'),
State('number', 'value'),
State('store', 'data'),
)
def savemapsatae(clicks,elements, number, store):
if clicks is None:
raise PreventUpdate
else:
store[number] = elements[0]
return store
@app.callback(
Output('cyto-card', 'children'),
Input('update', 'n_clicks'),
Input('reset', 'n_clicks'),
State('number', 'value'),
State('store', 'data'),
prevent_initial_call=True
)
def updatemapsatae(click1, click2, number, store):
triggered_id = callback_context.triggered[0]['prop_id'].split('.')[0]
if click1 is None and click2 is None:
raise PreventUpdate
else:
if "update" in triggered_id:
elements = store[number]
layout = {
'name': 'preset',
}
elif "reset" in triggered_id:
elements = initial_data[number]
layout = {
'name': 'concentric',
'fit': True,
'minNodeSpacing': 100,
'avoidOverlap': True,
'startAngle': 50,
}
n = sum(filter(None, [click1, click2]))
cyto_return = cyto.Cytoscape(
id={'type':'cyto', 'index':n},
layout=layout,
panningEnabled=True,
zoom=0.5,
zoomingEnabled=True,
elements=elements,
)
return cyto_return
if __name__ == '__main__':
app.run_server(debug=True)
Metadata
Metadata
Assignees
Labels
No labels