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 0000000..e626568 Binary files /dev/null and b/docs/images/dg.png differ diff --git a/docs/images/mg.png b/docs/images/mg.png new file mode 100644 index 0000000..8d608f2 Binary files /dev/null and b/docs/images/mg.png differ 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",