From 9e1b096f29acb947364e7f2bae3612f11b7ba6a3 Mon Sep 17 00:00:00 2001 From: Ashton-Sidhu Date: Fri, 27 Mar 2020 21:58:47 -0400 Subject: [PATCH] added MG, DG and edge properties --- README.md | 56 +++++++++- docs/images/dg.png | Bin 0 -> 28603 bytes docs/images/mg.png | Bin 0 -> 16970 bytes examples/DiGraphs.ipynb | 80 ++++++++++++++ examples/Multigraphs.ipynb | 54 ++++++++++ examples/tutorial.ipynb | 18 +--- igviz/igviz.py | 208 ++++++++++++++++++++++++------------- igviz/tests/test_plot.py | 44 +++++++- setup.py | 2 +- 9 files changed, 368 insertions(+), 94 deletions(-) create mode 100644 docs/images/dg.png create mode 100644 docs/images/mg.png create mode 100644 examples/DiGraphs.ipynb create mode 100644 examples/Multigraphs.ipynb diff --git a/README.md b/README.md index 50a7c00..5b6aaad 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Interactive Graph Visualization (igviz) is a library to help visualize graphs in ## Usage +Example notebooks can be found [here](https://github.com/Ashton-Sidhu/plotly-graph/tree/master/examples). + ### Basic ```python @@ -43,7 +45,7 @@ The default plot colors and sizes the nodes by the Degree but it is configurable ig.plot( G, # Your graph title="My Graph", - sizing_method="static", # Makes node sizes the same + size_method="static", # Makes node sizes the same color_method="##ffcccb", # Makes all the node colours black, node_text=["prop"], # Adds the 'prop' property to the hover text of the node annotation_text="Visualization made by igviz & plotly.", # Adds a text annotation to the graph @@ -55,7 +57,7 @@ ig.plot( ig.plot( G, title="My Graph", - sizing_method="prop", # Makes node sizes the size of the "prop" property + size_method="prop", # Makes node sizes the size of the "prop" property color_method="prop", # Colors the nodes based off the "prop" property and a color scale, node_text=["prop"], # Adds the 'prop' property to the hover text of the node ) @@ -65,7 +67,7 @@ ig.plot( #### How to add your own custom sizing method and colour method -To add your own custom sizing and color method, just pass a list to the `sizing_method` and `color_method`. +To add your own custom sizing and color method, just pass a list to the `size_method` and `color_method`. ```python color_list = [] @@ -80,7 +82,7 @@ for node in G.nodes(): ig.plot( G, title="My Graph", - sizing_method=sizing_list, # Makes node sizes the size of the "prop" property + size_method=sizing_list, # Makes node sizes the size of the "prop" property color_method=color_list, # Colors the nodes based off the "prop" property and a color scale, node_text=["prop"], # Adds the 'prop' property to the hover text of the node ) @@ -118,6 +120,52 @@ ig.plot( ) ``` +#### Directed & Multi Graphs + +Igviz also plots Directed and Multigraphs with no configuration chages. For Directed Graphs the arrows are shown from node to node. For Multi Graphs only one edge is shown and it is recommended to set `show_edgetext=True` to display the weights of all edges between 2 Multi Graph nodes. + +Note: `show_edgetext=True` also works for vanilla and Directed Graphs. + +##### Directed Graph + +```python +def createDiGraph(): + # Create a directed graph (digraph) object; i.e., a graph in which the edges + # have a direction associated with them. + G = nx.DiGraph() + + # Add nodes: + nodes = ['A', 'B', 'C', 'D', 'E'] + G.add_nodes_from(nodes) + + # Add edges or links between the nodes: + edges = [('A','B'), ('B','C'), ('B', 'D'), ('D', 'E')] + G.add_edges_from(edges) + return G + +DG = createDiGraph() + +ig.plot(DG, size_method="static") +``` +![](docs/images/dg.png) + +##### Multi Graph + +```python +MG = nx.MultiGraph() +MG.add_weighted_edges_from([(1, 2, 0.5), (1, 2, 0.75), (2, 3, 0.5)]) + +ig.plot( + MG, + layout="spring", + size_method="static", + show_edgetext=True, + colorscale="Rainbow" +) +``` + +![](docs/images/mg.png) + ## Installation `pip install igviz` diff --git a/docs/images/dg.png b/docs/images/dg.png new file mode 100644 index 0000000000000000000000000000000000000000..e62656831f9ef98ceed9b358ff9a3422a3f7ae48 GIT binary patch literal 28603 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 Q$b@OBpFWX&%<}jD1*PW#5dZ)H literal 0 HcmV?d00001 diff --git a/examples/DiGraphs.ipynb b/examples/DiGraphs.ipynb new file mode 100644 index 0000000..d60dc46 --- /dev/null +++ b/examples/DiGraphs.ipynb @@ -0,0 +1,80 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import networkx as nx\n", + "import igviz" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "ename": "NameError", + "evalue": "name 'nx' is not defined", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mNameError\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 13\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mG\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 14\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 15\u001b[0;31m \u001b[0mDG\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mcreateDiGraph\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", + "\u001b[0;32m\u001b[0m in \u001b[0;36mcreateDiGraph\u001b[0;34m()\u001b[0m\n\u001b[1;32m 2\u001b[0m \u001b[0;31m# Create a directed graph (digraph) object; i.e., a graph in which the edges\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 3\u001b[0m \u001b[0;31m# have a direction associated with them.\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 4\u001b[0;31m \u001b[0mG\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mnx\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mDiGraph\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 5\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 6\u001b[0m \u001b[0;31m# Add nodes:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;31mNameError\u001b[0m: name 'nx' is not defined" + ] + } + ], + "source": [ + "def createDiGraph():\n", + " # Create a directed graph (digraph) object; i.e., a graph in which the edges\n", + " # have a direction associated with them.\n", + " G = nx.DiGraph()\n", + "\n", + " # Add nodes:\n", + " nodes = ['A', 'B', 'C', 'D', 'E']\n", + " G.add_nodes_from(nodes)\n", + "\n", + " # Add edges or links between the nodes:\n", + " edges = [('A','B'), ('B','C'), ('B', 'D'), ('D', 'E')]\n", + " G.add_edges_from(edges)\n", + " return G\n", + "\n", + "DG = createDiGraph()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ig.plot(DG, size_method=\"static\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.9" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/Multigraphs.ipynb b/examples/Multigraphs.ipynb new file mode 100644 index 0000000..500573f --- /dev/null +++ b/examples/Multigraphs.ipynb @@ -0,0 +1,54 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import networkx as nx\n", + "import igviz" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "MG = nx.MultiGraph()\n", + "MG.add_weighted_edges_from([(1, 2, 0.5), (1, 2, 0.75), (2, 3, 0.5)])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ig.plot(MG, layout=\"spring\", size_method=\"static\", show_edgetext=True)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.9" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/tutorial.ipynb b/examples/tutorial.ipynb index 660dc7f..cff7b3c 100644 --- a/examples/tutorial.ipynb +++ b/examples/tutorial.ipynb @@ -1,15 +1,5 @@ { "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, { "cell_type": "code", "execution_count": 2, @@ -7513,7 +7503,7 @@ } ], "source": [ - "ig.plot(G, layout=\"kamada\")" + "ig.plot(G)" ] }, { @@ -14486,7 +14476,7 @@ "ig.plot(\n", " G, # Your graph\n", " title=\"My Graph\",\n", - " sizing_method=\"static\", # Makes node sizes the same\n", + " size_method=\"static\", # Makes node sizes the same\n", " color_method=\"#ffcccb\", # Makes all the node colours black,\n", " node_text=[\"prop\"], # Adds the 'prop' property to the hover text of the node\n", " annotation_text=\"Visualization made by igviz & plotly.\", # Adds a text annotation to the graph\n", @@ -21607,7 +21597,7 @@ "ig.plot(\n", " G,\n", " title=\"My Graph\",\n", - " sizing_method=\"prop\", # Makes node sizes the size of the \"prop\" property\n", + " size_method=\"prop\", # Makes node sizes the size of the \"prop\" property\n", " color_method=\"prop\", # Colors the nodes based off the \"prop\" property and a color scale,\n", " node_text=[\"prop\"], # Adds the 'prop' property to the hover text of the node\n", ")" @@ -28736,7 +28726,7 @@ "ig.plot(\n", " G,\n", " title=\"My Graph\",\n", - " sizing_method=sizing_list, # Makes node sizes the size of the \"prop\" property\n", + " size_method=sizing_list, # Makes node sizes the size of the \"prop\" property\n", " color_method=color_list, # Colors the nodes based off the \"prop\" property and a color scale,\n", " node_text=[\"prop\"], # Adds the 'prop' property to the hover text of the node\n", ")" diff --git a/igviz/igviz.py b/igviz/igviz.py index 5b1e9fd..3270609 100644 --- a/igviz/igviz.py +++ b/igviz/igviz.py @@ -7,12 +7,14 @@ def plot( G, title="Graph", layout=None, - sizing_method="degree", + size_method="degree", color_method="degree", node_text=[], + show_edgetext=False, titlefont_size=16, showlegend=False, annotation_text="", + colorscale="YlGnBu", colorbar_title="", ): """ @@ -44,7 +46,7 @@ def plot( spiral: Position nodes in a spiral layout. - sizing_method : {'degree', 'static'}, node property or a list, optional + size_method : {'degree', 'static'}, node property or a list, optional How to size the nodes., by default "degree" degree: The larger the degree, the larger the node. @@ -69,6 +71,9 @@ def plot( node_text : list, optional A list of node properties to display when hovering over the node. + show_edgetext : bool, optional + True to display the edge properties on hover. + titlefont_size : int, optional Font size of the title, by default 16 @@ -78,6 +83,9 @@ def plot( annotation_text : str, optional Graph annotation text, by default "" + colorscale : {'Greys', 'YlGnBu', 'Greens', 'YlOrRd', 'Bluered', 'RdBu', 'Reds', 'Blues', 'Picnic', 'Rainbow', 'Portland', 'Jet', 'Hot', 'Blackbody', 'Earth', 'Electric', 'Viridis'} + Scale of the color bar + colorbar_title : str, optional Color bar axis title, by default "" @@ -92,17 +100,21 @@ def plot( elif not nx.get_node_attributes(G, "pos"): _apply_layout(G, "random") - node_trace, edge_trace = _generate_scatter_trace( + node_trace, edge_trace, middle_node_trace = _generate_scatter_trace( G, - sizing_method=sizing_method, + size_method=size_method, color_method=color_method, + colorscale=colorscale, colorbar_title=colorbar_title, node_text=node_text, + show_edgetext=show_edgetext, ) fig = _generate_figure( + G, node_trace, edge_trace, + middle_node_trace, title=title, titlefont_size=titlefont_size, showlegend=showlegend, @@ -114,126 +126,178 @@ def plot( def _generate_scatter_trace( G, - sizing_method: Union[str, list], + size_method: Union[str, list], color_method: Union[str, list], + colorscale: str, colorbar_title: str, node_text: list, + show_edgetext: bool, ): """ Helper function to generate Scatter plot traces for the graph. """ - edge_x = [] - edge_y = [] - node_x = [] - node_y = [] - node_size = [] - color = [] - node_text_list = [] + edge_text_list = [] + edge_properties = {} + + edge_trace = go.Scatter( + x=[], y=[], line=dict(width=1, color="#888"), hoverinfo="text", mode="lines", + ) + + # NOTE: This is a hack because Plotly does not allow you to have hover text on a line + # Were adding an invisible node to the edges that will display the edge properties + middle_node_trace = go.Scatter( + x=[], y=[], text=[], mode="markers", hoverinfo="text", marker=dict(opacity=0) + ) - for edge in G.edges(): + node_trace = go.Scatter( + x=[], + y=[], + mode="markers", + text=[], + hoverinfo="text", + marker=dict( + showscale=True, + colorscale=colorscale, + reversescale=True, + size=[], + color=[], + colorbar=dict( + thickness=15, title=colorbar_title, xanchor="left", titleside="right" + ), + line_width=2, + ), + ) + + for edge in G.edges(data=True): x0, y0 = G.nodes[edge[0]]["pos"] x1, y1 = G.nodes[edge[1]]["pos"] - edge_x.append(x0) - edge_x.append(x1) - edge_x.append(None) - edge_y.append(y0) - edge_y.append(y1) - edge_y.append(None) + edge_trace["x"] += tuple([x0, x1, None]) + edge_trace["y"] += tuple([y0, y1, None]) - edge_trace = go.Scatter( - x=edge_x, - y=edge_y, - line=dict(width=0.5, color="#888"), - hoverinfo="none", - mode="lines", - ) + if show_edgetext: + # Now we can add the text + # First we need to aggregate all the properties for each edge + edge_pair = (edge[0], edge[1]) + # if an edge property for an edge hasn't been tracked, add an entry + if edge_pair not in edge_properties: + edge_properties[edge_pair] = {} + + # Since we haven't seen this node combination before also add it to the trace + middle_node_trace["x"] += tuple([(x0 + x1) / 2]) + middle_node_trace["y"] += tuple([(y0 + y1) / 2]) + + # For each edge property, create an entry for that edge, keeping track of the property name and its values + # If it doesn't exist, add an entry + for k, v in edge[2].items(): + if k not in edge_properties[edge_pair]: + edge_properties[edge_pair][k] = [] + + edge_properties[edge_pair][k] += [v] for node in G.nodes(): - text = f"Degree: {G.degree(node)}" + text = f"Node: {node}
Degree: {G.degree(node)}" x, y = G.nodes[node]["pos"] - node_x.append(x) - node_y.append(y) + node_trace["x"] += tuple([x]) + node_trace["y"] += tuple([y]) if node_text: for prop in node_text: text += f"

{prop}: {G.nodes[node][prop]}" - node_text_list.append(text.strip()) + node_trace["text"] += tuple([text.strip()]) - if isinstance(sizing_method, list): - node_size = sizing_method + if isinstance(size_method, list): + node_trace["marker"]["size"] = size_method else: - if sizing_method == "degree": - node_size.append(G.degree(node) * 2) - elif sizing_method == "static": - node_size.append(12) + if size_method == "degree": + node_trace["marker"]["size"] += tuple([G.degree(node) * 2]) + elif size_method == "static": + node_trace["marker"]["size"] += tuple([12]) else: - node_size.append(G.nodes[node][sizing_method]) + node_trace["marker"]["size"] += tuple([G.nodes[node][size_method]]) if isinstance(color_method, list): - color = color_method + node_trace["marker"]["color"] = color_method else: if color_method == "degree": - color.append(G.degree(node)) + node_trace["marker"]["color"] += tuple([G.degree(node)]) else: # Look for the property, otherwise look for a color code # If none exist, throw an error if color_method in G.nodes[node]: - color.append(G.nodes[node][color_method]) + node_trace["marker"]["color"] += tuple( + [G.nodes[node][color_method]] + ) else: - color.append(color_method) + node_trace["marker"]["color"] += tuple([color_method]) - node_trace = go.Scatter( - x=node_x, - y=node_y, - mode="markers", - hoverinfo="text", - marker=dict( - showscale=True, - colorscale="YlGnBu", - reversescale=True, - size=node_size, - colorbar=dict( - thickness=15, title=colorbar_title, xanchor="left", titleside="right" - ), - line_width=2, - ), - ) + if show_edgetext: + + edge_text_list = [ + "\n".join(f"{k}: {v}" for k, v in vals.items()) + for _, vals in edge_properties.items() + ] - node_trace.marker.color = color - node_trace.text = node_text_list + middle_node_trace["text"] = edge_text_list - return node_trace, edge_trace + return node_trace, edge_trace, middle_node_trace def _generate_figure( - node_trace, edge_trace, title, titlefont_size, showlegend, annotation_text + G, + node_trace, + edge_trace, + middle_node_trace, + title, + titlefont_size, + showlegend, + annotation_text, ): """ Helper function to generate the figure for the Graph. """ + annotations = [ + dict( + text=annotation_text, + showarrow=False, + xref="paper", + yref="paper", + x=0.005, + y=-0.002, + ) + ] + + if isinstance(G, (nx.DiGraph, nx.MultiDiGraph)): + + for edge in G.edges(): + annotations.append( + dict( + ax=G.nodes[edge[0]]["pos"][0], + ay=G.nodes[edge[0]]["pos"][1], + axref="x", + ayref="y", + x=G.nodes[edge[1]]["pos"][0], + y=G.nodes[edge[1]]["pos"][1], + xref="x", + yref="y", + showarrow=True, + arrowhead=1, + ) + ) + fig = go.Figure( - data=[edge_trace, node_trace], + data=[edge_trace, node_trace, middle_node_trace], layout=go.Layout( title=title, titlefont_size=titlefont_size, showlegend=showlegend, hovermode="closest", margin=dict(b=20, l=5, r=5, t=40), - annotations=[ - dict( - text=annotation_text, - showarrow=False, - xref="paper", - yref="paper", - x=0.005, - y=-0.002, - ) - ], + annotations=annotations, xaxis=dict(showgrid=False, zeroline=False, showticklabels=False), yaxis=dict(showgrid=False, zeroline=False, showticklabels=False), ), diff --git a/igviz/tests/test_plot.py b/igviz/tests/test_plot.py index d049186..13190a7 100644 --- a/igviz/tests/test_plot.py +++ b/igviz/tests/test_plot.py @@ -11,6 +11,30 @@ def G(): return G +@pytest.fixture(scope="function") +def DG(): + # Create a directed graph (digraph) object; i.e., a graph in which the edges + # have a direction associated with them. + G = nx.DiGraph() + + # Add nodes: + nodes = ["A", "B", "C", "D", "E"] + G.add_nodes_from(nodes) + + # Add edges or links between the nodes: + edges = [("A", "B"), ("B", "C"), ("B", "D"), ("D", "E")] + G.add_edges_from(edges) + return G + + +@pytest.fixture(scope="function") +def MG(): + G = nx.MultiGraph() + G.add_weighted_edges_from([(1, 2, 0.5), (1, 2, 0.75), (2, 3, 0.5)]) + + return G + + def test_plot(G): ig.plot(G) @@ -20,14 +44,14 @@ def test_plot(G): def test_plot_fixed_size_color(G): - ig.plot(G, sizing_method="static", color_method="#ffffff") + ig.plot(G, show_edgetext=True, size_method="static", color_method="#ffffff") assert True def test_plot_property(G): - ig.plot(G, sizing_method="prop", color_method="prop") + ig.plot(G, size_method="prop", color_method="prop") assert True @@ -46,7 +70,7 @@ def test_plot_size_list(G): for node in G.nodes(): size.append(3) - ig.plot(G, sizing_method=size) + ig.plot(G, size_method=size) assert True @@ -73,3 +97,17 @@ def test_plot_layout(G): ig.plot(G, layout="kamada") assert True + + +def test_digraph(DG): + + ig.plot(DG) + + assert True + + +def test_multigraph(MG): + + ig.plot(MG) + + assert True diff --git a/setup.py b/setup.py index dc7f272..ecb2b47 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from setuptools import find_packages, setup from setuptools.command.install import install -VERSION = "0.2.0" +VERSION = "0.3.0" pkgs = [ "networkx==2.4",