diff --git a/README.md b/README.md index 6f311a5..5d37f68 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,28 @@ -# netgraph +# Netgraph -[![Downloads](https://pepy.tech/badge/netgraph)](https://pepy.tech/project/netgraph) +*Publication-quality network visualisations in python* -Netgraph is a python library for creating publication quality plots of networks. -Netgraph is compatible with a variety of network data formats, including `networkx` and `igraph` `Graph` objects. +[![Downloads](https://pepy.tech/badge/netgraph)](https://pepy.tech/project/netgraph) +Netgraph is a python library that aims to complement existing network analysis libraries such as such as [networkx](https://networkx.org/), [igraph](https://igraph.org/), and [graph-tool](https://graph-tool.skewed.de/) with publication-quality visualisations within the python ecosystem. To facilitate a seamless integration, netgraph supports a variety of input formats, including networkx, igraph, and graph-tool `Graph` objects. Netgraph implements numerous node layout algorithms and several edge routing routines. Uniquely among python alternatives, it handles networks with multiple components gracefully (which otherwise break most node layout routines), and it post-processes the output of the node layout and edge routing algorithms with several heuristics to increase the interpretability of the visualisation (reduction of overlaps between nodes, edges, and labels; edge crossing minimisation and edge unbundling where applicable). The highly customisable plots are created using [matplotlib](https://matplotlib.org/), and the resulting matplotlib objects are exposed in an easily queryable format such that they can be further manipulated and/or animated using standard matplotlib syntax. Finally, netgraph also supports interactive changes: with the `InteractiveGraph` class, nodes and edges can be positioned using the mouse, and the `EditableGraph` class additionally supports insertion and deletion of nodes and edges as well as their (re-)labelling through standard text-entry. ## Installation -Install the current release of `netgraph` with: +Install the current release of `netgraph` from PyPI: ``` shell pip install netgraph ``` +If you are using (Ana-)conda (or mamba), you can also obtain netgraph from conda-forge: + +``` shell +conda install -c conda-forge netgraph +``` ## Documentation -The documentation of the full API, as well as numerous code examples can be found on [ReadTheDocs](https://netgraph.readthedocs.io/en/latest/index.html). +Numerous tutorials, code examples, and a complete documentation of the API can be found on [ReadTheDocs](https://netgraph.readthedocs.io/en/latest/index.html). ## Quickstart @@ -92,215 +97,22 @@ help(EditableGraph) ``` -## Reasons why you might want to use netgraph +## Examples -### Better layouts - ![Example visualisations](./figures/gallery_portrait.png) -### Interactivity - -Algorithmically finding a visually pleasing graph layout is hard. -This is demonstrated by the plethora of different algorithms in use -(if graph layout was a solved problem, there would only be one -algorithm). To ameliorate this problem, this module contains an -`InteractiveGraph` class, which allows node positions to be tweaked -with the mouse after an initial draw. - -- Individual nodes and edges can be selected using the left-click. -- Multiple nodes and or edges can be selected by holding `control` - while clicking, or by using the rectangle/window selector. -- Selected plot elements can be dragged around by holding left-click - on a selected artist. - -![Demo of selecting, dragging, and hovering](https://media.giphy.com/media/yEysQUUTndLT6mI9cN/giphy.gif) - - -``` python -import matplotlib.pyplot as plt -import networkx as nx -from netgraph import InteractiveGraph - -g = nx.house_x_graph() - -edge_color = dict() -for ii, edge in enumerate(g.edges): - edge_color[edge] = 'tab:gray' if ii%2 else 'tab:orange' - -node_color = dict() -for node in g.nodes: - node_color[node] = 'tab:red' if node%2 else 'tab:blue' - -plot_instance = InteractiveGraph( - g, node_size=5, node_color=node_color, - node_labels=True, node_label_offset=0.1, node_label_fontdict=dict(size=20), - edge_color=edge_color, edge_width=2, - arrows=True, ax=ax) - -plt.show() -``` - -There is also some experimental support for editing the graph -elements interactively using the `EditableGraph` class. - -- Pressing `insert` or `+` will add a new node to the graph. -- Double clicking on two nodes successively will create an edge between them. -- Pressing `delete` or `-` will remove selected nodes and edges. -- Pressing `@` will reverse the direction of selected edges. - -When adding a new node, the properties of the last selected node will -be used to style the node artist. Ditto for edges. If no node or edge -has been previously selected, the first created node or edge artist -will be used. - -![Demo of interactive editing](https://media.giphy.com/media/TyiS2Pl1z9CFqYMYe7/giphy.gif) - -Finally, elements of the graph can be labeled and annotated. Labels -remain always visible, whereas annotations can be toggled on and off by -clicking on the corresponding node or edge. - -- To create or edit a node or edge label, select the node (or edge) - artist, press the `enter` key, and type. -- To create or edit an annotation, select the node (or edge) artist, - press `alt + enter`, and type. -- Terminate either action by pressing `enter` or `alt + enter` a - second time. - -![Demo of interactive labeling](https://media.giphy.com/media/OofBM1xtwfSpK7DPSU/giphy.gif) - -``` python -import matplotlib.pyplot as plt -import networkx as nx -from netgraph import EditableGraph - -g = nx.house_x_graph() - -edge_color = dict() -for ii, (source, target) in enumerate(g.edges): - edge_color[(source, target)] = 'tab:gray' if ii%2 else 'tab:orange' - -node_color = dict() -for node in g.nodes: - node_color[node] = 'tab:red' if node%2 else 'tab:blue' - -annotations = { - 4 : 'This is the representation of a node.', - (0, 1) : dict(s='This is not a node.', color='red') -} - - -fig, ax = plt.subplots(figsize=(10, 10)) - -plot_instance = EditableGraph( - g, node_color=node_color, node_size=5, - node_labels=True, node_label_offset=0.1, node_label_fontdict=dict(size=20), - edge_color=edge_color, edge_width=2, - annotations=annotations, annotation_fontdict = dict(color='blue', fontsize=15), - arrows=True, ax=ax) - -plt.show() -``` - -### Fine control over plot elements - -High quality figures require fine control over plot elements. -To that end, all node artist and edge artist properties can be specified in three ways: - -1. Using a single scalar or string that will be applied to all artists. - -``` python -import matplotlib.pyplot as plt -from netgraph import Graph - -edges = [(0, 1), (1, 1)] -Graph(edges, node_color='red', node_size=4.) -plt.show() -``` - -2. Using a dictionary mapping individual nodes or individual edges to a property: - -``` python -import matplotlib.pyplot as plt -from netgraph import Graph - -Graph([(0, 1), (1, 2), (2, 0)], - edge_color={(0, 1) : 'g', (1, 2) : 'lightblue', (2, 0) : np.array([1, 0, 0])}, - node_size={0 : 20, 1 : 4.2, 2 : np.pi}, -) -plt.show() -``` - -3. By directly manipulating the node and edge artists (which are derived from matplotlib PathPatch artists): - -``` python -import matplotlib.pyplot as plt; plt.ion() -from netgraph import Graph - -fig, ax = plt.subplots() -g = Graph([(0, 1), (1, 2), (2, 0)], ax=ax) - -# make some changes -g.edge_artists[(0, 1)].set_facecolor('red') -g.edge_artists[(1, 2)].set_facecolor('lightblue') - -# force redraw to display changes -fig.canvas.draw() -``` - -Similarly, node and edge labels are just matplotlib text objects. -Their properties can also be specified using a single value that is applied to all of them: - -``` python -import matplotlib.pyplot as plt -from netgraph import Graph - -Graph([(0, 1)], - node_size=20, - node_labels={0 : 'Lorem', 1 : 'ipsum'}, - node_label_fontdict=dict(size=18, fontfamily='Arial', fontweight='bold'), - edge_labels={(0, 1) : 'dolor sit'}, - # blue bounding box with red edge: - edge_label_fontdict=dict(bbox=dict(boxstyle='round', - ec=(1.0, 0.0, 0.0), - fc=(0.5, 0.5, 1.0))), -) -plt.show() -``` - -Alternatively, their properties can be manipulated individually after an initial draw: - -``` python -import matplotlib.pyplot as plt -from netgraph import Graph - -fig, ax = plt.subplots() -g = Graph([(0, 1)], - node_size=20, - node_labels={0 : 'Lorem', 1 : 'ipsum'}, - edge_labels={(0, 1) : 'dolor sit'}, - ax=ax -) - -# make some changes -g.node_label_artists[1].set_color('hotpink') -g.edge_label_artists[(0, 1)].set_style('italic') - -# force redraw to display changes -fig.canvas.draw() -plt.show() -``` - -For a full list of available arguments, please consult the docstrings -of the `Graph` or `InteractiveGraph` class: - -```python -from netgraph import Graph; help(Graph) -``` - ## Recent changes +- 4.12.12 Expanded the documentation to cover installation of optional dependencies, automated testing, and troubleshooting issues with matplotlib event handling (issue #69). +- 4.12.11 Mitigated a bug in `EditableGraph` that occurred when deleting a node while hovering over an edge incident to that node (issue #66). +- 4.12.10 Fixed a bug with automatic node label rescaling if the node label fontsize was specified using the `fontsize` keyword argument (instead of just `size`). +- 4.12.9 Fixed a bug that occurred when the distance argument to `_shorten_line_by` was equal or smaller than zero. +- 4.12.8 Fixed a bug that occurred with recent numpy versions when using multi-partite or shell layouts with un-equal numbers of nodes in each layer (issue #65). +- 4.12.7 Fixed a bug that occurred with recent matplotlib versions when using the rectangle selector in `InteractiveGraph`. +- 4.12.6 Added support for graphs with nodes but no edges to `EditableGraph` (issue #62). +- 4.12.5 Added support for empty graphs in `EditableGraph` (issue #62). - 4.12.4 Turned off clipping of self-loop paths. - 4.12.3 Bugfix: stopped overwriting `step` parameter in `get_community_layout`. - 4.12.2 Improved node positions rescaling for some layouts & standardised node position padding across all layouts. diff --git a/docs/source/index.rst b/docs/source/index.rst index edd3729..0f018b9 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -6,19 +6,33 @@ Netgraph ======== +*Publication-quality network visualisations in python* + .. image:: https://pepy.tech/badge/netgraph -Netgraph is a python library for creating publication quality plots of networks. -Netgraph is compatible with a variety of network data formats, including :code:`networkx`, :code:`igraph`, and :code:`graph_tool` :code:`Graph` objects. +Netgraph is a python library that aims to complement existing network analysis libraries such as such as networkx_, igraph_, and graph-tool_ with publication-quality visualisations within the python ecosystem. To facilitate a seamless integration, netgraph supports a variety of input formats, including networkx, igraph, and graph-tool :code:`Graph` objects. Netgraph implements numerous node layout algorithms and several edge routing routines. Uniquely among python alternatives, it handles networks with multiple components gracefully (which otherwise break most node layout routines), and it post-processes the output of the node layout and edge routing algorithms with several heuristics to increase the interpretability of the visualisation (reduction of overlaps between nodes, edges, and labels; edge crossing minimisation and edge unbundling where applicable). The highly customisable plots are created using matplotlib_, and the resulting matplotlib objects are exposed in an easily queryable format such that they can be further manipulated and/or animated using standard matplotlib syntax. Finally, netgraph also supports interactive changes: with the :code:`InteractiveGraph` class, nodes and edges can be positioned using the mouse, and the :code:`EditableGraph` class additionally supports insertion and deletion of nodes and edges as well as their (re-)labelling through standard text-entry. + +.. _networkx: https://networkx.org/ +.. _igraph: https://igraph.org/ +.. _graph-tool: https://graph-tool.skewed.de/ +.. _matplotlib: https://matplotlib.org/ Installation ------------ +From PyPI: + .. code-block:: shell pip install netgraph +From conda-forge: + +.. code-block:: + + conda install -c conda-forge netgraph + Contributing & Support ---------------------- @@ -37,7 +51,7 @@ cool feature, I will probably worship the ground you walk on for the rest of the week. Probably. .. _GitHub: /~https://github.com/paulbrodersen/netgraph -.. _StackOverflow: https://stackoverflow.com/ +.. _StackOverflow: https://stackoverflow.com/questions/tagged/netgraph __ https://stackoverflow.com/help/minimal-reproducible-example diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 4889bd8..3bef1b4 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -1,26 +1,32 @@ .. _installation: -Installation -============ +Installation & Testing +====================== -Install the current release of `netgraph` with: +Install the current release of Netgraph with: .. code-block:: shell pip install netgraph -To upgrade to a newer version, use the `--upgrade` flag: +To upgrade to a newer version, use the :code:`--upgrade` flag: .. code-block:: pip install --upgrade netgraph -If you do not have permission to install software systemwide, you can install into your user directory using the --user flag: +If you do not have permission to install software systemwide, you can install into your user directory using the :code:`--user` flag: .. code-block:: pip install --user netgraph +If you are using (Ana-)conda (or mamba), you can also obtain netgraph from conda-forge: + +.. code-block:: + + conda install -c conda-forge netgraph + Alternatively, you can manually download netgraph from GitHub_ or PyPI_. To install one of these versions, unpack it and run the following from the top-level source directory using the terminal: @@ -36,3 +42,21 @@ Or without pip: .. code-block:: python setup.py install + +Some of the examples in the documentation use additional libraries. These can be installed with: + +.. code-block:: + + pip install netgraph[docs] + +For automated testing, install the additional dependencies required for testing using: + +.. code-block:: + + pip install netgraph[tests] + +The full test suite can then be executed by running the following from the top-level source directory using the terminal: + +.. code-block:: + + pytest diff --git a/docs/source/interactivity.rst b/docs/source/interactivity.rst index c77cf8e..6488d2a 100644 --- a/docs/source/interactivity.rst +++ b/docs/source/interactivity.rst @@ -95,3 +95,59 @@ Finally, elements of the graph can be labeled and annotated. Labels remain alway arrows=True, ax=ax) plt.show() + + +Troubleshooting +--------------- + +Interactive graphs require a working interactive matplotlib backend with proper event handling, and by default, matplotlib should be configured appropriately. +However, several circumstances can silently interfere with proper event handling without raising obvious errors: + +1. The matplotlib python object corresponding to the figure is garbage collected. + + This can occur while the figure is still being displayed. To prevent garbage collection, a reference to the figure object has to be retained. + In the examples above, :code:`InteractiveGraph` and :code:`EditableGraph` instances are assigned to a variable :code:`plot_instance` (the variable name is arbitrary). + + When using IDE's such as PyCharm, python objects are often garbage collected despite such references. To circumvent this behaviour, the code has to be executed in a console or shell. In PyCharm, this can be achieved by pressing Alt+Shift+E or selecting the appropriate drop-down menu item. + +2. Running matplotlib on a server without X forwarding. + + This includes Jupyter and Google colab notebooks, both of which don't support interactive events natively. + +3. Running matplotlib while not using an interactive backend. + + You can determine your current matplotlib backend with the following commands: + + .. code:: + + import matplotlib + matplotlib.get_backend() + + The matplotlib documentation provides an exhaustive list of all available backends as well as instructions for configuring interactive backends here_. + + .. _here: https://matplotlib.org/stable/users/explain/backends.html + +To confirm that the matplotlib backend is interactive and handles events properly, you can run the following example from the matplotlib documentation_: + +.. _documentation: https://matplotlib.org/stable/users/explain/event_handling.html + +.. code:: + + import numpy as np + import matplotlib.pyplot as plt + + fig, ax = plt.subplots() + ax.plot(np.random.rand(10)) + + def onclick(event): + print('%s click: button=%d, x=%d, y=%d, xdata=%f, ydata=%f' % + ('double' if event.dblclick else 'single', event.button, + event.x, event.y, event.xdata, event.ydata)) + + cid = fig.canvas.mpl_connect('button_press_event', onclick) + plt.show() + +If clicking on the figure canvas results in print statements, then matplotlib is correctly configured. +If you still encounter issues with the :code:`InteractiveGraph` or the :code:`EditableGraph` class despite following this troubleshooting guide, please raise an issue on GitHub_. + +.. _GitHub: /~https://github.com/paulbrodersen/netgraph/issues diff --git a/netgraph/__init__.py b/netgraph/__init__.py index 87d5ce7..28f7d50 100755 --- a/netgraph/__init__.py +++ b/netgraph/__init__.py @@ -98,7 +98,7 @@ >>> help(EditableGraph) """ -__version__ = "4.12.4" +__version__ = "4.12.12" __author__ = "Paul Brodersen" __email__ = "paulbrodersen+netgraph@gmail.com" diff --git a/netgraph/_interactive_variants.py b/netgraph/_interactive_variants.py index 4924105..f6184fd 100755 --- a/netgraph/_interactive_variants.py +++ b/netgraph/_interactive_variants.py @@ -17,9 +17,11 @@ from ._main import InteractiveGraph, BASE_SCALE, DraggableGraph from ._line_supercover import line_supercover from ._artists import NodeArtist, EdgeArtist + from ._parser import is_order_zero, is_empty, parse_graph except ValueError: from _main import InteractiveGraph, BASE_SCALE from _line_supercover import line_supercover + from _parser import is_order_zero, is_empty, parse_graph class NascentEdge(plt.Line2D): @@ -55,16 +57,54 @@ class MutableGraph(InteractiveGraph): def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + if is_order_zero(args[0]): + # The graph is order-zero, i.e. it has no edges and no nodes. + # We hence initialise with a single edge, which populates + # - last_selected_node_properties + # - last_selected_edge_properties + # with the chosen parameters. + # We then delete the edge and the two nodes and return the empty canvas. + super().__init__([(0, 1)], *args[1:], **kwargs) + self._initialize_data_structures() + self._delete_edge((0, 1)) + self._delete_node(0) + self._delete_node(1) + + elif is_empty(args[0]): + # The graph is empty, i.e. it has at least one node but no edges. + nodes, _, _ = parse_graph(args[0]) + if len(nodes) > 1: + edge = (nodes[0], nodes[1]) + super().__init__([edge], nodes=nodes, *args[1:], **kwargs) + self._initialize_data_structures() + self._delete_edge(edge) + else: # single node + node = nodes[0] + dummy = 0 if node != 0 else 1 + edge = (node, dummy) + super().__init__([edge], *args[1:], **kwargs) + self._initialize_data_structures() + self._delete_edge(edge) + self._delete_node(dummy) + else: + super().__init__(*args, **kwargs) + self._initialize_data_structures() + + # Ignore data limits and return full canvas. + xmin, ymin = self.origin + dx, dy = self.scale + self.ax.axis([xmin, xmin+dx, ymin, ymin+dy]) + + self.fig.canvas.mpl_connect('key_press_event', self._on_key_press) + + def _initialize_data_structures(self): self._reverse_node_artists = {artist : node for node, artist in self.node_artists.items()} self._reverse_edge_artists = {artist : edge for edge, artist in self.edge_artists.items()} self._last_selected_node_properties = self._extract_node_properties(next(iter(self.node_artists.values()))) self._last_selected_edge_properties = self._extract_edge_properties(next(iter(self.edge_artists.values()))) self._nascent_edge = None - self.fig.canvas.mpl_connect('key_press_event', self._on_key_press) - def _on_key_press(self, event): if event.key in ('insert', '+'): @@ -263,7 +303,8 @@ def _delete_node(self, node): self._clickable_artists.remove(artist) self._selectable_artists.remove(artist) self._draggable_artists.remove(artist) - self._selected_artists.remove(artist) + if artist in self._selected_artists: + self._selected_artists.remove(artist) del self._base_linewidth[artist] del self._base_edgecolor[artist] # 3c) EmphasizeOnHover @@ -372,6 +413,10 @@ def _delete_edge(self, edge): del self._base_edgecolor[artist] # 3c) EmphasizeOnHover self.emphasizeable_artists.remove(artist) + try: + self.deemphasized_artists.remove(artist) + except ValueError: + pass del self._base_alpha[artist] # 3d) AnnotateOnClick if artist in self.artist_to_annotation: diff --git a/netgraph/_main.py b/netgraph/_main.py index 5493927..56584fa 100755 --- a/netgraph/_main.py +++ b/netgraph/_main.py @@ -722,8 +722,8 @@ def draw_edges(self, edge_path, edge_width, edge_color, edge_alpha, head_length = 2 * edge_width[edge] head_width = 3 * edge_width[edge] else: - head_length = 1e-10 # 0 throws error - head_width = 1e-10 # 0 throws error + head_length = 0 + head_width = 0 edge_artist = EdgeArtist( midline = edge_path[edge], @@ -884,7 +884,8 @@ def _initialize_node_label_fontdict(self, node_label_fontdict, node_labels, node # Labels are centered on node artists. # Set fontsize such that labels fit the diameter of the node artists. size = self._get_font_size(node_labels, node_label_fontdict) * 0.75 # conservative fudge factor - node_label_fontdict.setdefault('size', size) + if ('size' not in node_label_fontdict) and ('fontsize' not in node_label_fontdict): + node_label_fontdict.setdefault('size', size) return node_label_fontdict @@ -904,6 +905,8 @@ def _get_font_size(self, node_labels, node_label_fontdict): if 'size' in node_label_fontdict: size = rescale_factor * node_label_fontdict['size'] + elif 'fontsize' in node_label_fontdict: + size = rescale_factor * node_label_fontdict['fontsize'] else: size = rescale_factor * plt.rcParams['font.size'] return size @@ -1656,9 +1659,9 @@ def _selector_on(self): self._rect.set_visible(True) xlim = np.sort([self._x0, self._x1]) ylim = np.sort([self._y0, self._y1]) - self._rect.set_xy((xlim[0],ylim[0] ) ) - self._rect.set_width(np.diff(xlim)) - self._rect.set_height(np.diff(ylim)) + self._rect.set_xy((xlim[0], ylim[0])) + self._rect.set_width(xlim[1] - xlim[0]) + self._rect.set_height(ylim[1] - ylim[0]) self.fig.canvas.draw_idle() @@ -2068,7 +2071,11 @@ def _on_motion(self, event): # not on any artist if (selected_artist is None) and self.deemphasized_artists: for artist in self.deemphasized_artists: - artist.set_alpha(self._base_alpha[artist]) + try: + artist.set_alpha(self._base_alpha[artist]) + except KeyError: + # This mitigates issue #66. + pass self.deemphasized_artists = [] self.fig.canvas.draw_idle() diff --git a/netgraph/_node_layout.py b/netgraph/_node_layout.py index b317eaa..345c0de 100755 --- a/netgraph/_node_layout.py +++ b/netgraph/_node_layout.py @@ -1359,15 +1359,19 @@ def get_multipartite_layout(edges, layers, layer_positions=None, origin=(0, 0), # set the space between nodes if uniform_node_spacing: try: - node_spacings = scale[1] / (np.max([len(layer) for layer in layers]) - 1) * np.ones_like(layers, dtype=float) + spacing = scale[1] / (np.max([len(layer) for layer in layers]) - 1) + node_spacings = [spacing * np.ones_like(layer, dtype=float) for layer in layers] except ZeroDivisionError: # The graph has at most a single edge between each pair of layers. - node_spacings = np.ones_like(layers, dtype=float) + spacing = 1 + node_spacings = [spacing * np.ones_like(layer, dtype=float) for layer in layers] else: - node_spacings = np.ones_like(layers, dtype=float) + node_spacings = [] for ii, layer in enumerate(layers): if len(layer) > 1: - node_spacings[ii] = 1./(len(layer) - 1) + node_spacings.append(1./(len(layer) - 1) * np.ones_like(layer, float)) + else: + node_spacings.append(np.ones_like(layer, float)) # set the space between layers if layer_positions is None: diff --git a/netgraph/_parser.py b/netgraph/_parser.py index 8062233..f4c1612 100644 --- a/netgraph/_parser.py +++ b/netgraph/_parser.py @@ -44,8 +44,7 @@ def _is_listlike(graph): @_handle_multigraphs def _parse_sparse_matrix_format(adjacency): """Parse graphs given in a sparse format, i.e. edge lists or sparse matrix representations.""" - adjacency = np.array(adjacency) - rows, columns = adjacency.shape + rows, columns = np.array(adjacency).shape if columns == 2: edges = _parse_edge_list(adjacency) @@ -53,9 +52,9 @@ def _parse_sparse_matrix_format(adjacency): return nodes, edges, None elif columns == 3: - edges = _parse_edge_list(adjacency[:, :2]) - nodes = _get_unique_nodes(edges) edge_weight = {(source, target) : weight for (source, target, weight) in adjacency} + edges = list(edge_weight.keys()) + nodes = _get_unique_nodes(edges) # In a sparse adjacency format with integer nodes and float weights, # the type of nodes is promoted to the same type as weights. @@ -92,17 +91,17 @@ def _is_nparray(graph): def _parse_nparray(graph): - rows, columns = graph.shape - if columns in (2, 3): - return _parse_sparse_matrix_format(graph) - elif rows == columns: - return _parse_adjacency_matrix(graph) - else: - msg = "Could not interpret input graph." - msg += "\nIf a graph is specified as a numpy array, it has to have one of the following shapes:" - msg += "\n\t-(E, 2) or (E, 3), where E is the number of edges" - msg += "\n\t-(V, V), where V is the number of nodes (i.e. full rank)" - msg += f"However, the given graph had shape {graph.shape}." + rows, columns = graph.shape + if columns in (2, 3): + return _parse_sparse_matrix_format(graph) + elif rows == columns: + return _parse_adjacency_matrix(graph) + else: + msg = "Could not interpret input graph." + msg += "\nIf a graph is specified as a numpy array, it has to have one of the following shapes:" + msg += "\n\t-(E, 2) or (E, 3), where E is the number of edges" + msg += "\n\t-(V, V), where V is the number of nodes (i.e. full rank)" + msg += f"However, the given graph had shape {graph.shape}." def _parse_adjacency_matrix(adjacency): @@ -222,5 +221,39 @@ def parse_graph(graph): except ModuleNotFoundError: pass else: - allowed = ['list', 'tuple', 'set', 'networkx.Graph', 'igraph.Graph', 'graphtool.Graph'] + allowed = ['list', 'tuple', 'set', 'networkx.Graph', 'igraph.Graph', 'graph_tool.Graph'] + raise NotImplementedError("Input graph must be one of: {}\nCurrently, type(graph) = {}".format("\n\n\t" + "\n\t".join(allowed), type(graph))) + + +def is_order_zero(graph): + """Determine if a graph is an order zero graph, i.e. a graph with no nodes (and no edges).""" + for check, parser in _check_to_parser.items(): + try: + if check(graph): + nodes, edges, _ = parser(graph) + if (not nodes) and (not edges): + return True + else: + return False + except ModuleNotFoundError: + pass + else: + allowed = ['list', 'tuple', 'set', 'networkx.Graph', 'igraph.Graph', 'graph_tool.Graph'] + raise NotImplementedError("Input graph must be one of: {}\nCurrently, type(graph) = {}".format("\n\n\t" + "\n\t".join(allowed), type(graph))) + + +def is_empty(graph): + """Determine if a graph is an empty graph, i.e. a graph with nodes but no edges.""" + for check, parser in _check_to_parser.items(): + try: + if check(graph): + nodes, edges, _ = parser(graph) + if nodes and (not edges): + return True + else: + return False + except ModuleNotFoundError: + pass + else: + allowed = ['list', 'tuple', 'set', 'networkx.Graph', 'igraph.Graph', 'graph_tool.Graph'] raise NotImplementedError("Input graph must be one of: {}\nCurrently, type(graph) = {}".format("\n\n\t" + "\n\t".join(allowed), type(graph))) diff --git a/netgraph/_utils.py b/netgraph/_utils.py index 4694faf..5f3d324 100644 --- a/netgraph/_utils.py +++ b/netgraph/_utils.py @@ -380,22 +380,26 @@ def _shorten_line_by(path, distance): """ - distance_to_end = np.linalg.norm(path - path[-1], axis=1) - is_valid = (distance_to_end - distance) >= 0 - if np.any(is_valid): - idx = np.where(is_valid)[0][-1] # i.e. the last valid point - else: - idx = 0 + if distance > 0: + distance_to_end = np.linalg.norm(path - path[-1], axis=1) + is_valid = (distance_to_end - distance) >= 0 + if np.any(is_valid): + idx = np.where(is_valid)[0][-1] # i.e. the last valid point + else: + idx = 0 - # We could truncate the path using `path[:idx+1]` and return here. - # However, if the path is not densely sampled, the error will be large. - # Therefor, we compute a point that is on the line from the last valid point to - # the end point, and append it to the truncated path. - vector = path[idx] - path[-1] - unit_vector = vector / np.linalg.norm(vector) - new_end_point = path[-1] + distance * unit_vector + # We could truncate the path using `path[:idx+1]` and return here. + # However, if the path is not densely sampled, the error will be large. + # Therefor, we compute a point that is on the line from the last valid point to + # the end point, and append it to the truncated path. + vector = path[idx] - path[-1] + unit_vector = vector / np.linalg.norm(vector) + new_end_point = path[-1] + distance * unit_vector - return np.concatenate([path[:idx+1], new_end_point[None, :]], axis=0) + return np.concatenate([path[:idx+1], new_end_point[None, :]], axis=0) + + else: + return path def _get_point_along_spline(spline, fraction): diff --git a/optional-requirements.txt b/optional-requirements.txt index 8d4d82c..8e65ec5 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -1,2 +1,8 @@ pytest pytest-mpl +sphinx +sphinx-rtd-theme +numpydoc +sphinx-gallery +Pillow +networkx diff --git a/pyproject.toml b/pyproject.toml index a3edf3a..a3953a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,13 +42,13 @@ tests = [ "pytest", "pytest-mpl", ] -doc = [ - "sphinx", - "sphinx-rtd-theme", - "numpydoc", - "sphinx-gallery", - "Pillow", - "networkx", +docs = [ + "sphinx", + "sphinx-rtd-theme", + "numpydoc", + "sphinx-gallery", + "Pillow", + "networkx", ] [tool.setuptools.dynamic] diff --git a/setup.py b/setup.py index 78dfa23..8133eac 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ def read_file(filename): with open(os.path.join(os.path.dirname(__file__), filename)) as file: return file.read() -version = '4.12.4' +version = '4.12.12' setup( name='netgraph', @@ -29,7 +29,10 @@ def read_file(filename): ], platforms=['Platform Independent'], packages=find_packages(), - python_requires='>=3', + python_requires='>=3.6', install_requires=['numpy', 'matplotlib', 'scipy', 'rectangle-packer', 'grandalf'], - extras_require={'tests' : ['pytest', 'pytest-mpl']}, + extras_require={ + 'tests' : ['pytest', 'pytest-mpl'], + 'docs' : ['sphinx', 'sphinx-rtd-theme', 'numpydoc', 'sphinx-gallery', 'Pillow', 'networkx'], + }, )