Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
celia-lm opened this issue Jul 21, 2023 · 0 comments
Open

Comments

@celia-lm
Copy link

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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant