Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
Unreleased

Behaviour change

- `Graph.plot()` and `Graph.highlight_path()` now default to `block=None`
instead of `False`. They no longer call `plt.show()` unless `block` is
specified, allowing notebooks and other environments to manage display.
Scripts and plain IPython sessions without `%matplotlib` or `plt.ion()`
that relied on `g.plot()` opening a window now need an explicit
`plt.show()` after drawing, or `block=False` for the previous behaviour.
`block=True` still waits for the window to close.

Performance

- `closest()` and `near()` no longer make a Python-level pass over the vertices
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ This needs Python 3.9 or later. The dependencies, NumPy, Matplotlib and Spatial

```python
from pgraph import UGraph
import matplotlib.pyplot as plt
import json

# load the places and routes
Expand All @@ -75,8 +76,9 @@ for route in data['routes']:

# plan a path from Hughenden to Brisbane
p, length, _ = g.path_Astar('Hughenden', 'Brisbane')
g.plot(block=False) # plot it
g.highlight_path(p) # overlay the path
g.plot() # create the graph without displaying it yet
g.highlight_path(p) # overlay the path on the same axes
plt.show() # show the finished figure in a script
```

The data file and the complete example are in the notebook [`examples/roads.ipynb`](https://github.com/petercorke/pgraph-python/blob/main/examples/roads.ipynb).
Expand Down
2 changes: 1 addition & 1 deletion docs/llms_intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ g.highlight_path(path)
- **Metrics.** `"L2"` (default), `"L1"`, `"SE2"` for `(x, y, theta)` poses, `SE2Metric(weight)` to weight the angle, or a function of one difference vector. A* uses `heuristic` (default: the metric), which must be in the same units as the edge costs and must not exceed the true cost to go, or the path is not the shortest.
- **Spatial queries** on an embedded graph: `closest(point)` returns `(vertex, distance)`, `near(point, radius)`, `within(box)` with `box` a 2 x n array of lower and upper corner, `coordinates()` the `(dim, n)` matrix of coordinates, and `subgraph(vertices)`, which takes vertices, names or a boolean mask and returns a new graph. `build_kdtree()` is an optional index (it needs scipy) that `closest` and `near` then use.
- **Components and matrices.** `g.component(c)` is the vertices in component number `c`, `v.label` is the component of a vertex, `g.samecomponent(v1, v2)`. `adjacency()`, `incidence()`, `laplacian()`, `degree()` and `connectivity()` return dense NumPy arrays.
- **Output.** `g.plot()` (arrowheads for a directed graph, arcs for parallel edges, circles for loops), `highlight_path(path)`, and `g.export(file)` for GraphViz dot, Mermaid, GraphML, ELK JSON or an image (images need the GraphViz executables).
- **Output.** `g.plot()` (arrowheads for a directed graph, arcs for parallel edges, circles for loops), `highlight_path(path)`, and `g.export(file)` for GraphViz dot, Mermaid, GraphML, ELK JSON or an image (images need the GraphViz executables). Both methods default to `block=None`, leaving figure display to the environment. In scripts, use `plt.show()` after composing a figure, or `block=False` for the previous nonblocking show; `block=True` blocks until closed.
- **Subclassing.** `class Foo(Vertex)` and `UGraph(vertex_class=Foo, edge_class=Bar)` make the graph build them; a subclass `__init__` must pass `coord` and `name` (vertices) or `v1, v2, cost, data` (edges) to its parent.
- **Copying.** `g.copy()` and `pickle` work for a whole graph; a vertex or edge cannot be copied or pickled on its own, and a graph whose metric is a `lambda` cannot be pickled.

Expand Down
3 changes: 3 additions & 0 deletions docs/source/intro.rst
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ The graph can be plotted by

>>> g.plot()

In a regular Python script, call ``plt.show()`` after drawing to display the
figure. Sphinx's ``plot`` directive and interactive notebooks handle display.

.. plot::

>>> import pgraph
Expand Down
4 changes: 2 additions & 2 deletions examples/roads.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -406,8 +406,8 @@
}
],
"source": [
"g.plot(block=None) # block=None: show it once, with the path\n",
"g.highlight_path(p, alpha=0.5, scale=2, block=None)"
"g.plot() # draw; the notebook displays the completed figure\n",
"g.highlight_path(p, alpha=0.5, scale=2)"
]
},
{
Expand Down
2 changes: 2 additions & 0 deletions examples/roads.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
from pgraph import UGraph, DGraph
import matplotlib.pyplot as plt

# load the JSON file
with open('queensland.json', 'r') as f:
Expand Down Expand Up @@ -32,6 +33,7 @@
# plot the graph, and overlay the path
g.plot()
g.highlight_path(p, alpha=0.5, scale=2)
plt.show() # show the completed figure in a script

# turn the vertex parent information into a tree, it's a dict that maps a
# vertex to its parent.
Expand Down
47 changes: 29 additions & 18 deletions src/pgraph/PGraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -2517,7 +2517,7 @@ def plot(
vopt: dict | None = None,
eopt: dict | None = None,
text: dict | bool | None = None,
block: bool | None = False,
block: bool | None = None,
grid: bool = True,
ax: Any = None,
*,
Expand Down Expand Up @@ -2560,14 +2560,19 @@ def plot(
:type grid: bool, optional
:param ax: axes to draw in, defaults to the current axes or a new figure
:type ax: Axes or Axes3D, optional
:param block: block until figure is dismissed, defaults to False. If
None the figure is not shown at all, which is useful when
adding to the plot or saving it.
:param block: None (default) leaves display to the environment without
calling ``plt.show()``; False shows without waiting; True waits
until the window is closed (desktop GUI backends).
:type block: bool or None, optional
:raises ValueError: the graph is empty, or a vertex has no coordinate
or fewer than two coordinate values
:raises TypeError: an option is given by both its old and new name

.. versionchanged:: 1.1
The default for ``block`` changed from ``False`` to ``None``.
The method no longer calls ``plt.show()`` unless ``block`` is
explicitly given; pass ``block=False`` for the old behaviour.

The graph is plotted using Matplotlib according to the dimensionality of the vertex
coordinates (set by the `dim` parameter to graph constructor). If the dimensionality is

Expand Down Expand Up @@ -2610,7 +2615,7 @@ def plot(
... g.add_vertex(np.random.rand(2));
>>> for i, j in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)]:
... g.add_edge(g[i], g[j]);
>>> g.plot(block=None)
>>> g.plot()

.. plot::

Expand All @@ -2622,7 +2627,7 @@ def plot(
... g.add_vertex(np.random.rand(2));
>>> for i, j in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)]:
... g.add_edge(g[i], g[j]);
>>> g.plot(block=None)
>>> g.plot()

A directed graph, with a pair of antiparallel edges, a pair of parallel
edges and a loop
Expand All @@ -2635,7 +2640,7 @@ def plot(
... g.add_vertex(xy, name=name);
>>> for a, b in ["AB", "AB", "AD", "BC", "CB", "DC", "CC"]:
... g.add_edge(a, b);
>>> g.plot(block=None)
>>> g.plot()

:seealso: :meth:`highlight_path` :meth:`highlight_edge` :meth:`show`
"""
Expand Down Expand Up @@ -2767,19 +2772,25 @@ def plot(
plt.show(block=block)

def highlight_path(
self, path: list[Vertex], block: bool | None = False, **kwargs: Any
self, path: list[Vertex], block: bool | None = None, **kwargs: Any
) -> None:
"""
Highlight a path through the graph

:param path: sequence of vertices forming a path
:type path: list of Vertex subclass
:param block: block until figure is dismissed, defaults to False. If
None the figure is not shown.
:param block: None (default) leaves display to the environment without
calling ``plt.show()``; False shows without waiting; True waits
until the window is closed (desktop GUI backends).
:type block: bool or None, optional
:param kwargs: arguments passed to :meth:`highlight_edge` and
:meth:`highlight_vertex`

.. versionchanged:: 1.1
The default for ``block`` changed from ``False`` to ``None``.
The method no longer calls ``plt.show()`` unless ``block`` is
explicitly given; pass ``block=False`` for the old behaviour.

The vertices and edges along the path are overwritten with a different
size/width and color.

Expand All @@ -2793,9 +2804,9 @@ def highlight_path(
... g.add_vertex(np.random.rand(2));
>>> for i, j in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)]:
... g.add_edge(g[i], g[j]);
>>> g.plot(block=None)
>>> g.plot()
>>> path, length, _ = g.path_Astar(g[2], g[4])
>>> g.highlight_path(path, block=None)
>>> g.highlight_path(path)

.. plot::

Expand All @@ -2807,9 +2818,9 @@ def highlight_path(
... g.add_vertex(np.random.rand(2));
>>> for i, j in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)]:
... g.add_edge(g[i], g[j]);
>>> g.plot(block=None)
>>> g.plot()
>>> path, length, _ = g.path_Astar(g[2], g[4])
>>> g.highlight_path(path, block=None)
>>> g.highlight_path(path)

:seealso: :meth:`highlight_vertex` :meth:`highlight_edge`
"""
Expand Down Expand Up @@ -2853,7 +2864,7 @@ def highlight_edge(
>>> edges = []
>>> for i, j in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)]:
... edges.append(g.add_edge(g[i], g[j]));
>>> g.plot(block=None)
>>> g.plot()
>>> g.highlight_edge(edges[2])

.. plot::
Expand All @@ -2867,7 +2878,7 @@ def highlight_edge(
>>> edges = []
>>> for i, j in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)]:
... edges.append(g.add_edge(g[i], g[j]));
>>> g.plot(block=None)
>>> g.plot()
>>> g.highlight_edge(edges[2])

:seealso: :meth:`plot` :meth:`highlight_vertex` :meth:`highlight_path`
Expand Down Expand Up @@ -2926,7 +2937,7 @@ def highlight_vertex(
... g.add_vertex(np.random.rand(2));
>>> for i, j in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)]:
... g.add_edge(g[i], g[j]);
>>> g.plot(block=None)
>>> g.plot()
>>> g.highlight_vertex(g[2])

.. plot::
Expand All @@ -2939,7 +2950,7 @@ def highlight_vertex(
... g.add_vertex(np.random.rand(2));
>>> for i, j in [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (3, 4)]:
... g.add_edge(g[i], g[j]);
>>> g.plot(block=None)
>>> g.plot()
>>> g.highlight_vertex(g[2])

"""
Expand Down
71 changes: 71 additions & 0 deletions tests/test_plot_block.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Regression tests for the three-state block= plotting contract."""

import unittest
from unittest.mock import patch

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt

from pgraph import UGraph


class TestPlotBlock(unittest.TestCase):
def setUp(self) -> None:
self.graph = UGraph()
a = self.graph.add_vertex([0.0, 0.0], name="a")
b = self.graph.add_vertex([1.0, 1.0], name="b")
self.graph.add_edge(a, b)
self.path = [a, b]

def tearDown(self) -> None:
plt.close("all")

def test_default_plot_and_highlight_do_not_call_show(self) -> None:
with patch.object(plt, "show") as show:
self.graph.plot()
axes = plt.gca()
before = len(axes.patches)
self.graph.highlight_path(self.path)
show.assert_not_called()
self.assertIs(plt.gca(), axes)
self.assertGreater(len(axes.patches), before)

def test_default_plot_then_explicit_highlight_shows_once(self) -> None:
with patch.object(plt, "show") as show:
self.graph.plot()
ax = plt.gca()
count = len(ax.patches)
show.assert_not_called()
self.graph.highlight_path(self.path, block=False)
show.assert_called_once_with(block=False)
self.assertIs(plt.gca(), ax)
self.assertGreater(len(ax.patches), count)

def test_plot_with_caller_axes_and_none(self) -> None:
fig, ax = plt.subplots()
with patch.object(plt, "show") as show:
self.graph.plot(block=None, ax=ax)
show.assert_not_called()
self.assertIs(ax.figure, fig)
self.assertGreater(len(ax.patches), 0)

def test_explicit_plot_show_modes(self) -> None:
for block in (False, True):
with self.subTest(block=block):
with patch.object(plt, "show") as show:
self.graph.plot(block=block)
show.assert_called_once_with(block=block)

def test_explicit_highlight_show_modes(self) -> None:
self.graph.plot()
for block in (False, True):
with self.subTest(block=block):
with patch.object(plt, "show") as show:
self.graph.highlight_path(self.path, block=block)
show.assert_called_once_with(block=block)


if __name__ == "__main__":
unittest.main()