plotly / dash-cytoscape

Interactive network visualization in Python and Dash, powered by Cytoscape.js

Home Page:https://dash.plot.ly/cytoscape

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[bug?] Custom expand/collapse nodes

marc-vdm opened this issue · comments

Description

Hi Devs, awesome project! I'm working on code to show (parts of) graphs for our project Activity Browser.

What I want to make is a feature similar to #104, as this didn't yet exist, I thought I'd write it myself with a callback.
I had already written a Stackoverflow post, but after looking through the issues here, I'm getting a feeling there are bugs involved that are out of my control (e.g. #112), though I'm also experiencing the problem when using the Grid Layout.

Anyway, I will re-post most of the post from Stack Overflow as well:


I want to have an interactive graph, where I can click on a parent node, and the parent should 'collapse' (not show it's children anymore) or 'expand' (show it's children). This also means that the graph should replace the edges of it's children with edges to the parent.

As I'm still learning to use Dash-Cytoscape, I'm using a toy example. Each of the two graphs (with children and with children hidden) are separate variables. In my real project, I would dynamically re-generate the elements for the graph.

I can manage to write a callback function that happily feeds the graph a new set of elements when I click the right node (the special 'parent' nodes).

Note here, that I have both of the two example states showing what I want.

When I click the parent node, the elements should be replaced with the other state, making the graph show/hide the children of the parent.

See also the two states here:

intended1
intended2

What is going wrong:
What is instead happening is that the parent node is detached from the other nodes, it loses the edges connecting it to the rest of the graph.

See also the two broken states here:

broken1
broken2

What I tried:
So far, I loaded each of the states separately, this shows exactly what I want
I tried to make an MRE with fewer nodes and edges, but when I run that, there is no problem. I cannot see how my MRE differs from the 'real' code where it matters.

So to be clear: What I want is to be able to switch between the two top images, but this ends up breaking the network in some way I don't understand. If you want to see the other working graph in the main code, replace current_state = m_start_elements with current_state = start_elements.

Steps/Code to Reproduce

For reference, here's my MRE:

import dash
import dash_cytoscape as cyto
from dash import html
from dash.dependencies import Input, Output, State

import threading

n1 = {'data': {'id': 'n1', 
               'label': 'n1',
               'parent': 'p1'}}
n2 = {'data': {'id': 'n2', 
               'label': 'n2',
               'parent': 'p1'}}
n3 = {'data': {'id': 'n3', 
               'label': 'n3'}}

p1 = {'data': {'id': 'p1', 
               'label': 'p1'},
      'classes': 'parent_class'}
p2 = {'data': {'id': 'p2', 
               'label': 'p2'},
      'classes': 'parent_class'}

e1 = {'data': {'id': 'e1',
               'source': 'n1', 
               'target': 'n2', 
               'label': 'e1'}}
e2 = {'data': {'id': 'e2',
               'source': 'n2', 
               'target': 'n3', 
               'label': 'e2'}}

e3 = {'data': {'id': 'e3',
               'source': 'p2', 
               'target': 'n3', 
               'label': 'e3'}}

first_elems = [n1, n2, n3, p1, e1, e2]
other_elems = [n3, p2, e3]
current_state = first_elems

parent_names = ['p1', 'p2']


layout={'name': 'grid'}

stylesheet = [
    # Group selectors
    {'selector': 'node',
     'style': {'shape': 'round-rectangle',
               'content': 'data(label)'}},
    
    {'selector': 'edge',
     'style': {'content': 'data(label)',
               'curve-style': 'unbundled-bezier',
               'width': 1,
               'target-arrow-shape': 'triangle'}},

    # Class selectors
    {'selector': '.parent_class',
     'style': {'background-color': 'lightgreen',
               'border-color': 'black'}}]

app = dash.Dash(__name__)

app.layout = html.Div([
    cyto.Cytoscape(
        id='test-cyto',
        layout=layout,
        style={'width': '100%', 'height': '600px'},
        stylesheet=stylesheet,
        elements=current_state,
        autounselectify=True
    )
])


@app.callback(Output('test-cyto', 'elements'),
              Input('test-cyto', 'tapNodeData'),
              State('test-cyto', 'elements'))
def actionTapNodeData(tap_data, elements):

    if tap_data == None:
        # no node was clicked
        current_state = elements
    elif tap_data['id'] not in parent_names:
        # no module was clicked
        current_state = elements
    else:
        # a module was clicked
        if elements == first_elems:
            current_state = other_elems
        else:
            current_state = first_elems
    
    return current_state


def run_dash():
    app.run_server(debug=False)

if __name__ == '__main__':
    
    server = threading.Thread(target=run_dash, daemon=True)
    server.start()

And here's my actual code:

import dash
import dash_cytoscape as cyto
from dash import html
from dash.dependencies import Input, Output, State

import threading
from math import pi

cyto.load_extra_layouts()


P1 = {'data': {'id': 'p1', 
               'label': 'Use Bulb'},
      'grabbable': False,
      'classes': 'process'}
P2 = {'data': {'id': 'p2', 
                'label': 'Prod. Bulb'}, 
      'grabbable': False,
      'classes': 'process'}
P3 = {'data': {'id': 'p3', 
               'label': 'Prod. Elec',
               'parent': 'm1'}, 
      'grabbable': False,
      'classes': 'process'}
P4 = {'data': {'id': 'p4', 
               'label': 'Incineration'}, 
      'grabbable': False,
      'classes': 'process'}
P5 = {'data': {'id': 'p5', 
               'label': 'Prod. Glass'}, 
      'grabbable': False,
      'classes': 'process'}
P6 = {'data': {'id': 'p6', 
               'label': 'Prod. Copper'}, 
      'grabbable': False,
      'classes': 'process'}
P7 = {'data': {'id': 'p7', 
               'label': 'Prod. Fuel',
               'parent': 'm1'}, 
      'grabbable': False,
      'classes': 'process'}

nodes = [P1, P2, P3, P4, P5, P6, P7]
part_of_nodes = [P1, P2, P4, P5, P6]

E1 = {'data': {'id': 'e1',
               'source': 'p7', 
               'target': 'p3', 
               'label': 'Fuel'}}
E2 = {'data': {'id': 'e2',
               'source': 'p3', 
               'target': 'p6', 
                'label': 'Elec.'}}
E3 = {'data': {'id': 'e3',
               'source': 'p3', 
               'target': 'p2', 
               'label': 'Elec.'}}
E4 = {'data': {'id': 'e4',
               'source': 'p3', 
               'target': 'p5', 
               'label': 'Elec.'}}
E5 = {'data': {'id': 'e5',
               'source': 'p3', 
               'target': 'p1', 
               'label': 'Elec.'}}
E6 = {'data': {'id': 'e6',
               'source': 'p6', 
               'target': 'p2', 
               'label': 'Copper'}}
E7 = {'data': {'id': 'e7',
               'source': 'p5', 
               'target': 'p2', 
               'label': 'Glass'}}
E8 = {'data': {'id': 'e8',
               'source': 'p2', 
               'target': 'p1', 
               'label': 'Bulb'}}
E9 = {'data': {'id': 'e9',
               'source': 'p4', 
               'target': 'p1', 
               'label': 'Waste Treatment'}}

E22 = {'data': {'id': 'e2',
                'source': 'm2', 
                'target': 'p6', 
                'label': 'Elec.'}}
E33 = {'data': {'id': 'e3',
                'source': 'm2', 
                'target': 'p2', 
                'label': 'Elec.'}}
E44 = {'data': {'id': 'e4',
                'source': 'm2', 
                'target': 'p5', 
                'label': 'Elec.'}}
E55 = {'data': {'id': 'e5',
                'source': 'm2', 
                'target': 'p1', 
                'label': 'Elec.'}}

edges = [E1, E2, E3, E4, E5, E6, E7, E8, E9]
part_of_edges = [E22, E33, E44, E55, E6, E7, E8, E9]

M1 = {'data': {'id': 'm1', 
                'label': 'Electricity prod'}, 
      'grabbable': False,
      'classes': 'module'}

M2 = {'data': {'id': 'm2', 
                'label': 'Electricity prod'}, 
      'grabbable': False,
      'classes': 'empty_mod'}


parents = [M1]
parent_names = [n['data']['id'] for n in parents + [M2]]

start_elements = nodes + parents + edges
m_start_elements = part_of_nodes + [M2] + part_of_edges
current_state = m_start_elements

layout={'name': 'dagre',
        'spacingFactor': 1.15}

# for more info on style: https://js.cytoscape.org/#style
#TODO maange to add location label in different color
stylesheet = [
    # Group selectors
    {'selector': 'node',
     'style': {'content': 'data(label)',
               'font-size': 8}},
    
    {'selector': 'edge',
     'style': {'content': 'data(label)',
               'curve-style': 'unbundled-bezier',
               'width': 1,
               'line-color': 'lightblue',
               'target-arrow-color': 'lightblue',
               'target-arrow-shape': 'triangle',
               'text-margin-x': 0,
               'font-size': 8}},

    # Class selectors
    {'selector': '.process',
     'style': {'shape': 'round-rectangle',
               'background-color': 'white',
               'border-color': 'black',
               'border-width': 1,
               'text-valign': 'center',
               'height': 40,
               'width': 75}},
    {'selector': '.module',
     'style': {'shape': 'rectangle',
               'background-color': 'lightgrey',
               'border-color': 'black',
               'border-width': 1,
               'text-valign': 'center',
               'text-halign': 'left',
               'text-margin-x': -2,
               'text-rotation': (pi*2*0.75)}},
    {'selector': '.empty_mod',
     'style': {'shape': 'rectangle',
               'background-color': 'lightgrey',
               'border-color': 'black',
               'border-width': 1,
               'text-valign': 'center',
               'height': 40,
               'width': 75}}]

app = dash.Dash(__name__)

app.layout = html.Div([
    cyto.Cytoscape(
        id='test-cyto',
        # dagre layout: https://github.com/cytoscape/cytoscape.js-dagre
        layout=layout,
        style={'width': '100%', 'height': '600px'},
        stylesheet=stylesheet,
        elements=current_state,
        autounselectify=True
    ),
    html.Div(id='hidden-div', style={'display':'none'})
])


# interaction from: https://dash.plotly.com/cytoscape/events#simple-callback-construction
@app.callback(Output('test-cyto', 'elements'),
              Input('test-cyto', 'tapNodeData'),
              State('test-cyto', 'elements'))
def actionTapNodeData(tap_data, elements):

    if tap_data == None:
        # no node was clicked
        current_state = elements
    elif tap_data['id'] not in parent_names:
        # no module was clicked
        current_state = elements
    else:
        # a module was clicked
        if elements == start_elements:
            current_state = m_start_elements
        else:
            current_state = start_elements
    
    return current_state


def run_dash():
    app.run_server(debug=False)

if __name__ == '__main__':
    
    server = threading.Thread(target=run_dash, daemon=True)
    server.start()

Expected Results

See first two images above

Actual Results

See last two images

Versions

I'm using the Conda sources for the versions:

Dash 2.2.0
Dash Core Components 2.0.2
Dash HTML Components 2.2.0
Dash HTML Components 0.2.0

Oh, hi Mark,

it seems that the problem is that you are sharing the name of the edges E2 and E22, etc. Changing them makes it work as expected:

E22 = {'data': {'id': 'e22',
                'source': 'm2', 
                'target': 'p6', 
                'label': 'Elec.'}}
E33 = {'data': {'id': 'e33',
                'source': 'm2', 
                'target': 'p2', 
                'label': 'Elec.'}}
E44 = {'data': {'id': 'e44',
                'source': 'm2', 
                'target': 'p5', 
                'label': 'Elec.'}}
E55 = {'data': {'id': 'e55',
                'source': 'm2', 
                'target': 'p1', 
                'label': 'Elec.'}}

Unfortunately, I have no insights of why exactly it fails, and why it doesn't return an error