How to fix position of nodes in Dash Cytoscape while adding and removing nodes through callback?

1k Views Asked by At

I want to make a web app with an interactive network graph from the following example data source:

data = {'Source Node': ['a', 'a', 'b', 'b', 'c'],
     'Destination Node': ['b', 'c', 'c', 'd', 'd'],
     'Link': ['likes', 'likes', 'likes', 'likes', 'dislikes']
    }

The desired network graph has the following criteria:

  1. The nodes should be labelled with their name. (done)

  2. The edges should have different colors based on whether 'Link' is 'likes' (green) or 'dislikes' (red). (done through Networkx edge attributes)

  3. The nodes should be draggable by the user. (done - first tried Bokeh and then switched to Dash Cytoscape to make it possible)

  4. The user should be able to select elements in a checklist in order to view only these elements in the network graph. (done in a not so optimal way, see note in the end for more details)

  5. When adding or removing elements through the checklist, the existing nodes should stay in a fixed position, which is not the case by default because a new network graph is created at every callback.

So far, I made a draft app that meets criteria 1 through 4. However, I did not manage to keep the nodes in a fixed position while adding and removing nodes. I will provide more details after showing the code I have so far.

Below is a code that meets criteria 1 through 4.

# Python v 3.9.13
# Importing libraries
import pandas as pd # v 1.4.4
import numpy as np # v 1.21.5
import networkx as nx # v 2.8.8
import dash_cytoscape as cyto # v 0.2.0
from dash import Dash, html, dcc, Input, Output # v 2.7.0

# Data source
data = {'Source Node': ['a', 'a', 'b', 'b', 'c'],
     'Destination Node': ['b', 'c', 'c', 'd', 'd'],
     'Link': ['likes', 'likes', 'likes', 'likes', 'dislikes']
    }

# Creating a DataFrame using Pandas
df = pd.DataFrame(data)

# Defining lists for dcc.Checklist
list_all_ids = ['a', 'b', 'c', 'd']
default_list_selected_ids = ['a', 'c']


# Creating Dash app
app = Dash(__name__)

app.layout = html.Div(
    [
        dcc.Checklist(list_all_ids, default_list_selected_ids, id='checklist-elements'),
        cyto.Cytoscape(
            id='cytoscape_layout',
            elements=[],
            style={"width": "100%", "height": "400px"},
            layout={"name": "preset"},
            stylesheet=[
                {
                    'selector': 'node',
                    'style': {
                        'content': 'data(label)'
                    }
                },
                {
                    'selector': 'edge',
                    'style': {
                        'line-color': 'data(edge_color)'
                    }
                }
            ]
        )
    ]
)

@app.callback(
    Output('cytoscape_layout', 'elements'),
    Input('checklist-elements', 'value')
)
def update_elements(selected_ids):
    
    # Making a new df
    df_temp = df[df['Source Node'].isin(selected_ids)]
    df_selected = df_temp[df_temp['Destination Node'].isin(selected_ids)]
    # MAKING A NETWORK GRAPH WITH NETWORKX

    # Creating a network graph from the DataFrame
    G = nx.from_pandas_edgelist(df_selected, 'Source Node', 'Destination Node', edge_attr='Link')

    # Setting edge colors (green or red based on whether the nodes "like" or "dislike" each other)
    list_colors = ['red' if a['Link']=='dislikes' else 'green' for u, v, a in G.edges(data=True)]
    dict_colors = {k:v for (k,v) in zip(G.edges(), list_colors)}
    nx.set_edge_attributes(G, dict_colors, 'edge_color')

    pos = nx.spring_layout(G, seed = 100)

    
    # CONVERTING NETWORKX GRAPH TO CYTOSCAPE FORMAT - Big thanks to Robert Alexander and canbax
    # https://stackoverflow.com/questions/71428375/dash-cytoscape-from-python-networks-graph-not-honouring-the-nodes-coordinates
    
    # Initial conversion
    cy = nx.cytoscape_data(G)

    # Replacing dictionary key 'value' with 'label' in the nodes list of cy
    for n in cy["elements"]["nodes"]:
        for k, v in n.items():
            v["label"] = v.pop("value")

    # Adding positions of nodes from pos
    SCALING_FACTOR = 100
    for n, p in zip(cy["elements"]["nodes"], pos.values()):
        n["position"] = {"x": int(p[0] * SCALING_FACTOR), "y": int(p[1] * SCALING_FACTOR)}

    elements=cy["elements"]["nodes"] + cy["elements"]["edges"]
    
    return elements

if __name__ == "__main__":
    app.run_server()

As you can see if you run the code and play with the checklist, the nodes get a new position every time you click on a checkbox. This makes sense, since a new network graph is created every time. However, I want the nodes to stick to one position when you select or deselect other nodes. In order to achieve this, I tried two completely different approaches, which were both unsuccessful:

First approach:

  • Instead of using pos = nx.spring_layout(G, seed = 100), I created a dictionary of predefined positions (np.array() for the positions as it is the type found in pos = nx.spring_layout(G, seed = 100) (below). I placed this code before defining the app.
dict_fixed_pos = {'a': np.array([-0.5, -0.5]),
 'b': np.array([-0.5, 0.5]),
 'c': np.array([0.5, 0.5]),
 'd': np.array([0.5, -0.5])}
  • I replaced:
for n, p in zip(cy["elements"]["nodes"], pos.values()):
        n["position"] = {"x": int(p[0] * SCALING_FACTOR), "y": int(p[1] * SCALING_FACTOR)}

with:

for n in cy["elements"]["nodes"]:
        if n['data']['id'] in selected_ids:
            n['position'] = dict_fixed_pos[n['data']['id']]

With this, I was hoping that every time a network graph is created, each given node gets its specified own position from dict_fixed_pos. But instead, all nodes overlap on the same position. It looks like Cytoscape does not recognize the specified positions, but I can't figure out how to solve this. Any idea?

Second approach:

  • Instead of a callback that adds and removes elements, I tried to see if I could create a callback that changes the style of the nodes. So, the nodes would be invisible by default, and the selected nodes would be set as visible. As a test to see if this would be possible, I made the following changes in the callback function:
    • I removed the part that redefines the dataframe, in order to use the original df as such.
    • Inside the loop:
for n, p in zip(cy["elements"]["nodes"], pos.values()):
        n["position"] = {"x": int(p[0] * SCALING_FACTOR), "y": int(p[1] * SCALING_FACTOR)}

I added the following code in order to add the key 'selected' to each node. The value of 'selected' would be 'yes' or 'no' based on whether the corresponding 'id' is selected in dcc.Checklist:

if n['data']['id'] in selected_ids:
            n['selected'] = "yes"
        else:
            n['selected'] = "no"

And I added the following in the stylesheet of cyto.Cytoscape() (just changing the background color instead of the visibility as I haven't figured out yet how to change the visibility (just seen it is possible):

{
                    'selector': '[selected = "yes"]',
                    'style': {
                        'background-color': '#FF4136'
                    }

However, these changes resulted in all nodes becoming blue (instead of selected nodes becoming red).

So, do you think one of these two approaches could work after making some changes? Or do you have something completely different to suggest? Thank you in advance for your time.

Note on the 4th criterion, being able to add or remove nodes based on a selection in a checklist: initially, I wanted to create a df, network graph and list of elements once, at the start of the app; then, inside the callback function, based on the checklist value, the elements to display would be selected from the list of elements. Because this did not work, I decided to re-create the network graph from scratch in the callback function. However, this is far from ideal, so if anyone knows how to update only the list of elements in the callback function, help would be much appreciated for this as well.

0

There are 0 best solutions below