plotly / dash-cytoscape

Interactive network visualization in Python and Dash, powered by Cytoscape.js
https://dash.plot.ly/cytoscape
MIT License
587 stars 120 forks source link

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

Open celia-lm opened 1 year ago

celia-lm commented 1 year ago

Description

https://github.com/plotly/dash-cytoscape/assets/101562106/7e767f5a-9edb-4cb5-b160-a4324b7ce2d3

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

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)