Skip to content

[BUG] Elements positions don't match specification in preset layout #192

Open
@celia-lm

Description

@celia-lm

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 and layout 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

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions