[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:
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:
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