From e8f2d8ab87432f190e2315987a4e1121be2a5126 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Mon, 12 Oct 2026 13:10:36 +1000 Subject: [PATCH 1/3] feat: plot() draws arrowheads, parallel edges and loops plot() drew a straight line from each vertex to each neighbour, so a directed graph looked like an undirected one, an undirected edge was drawn twice, parallel edges lay exactly on top of each other, and loops were not visible at all. Each edge is now drawn once, as an arc patch: - a directed graph has arrowheads, at the end vertex, stopping on the rim of the vertex marker - edges that join the same pair of vertices, whichever way they point, bow to either side, so parallel and antiparallel edges can all be seen - a loop is a circle at its vertex, sized in points, rotated and grown for the next one at that vertex, and not clipped by the axes - 3D plots have the same, following a rotation of the view - highlight_edge() and highlight_path() follow the arc The options are named vertex_opt, edge_opt, arrow_opt, text_opt, labels, color_components and force_2d. The 0.6.5 names (vopt, eopt, text, colorcomponents, force2d) still work, with a DeprecationWarning that names the replacement; both an old and its new name is a TypeError; the positional order is unchanged. text=False is labels=False and a text dict is text_opt. edge_opt keys that are not edge options (it took Line2D options) are reported rather than ignored. Without component colors every vertex and edge is now one color, where it had been each in turn from the color cycle. Drawing is about 2x slower than the lines it replaces: 3000 edges plot in 1.2 s and draw in 0.7 s. Co-Authored-By: Claude Sonnet 5.5 --- src/pgraph/PGraph.py | 456 ++++++++++++++++++++++++++++++-------- tests/test_plot_args.py | 29 +-- tests/test_plot_arrows.py | 392 ++++++++++++++++++++++++++++++++ 3 files changed, 774 insertions(+), 103 deletions(-) create mode 100644 tests/test_plot_arrows.py diff --git a/src/pgraph/PGraph.py b/src/pgraph/PGraph.py index 75dc7e4b..221d1831 100644 --- a/src/pgraph/PGraph.py +++ b/src/pgraph/PGraph.py @@ -5,6 +5,10 @@ import warnings import numpy as np import matplotlib.pyplot as plt +from matplotlib.patches import FancyArrowPatch +from matplotlib.path import Path +from matplotlib.transforms import Affine2D, ScaledTranslation +from mpl_toolkits.mplot3d import proj3d import copy from collections.abc import Iterable, Iterator import tempfile @@ -45,6 +49,94 @@ def _SE2(v: NDArray) -> float: } +#: curvature, as the ``rad`` of an ``arc3`` connection style, between successive +#: edges that join the same pair of vertices +_ARC_STEP = 0.35 +#: radius in points of the first loop at a vertex, and the growth for each next +_LOOP_RADIUS = 20.0 +_LOOP_GROWTH = 6.0 +#: angle in degrees at which the first loop leaves a vertex, and the turn for each next +_LOOP_ANGLE = 60.0 +_LOOP_TURN = 75.0 + + +def _renamed( + func: str, old_name: str, old: Any, new_name: str, new: Any, stacklevel: int = 3 +) -> Any: + """ + Resolve an option that has been renamed (private function) + + Returns ``new`` if the old name was not used. Using the old name is still + accepted, with a :exc:`DeprecationWarning` that names its replacement. + Using both is a :exc:`TypeError`, since it is not clear which is meant. + """ + if old is None: + return new + if new is not None: + raise TypeError( + f"{func}() got both {old_name} and {new_name}; " + f"{old_name} is deprecated, use {new_name} only" + ) + warnings.warn( + f"{func}(): {old_name} is deprecated, use {new_name}", + DeprecationWarning, + stacklevel=stacklevel, + ) + return old + + +def _loop_path(angle: float, radius: float, markersize: float) -> Path: + """ + The path of a loop at a vertex, in points from the vertex (private function) + + A circle of ``radius`` whose centre is that far from the vertex, in the + direction ``angle`` degrees, less the part inside the vertex's marker. It + is counter-clockwise, so a directed loop has its head where it returns. + """ + m = markersize / 2 + phi = np.degrees(np.arccos(np.clip(1 - m * m / (2 * radius * radius), -1, 1))) + arc = Path.arc(angle + 180 + phi, angle + 540 - phi) + centre = radius * np.array([np.cos(np.radians(angle)), np.sin(np.radians(angle))]) + return Path(np.asarray(arc.vertices) * radius + centre, arc.codes) + + +class _Arrow3D(FancyArrowPatch): + """An edge between two points of a 3D plot, which follows a rotation""" + + def __init__(self, p1: ArrayLike, p2: ArrayLike, **kwargs: Any): + super().__init__((0, 0), (0, 0), **kwargs) + self._p1, self._p2 = np.asarray(p1, float), np.asarray(p2, float) + + def do_3d_projection(self, renderer: Any = None) -> float: + ax: Any = self.axes + xs, ys, zs = proj3d.proj_transform( + [self._p1[0], self._p2[0]], + [self._p1[1], self._p2[1]], + [self._p1[2], self._p2[2]], + ax.M, + ) + self.set_positions((xs[0], ys[0]), (xs[1], ys[1])) + return float(np.min(zs)) + + +class _Loop3D(FancyArrowPatch): + """A loop at a vertex of a 3D plot, which follows a rotation""" + + def __init__(self, anchor: ArrayLike, path: Path, **kwargs: Any): + super().__init__(path=path, **kwargs) + self._anchor = np.asarray(anchor, float) + + def do_3d_projection(self, renderer: Any = None) -> float: + ax: Any = self.axes + x, y, z = proj3d.proj_transform(*self._anchor, ax.M) + self.set_transform( + Affine2D().scale(1 / 72) + + ax.figure.dpi_scale_trans + + ScaledTranslation(x, y, ax.transData) + ) + return float(z) + + class Graph: """ Base class for all graphs @@ -1668,58 +1760,178 @@ def edges(self) -> list[Edge]: """ return list(self._edges) + def _edge_layout(self) -> tuple[dict[Edge, float], dict[Edge, int]]: + """ + How each edge is to be drawn, apart from its end points (private method) + + :return: the curvature of every edge that joins two vertices, and the + number of every loop among the loops at its vertex + + Edges that join the same pair of vertices, whichever way they point, + are drawn as arcs that bow to either side, so that parallel and + antiparallel edges can all be seen. A single edge is straight, with + curvature 0. Loops are numbered from 0 at each vertex. + """ + index = {v: i for i, v in enumerate(self)} + groups: dict[frozenset[int], list[Edge]] = {} + loops: dict[Vertex, list[Edge]] = {} + for e in self._edges: + assert e.v1 is not None and e.v2 is not None + if e.v1 is e.v2: + loops.setdefault(e.v1, []).append(e) + else: + groups.setdefault(frozenset((index[e.v1], index[e.v2])), []).append(e) + rads: dict[Edge, float] = {} + for edges in groups.values(): + n = len(edges) + for k, e in enumerate(edges): + assert e.v1 is not None and e.v2 is not None + offset = (k - (n - 1) / 2) * _ARC_STEP + # the same side in space for an edge drawn the other way round + rads[e] = offset if index[e.v1] < index[e.v2] else -offset + loopno = {e: k for edges in loops.values() for k, e in enumerate(edges)} + return rads, loopno + + def _edge_artist( + self, + ax: Any, + edge: Edge, + rad: float, + loopno: int, + markersize: float, + ndim: int, + **kwargs: Any, + ) -> Any: + """ + The matplotlib artist for one edge (private method) + + :param rad: curvature of an edge that is not a loop, see + :meth:`_edge_layout` + :param loopno: number of this edge among the loops at its vertex + :param markersize: size in points of a vertex marker, so that the edge + stops at the rim of the marker, not at the center of the vertex + :param ndim: 2 for a 2D axes, 3 for a 3D axes + :param kwargs: options of the patch: color, linewidth, arrowstyle, ... + :return: the artist, to be added to ``ax`` + """ + v1, v2 = edge.v1, edge.v2 + assert v1 is not None and v2 is not None + shrink = markersize / 2 + 1 + if v1 is v2: + radius = _LOOP_RADIUS + _LOOP_GROWTH * loopno + path = _loop_path(_LOOP_ANGLE + _LOOP_TURN * loopno, radius, markersize) + # a loop is a size in points, so it can reach beyond the limits of + # the axes, which are in data units: do not clip it there + options = {**dict(shrinkA=0, shrinkB=0, clip_on=False), **kwargs} + if ndim == 3: + return _Loop3D((v1.x, v1.y, v1.z), path, **options) + transform = ( + Affine2D().scale(1 / 72) + + ax.figure.dpi_scale_trans + + ScaledTranslation(v1.x, v1.y, ax.transData) + ) + return FancyArrowPatch(path=path, transform=transform, **options) + + options = { + **dict( + shrinkA=shrink, shrinkB=shrink, connectionstyle=f"arc3,rad={rad}" + ), + **kwargs, + } + if ndim == 3: + return _Arrow3D((v1.x, v1.y, v1.z), (v2.x, v2.y, v2.z), **options) + return FancyArrowPatch((v1.x, v1.y), (v2.x, v2.y), **options) + def plot( self, - colorcomponents: bool = True, - force2d: bool = False, + colorcomponents: bool | None = None, + force2d: bool | None = None, vopt: dict | None = None, eopt: dict | None = None, text: dict | bool | None = None, block: bool | None = False, grid: bool = True, ax: Any = None, + *, + vertex_opt: dict | None = None, + edge_opt: dict | None = None, + arrow_opt: dict | None = None, + text_opt: dict | None = None, + labels: bool | None = None, + color_components: bool | None = None, + force_2d: bool | None = None, ) -> None: """ Plot graph as a vertex/edge diagram using Matplotlib - :param vopt: vertex format, defaults to 12pt o-marker - :type vopt: dict, optional - :param eopt: edge format, defaults to 3pt line width - :type eopt: dict, optional - :param text: text label format, defaults to None - :type text: dict, bool or None, optional - :param colorcomponents: color vertices and edges by component, defaults to True - :type colorcomponents: bool, optional - :param force2d: plot only the first two coordinates of each vertex + :param vertex_opt: vertex format, a dict of Matplotlib ``plot`` + options, defaults to a 12 point circle marker + :type vertex_opt: dict, optional + :param edge_opt: edge format, a dict of Matplotlib patch options such as + ``color``, ``linewidth`` and ``linestyle``, defaults to a line width + of 3 + :type edge_opt: dict, optional + :param arrow_opt: arrowhead and path format, a dict of Matplotlib + ``FancyArrowPatch`` options such as ``arrowstyle``, + ``mutation_scale`` and ``connectionstyle``, defaults to + ``dict(arrowstyle="-|>", mutation_scale=15)`` for a directed graph + and ``dict(arrowstyle="-")``, no head, for an undirected graph + :type arrow_opt: dict, optional + :param labels: label each vertex with its name, defaults to True + :type labels: bool, optional + :param text_opt: format of the labels, a dict of Matplotlib ``text`` + options + :type text_opt: dict, optional + :param color_components: color vertices and edges by component, + defaults to True + :type color_components: bool, optional + :param force_2d: plot only the first two coordinates of each vertex even if they have more, defaults to False - :type force2d: bool, optional + :type force_2d: bool, optional + :param grid: show a grid, defaults to True + :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. :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 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 - + - 2, then a 2D plot is generated. - 3, then a 3D plot is generated. - 4 or more, then a 3D plot is generated using the first three coordinates. If a vertex has no coordinate or fewer than two values, a ValueError is raised. - If ``colorcomponents`` is True then all edges and vertices within a component are assigned a unique - color. + Every edge is drawn once. In a directed graph it ends in an arrowhead + at its end vertex. Edges that join the same pair of vertices, whichever + way they point, are drawn as arcs that bow to either side so that + parallel and antiparallel edges can all be seen, and a loop is drawn as + a circle at its vertex. - If ``text`` is a dict it is used to format text labels for the vertices - which are the vertex names. If ``text`` is None or True default - formatting is used. If ``text`` is False no labels are added. + If ``color_components`` is True then all edges and vertices within a component are assigned a unique + color. .. note:: Only embedded graphs can be plotted: every vertex must have a coordinate of at least two values. A coordinate of more than two values is drawn in 3D, using the first three. + .. versionchanged:: 1.0 + A directed graph is drawn with arrowheads. In 0.6.5 it was + indistinguishable from an undirected one, parallel edges were + drawn on top of each other and loops were not visible. The + options are named ``vertex_opt``, ``edge_opt``, ``arrow_opt`` and + ``text_opt``. ``vopt``, ``eopt``, ``text``, ``colorcomponents`` + and ``force2d`` are deprecated: they still work and give a + :exc:`DeprecationWarning`. ``text=False`` is ``labels=False`` and + a ``text`` dict is ``text_opt``. + .. runblock:: pycon >>> from pgraph import UGraph @@ -1744,8 +1956,50 @@ def plot( ... g.add_edge(g[i], g[j]); >>> g.plot(block=None) - :seealso: :meth:`highlight_path` + A directed graph, with a pair of antiparallel edges, a pair of parallel + edges and a loop + + .. plot:: + + >>> from pgraph import DGraph + >>> g = DGraph() + >>> for name, xy in dict(A=(0, 1), B=(2, 1.7), C=(3.3, 0.3), D=(1.2, -0.5)).items(): + ... 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) + + :seealso: :meth:`highlight_path` :meth:`highlight_edge` """ + # the options that have been renamed + vertex_opt = _renamed("plot", "vopt", vopt, "vertex_opt", vertex_opt) + edge_opt = _renamed("plot", "eopt", eopt, "edge_opt", edge_opt) + color_components = _renamed( + "plot", "colorcomponents", colorcomponents, "color_components", color_components + ) + force_2d = _renamed("plot", "force2d", force2d, "force_2d", force_2d) + if text is not None: + if labels is not None or text_opt is not None: + raise TypeError( + "plot() got both text and labels or text_opt; " + "text is deprecated, use labels and text_opt only" + ) + warnings.warn( + "plot(): text is deprecated, use labels=False or text_opt=dict(...)", + DeprecationWarning, + stacklevel=2, + ) + if text is False: + labels = False + elif isinstance(text, dict): + text_opt = text + if color_components is None: + color_components = True + if force_2d is None: + force_2d = False + if labels is None: + labels = True + if self.n == 0: raise ValueError("cannot plot an empty graph") ndim = 0 # number of coordinate values, taken from the first vertex @@ -1758,75 +2012,87 @@ def plot( if i == 0: ndim = len(vertex.coord) - vopt = {**dict(marker="o", markersize=12), **(vopt or {})} - eopt = {**dict(linewidth=3), **(eopt or {})} - # text labels: None or True is default formatting, False is no labels - textopt: dict | None = None - if text is not False: - textopt = text if isinstance(text, dict) else {} + # without component colors every vertex and edge is the first color of + # the cycle, rather than each in turn + same = {} if color_components else dict(color="C0") + vertex_opt = {**dict(marker="o", markersize=12), **same, **(vertex_opt or {})} + arrow_opt = { + **dict( + arrowstyle="-|>" if self.directed else "-", mutation_scale=15 + ), + **(arrow_opt or {}), + } + # the edge options are Line2D options in 0.6.5: keep those that a patch + # also has, and say so about the rest + patch_opt: dict[str, Any] = {} + for key, value in {**dict(linewidth=3), **same, **(edge_opt or {})}.items(): + if hasattr(FancyArrowPatch, "set_" + key): + patch_opt[key] = value + else: + warnings.warn( + f"plot(): edge_opt {key!r} does not apply to an edge and is ignored", + UserWarning, + stacklevel=2, + ) + edge_kwargs = {**patch_opt, **arrow_opt} - if colorcomponents: + if color_components: color = plt.cm.coolwarm(np.linspace(0, 1, self.nc)) - if ndim == 2 or force2d: - # 2D plotting - if ax is None: - ax = axes_logic(ax, 2) - for c in range(self.nc): - # for each component - for vertex in self.component(c): - if textopt is not None: - ax.text(vertex.x, vertex.y, f" {vertex.name}", **textopt) - if colorcomponents: - ax.plot(vertex.x, vertex.y, color=color[c, :], **vopt) - for v in vertex.adjacent(): - ax.plot( - [vertex.x, v.x], - [vertex.y, v.y], - color=color[c, :], - **eopt, - ) - else: - ax.plot(vertex.x, vertex.y, **vopt) - for v in vertex.adjacent(): - ax.plot([vertex.x, v.x], [vertex.y, v.y], **eopt) + draw3d = not (ndim == 2 or force_2d) + if draw3d: + ax = axes_logic(ax, 3) else: - # 3D or higher plotting, just do (x, y, z) - if ax is None: - ax = axes_logic(ax, 3) - for c in range(self.nc): - # for each component - for vertex in self.component(c): - if textopt is not None: - ax.text( - vertex.x, vertex.y, vertex.z, f" {vertex.name}", **textopt - ) - if colorcomponents: - ax.plot( - vertex.x, - vertex.y, - vertex.z, - **{**dict(color=color[c, :]), **vopt}, - ) - for v in vertex.adjacent(): - ax.plot( - [vertex.x, v.x], - [vertex.y, v.y], - [vertex.z, v.z], - **{**dict(color=color[c, :]), **eopt}, - ) - else: - ax.plot(vertex.x, vertex.y, vertex.z, **vopt) - for v in vertex.adjacent(): - ax.plot( - [vertex.x, v.x], - [vertex.y, v.y], - [vertex.z, v.z], - **eopt, - ) - # if nc > 1: - # # add a colorbar - # plt.colorbar() + ax = axes_logic(ax, 2) + + # the vertices, and their labels + for vertex in self: + coords = (vertex.x, vertex.y, vertex.z) if draw3d else (vertex.x, vertex.y) + opt = dict(vertex_opt) + if color_components: + opt = {**dict(color=color[vertex.label, :]), **opt} + ax.plot(*coords, **opt) + if labels: + ax.text(*coords, f" {vertex.name}", **(text_opt or {})) + + # the edges, each once + rads, loopno = self._edge_layout() + for e in self._edges: + assert e.v1 is not None + kwargs = dict(edge_kwargs) + if color_components: + kwargs = {**dict(color=color[e.v1.label, :]), **kwargs} + artist = self._edge_artist( + ax, + e, + rads.get(e, 0.0), + loopno.get(e, 0), + vertex_opt["markersize"], + 3 if draw3d else 2, + **kwargs, + ) + if draw3d: + ax.add_artist(artist) + else: + ax.add_patch(artist) + + if loopno and not draw3d: + # a loop is a size in points from its vertex, which matplotlib does + # not count in the limits of the axes, so make room for the largest + ax.autoscale_view() + reach = 2 * (_LOOP_RADIUS + _LOOP_GROWTH * max(loopno.values())) + pixels = (reach + vertex_opt["markersize"]) * ax.figure.dpi / 72 + (x0, y0), (x1, y1) = ax.transData.inverted().transform( + [(0, 0), (pixels, pixels)] + ) + dx, dy = abs(x1 - x0), abs(y1 - y0) + for e in loopno: + assert e.v1 is not None + ax.update_datalim( + [(e.v1.x - dx, e.v1.y - dy), (e.v1.x + dx, e.v1.y + dy)] + ) + ax.autoscale_view() + ax.grid(grid) if block is not None: plt.show(block=block) @@ -1936,12 +2202,24 @@ def highlight_edge( :seealso: :meth:`plot` :meth:`highlight_vertex` :meth:`highlight_path` """ - p1 = edge.v1 - p2 = edge.v2 - if p1 is None or p2 is None: + if edge.v1 is None or edge.v2 is None: raise ValueError("edge does not connect two vertices") - plt.plot( - [p1.x, p2.x], [p1.y, p2.y], color=color, linewidth=3 * scale, alpha=alpha + # over the edge as plot() draws it, so that it follows an arc + ax = plt.gca() + rads, loopno = self._edge_layout() + ax.add_patch( + self._edge_artist( + ax, + edge, + rads.get(edge, 0.0), + loopno.get(edge, 0), + 12, + 2, + arrowstyle="-", + color=color, + linewidth=3 * scale, + alpha=alpha, + ) ) def highlight_vertex( diff --git a/tests/test_plot_args.py b/tests/test_plot_args.py index 435f8416..3c7cc234 100644 --- a/tests/test_plot_args.py +++ b/tests/test_plot_args.py @@ -2,7 +2,8 @@ plot() / highlight_*() argument handling and the errors they give for graphs that cannot be drawn. These were found by mypy: ``text=None`` and ``text=True`` crashed although the docstring documents ``None`` as "default -formatting", and unplottable graphs gave a bare TypeError/IndexError. +formatting", and unplottable graphs gave a bare TypeError/IndexError. The +0.6.5 names of the options are in test_plot_arrows.py. """ import unittest @@ -32,28 +33,28 @@ def test_text_default_labels_vertices(self): self.g.plot(block=None) self.assertEqual(sorted(self.texts()), ["a", "b"]) - def test_text_none_means_default_formatting(self): - self.g.plot(text=None, block=None) + def test_text_opt_none_means_default_formatting(self): + self.g.plot(text_opt=None, block=None) self.assertEqual(sorted(self.texts()), ["a", "b"]) - def test_text_true_means_default_formatting(self): - self.g.plot(text=True, block=None) + def test_labels_true_means_default_formatting(self): + self.g.plot(labels=True, block=None) self.assertEqual(sorted(self.texts()), ["a", "b"]) - def test_text_false_means_no_labels(self): - self.g.plot(text=False, block=None) + def test_labels_false_means_no_labels(self): + self.g.plot(labels=False, block=None) self.assertEqual(self.texts(), []) - def test_text_dict_is_the_format(self): - self.g.plot(text={"color": "r"}, block=None) + def test_text_opt_is_the_format(self): + self.g.plot(text_opt={"color": "r"}, block=None) self.assertEqual({t.get_color() for t in plt.gca().texts}, {"r"}) - def test_vopt_eopt_none(self): - self.g.plot(vopt=None, eopt=None, block=None) + def test_options_none(self): + self.g.plot(vertex_opt=None, edge_opt=None, arrow_opt=None, text_opt=None, block=None) def test_options_are_not_shared_between_calls(self): # a mutable default argument must not accumulate state - self.g.plot(vopt={"markersize": 3}, block=None) + self.g.plot(vertex_opt={"markersize": 3}, block=None) plt.close("all") self.g.plot(block=None) sizes = {line.get_markersize() for line in plt.gca().lines} @@ -64,7 +65,7 @@ def test_3d_text_none(self): a = g.add_vertex([0, 0, 0], name="a") b = g.add_vertex([1, 1, 1], name="b") g.add_edge(a, b) - g.plot(text=None, block=None) + g.plot(text_opt=None, block=None) self.assertEqual(sorted(self.texts()), ["a", "b"]) def test_3d_vertices_plotted_at_their_z(self): @@ -73,7 +74,7 @@ def test_3d_vertices_plotted_at_their_z(self): a = g.add_vertex([0, 0, 5], name="a") b = g.add_vertex([1, 1, 7], name="b") g.add_edge(a, b) - g.plot(colorcomponents=False, block=None) + g.plot(color_components=False, block=None) zs = set() for line in plt.gca().lines: x, y, z = line.get_data_3d() diff --git a/tests/test_plot_arrows.py b/tests/test_plot_arrows.py new file mode 100644 index 00000000..a92c18f3 --- /dev/null +++ b/tests/test_plot_arrows.py @@ -0,0 +1,392 @@ +""" +plot() draws each edge once, as an arc patch: a directed graph with +arrowheads, parallel and antiparallel edges bowed apart, and loops. + +In 0.6.5 every vertex drew a straight line to each of its neighbours, so a +directed graph looked like an undirected one, an undirected edge was drawn +twice, parallel edges were drawn on top of each other, and loops were not +visible at all. +""" + +import unittest +import warnings + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.patches import ArrowStyle, ConnectionStyle, FancyArrowPatch + +from pgraph import DGraph, UGraph +from pgraph.PGraph import _Arrow3D, _Loop3D + + +def build(cls, edges, loops=(), dim=2): + """Four vertices a-d on a unit square, and edges named by their letters""" + g = cls() + xy = dict(a=(0, 0), b=(2, 0), c=(2, 2), d=(0, 2)) + for name, c in xy.items(): + g.add_vertex(list(c) + [0] * (dim - 2), name=name) + made = [g.add_edge(u, v) for u, v in edges] + return g, made + + +class _Plot(unittest.TestCase): + def tearDown(self): + plt.close("all") + + def draw(self, g, **kwargs): + fig, ax = plt.subplots() + g.plot(ax=ax, block=None, **kwargs) + fig.canvas.draw() + return ax + + def arrows(self, ax): + return [p for p in ax.patches if isinstance(p, FancyArrowPatch)] + + +class TestEdgesAreDrawnOnce(_Plot): + def test_one_patch_per_edge_undirected(self): + g, _ = build(UGraph, [("a", "b"), ("b", "c"), ("c", "d")]) + self.assertEqual(len(self.arrows(self.draw(g))), 3) # was 6: each edge twice + + def test_one_patch_per_edge_directed(self): + g, _ = build(DGraph, [("a", "b"), ("b", "c"), ("c", "d")]) + self.assertEqual(len(self.arrows(self.draw(g))), 3) + + def test_parallel_and_loops_each_have_a_patch(self): + g, _ = build(UGraph, [("a", "b"), ("a", "b"), ("c", "c"), ("c", "c"), ("d", "a")]) + self.assertEqual(len(self.arrows(self.draw(g))), g.ne) + + +class TestArrowheads(_Plot): + def style(self, patch): + return patch.get_arrowstyle() + + def test_directed_edges_have_heads(self): + g, _ = build(DGraph, [("a", "b"), ("b", "c")]) + for p in self.arrows(self.draw(g)): + self.assertIsInstance(self.style(p), ArrowStyle.CurveFilledB) + + def test_undirected_edges_have_none(self): + g, _ = build(UGraph, [("a", "b"), ("b", "c")]) + for p in self.arrows(self.draw(g)): + self.assertIsInstance(self.style(p), ArrowStyle.Curve) + + def test_head_is_at_the_end_vertex(self): + g, _ = build(DGraph, [("a", "b")]) + (p,) = self.arrows(self.draw(g)) + # the shaft starts at a (0, 0) and the head is out at b (2, 0): the + # path has the head's vertices, so the end is its largest x + path = p.get_path().vertices + self.assertLess(path[0][0], 0.5) + self.assertGreater(path[:, 0].max(), 1.5) + self.assertLess(path[:, 0].max(), 2.0) # stops short of the vertex centre + + def test_edge_stops_at_the_marker_not_the_vertex_centre(self): + g, _ = build(DGraph, [("a", "b")]) + (p,) = self.arrows(self.draw(g)) + self.assertGreater(p.shrinkA, 0) # points: half the marker, and a little + self.assertGreater(p.shrinkB, 0) + + def test_arrow_opt_overrides_the_style(self): + g, _ = build(DGraph, [("a", "b")]) + (p,) = self.arrows(self.draw(g, arrow_opt=dict(arrowstyle="->", mutation_scale=30))) + self.assertIsInstance(self.style(p), ArrowStyle.CurveB) + self.assertEqual(p.get_mutation_scale(), 30) + + def test_arrow_opt_can_give_an_undirected_graph_heads(self): + g, _ = build(UGraph, [("a", "b")]) + (p,) = self.arrows(self.draw(g, arrow_opt=dict(arrowstyle="<->"))) + self.assertIsInstance(self.style(p), ArrowStyle.CurveAB) + + def test_arrow_opt_none_is_the_default(self): + g, _ = build(DGraph, [("a", "b")]) + (p,) = self.arrows(self.draw(g, arrow_opt=None)) + self.assertIsInstance(self.style(p), ArrowStyle.CurveFilledB) + + +class TestParallelEdges(_Plot): + def rad(self, patch): + style = patch.get_connectionstyle() + self.assertIsInstance(style, ConnectionStyle.Arc3) + return style.rad + + def side(self, patch): + """+1 or -1: which side of the straight line between its ends the arc bows to""" + v = patch.get_path().vertices + start, end = v[0], v[-1] + mid = v.mean(axis=0) + d, m = end - start, mid - start + return float(np.sign(d[0] * m[1] - d[1] * m[0])) + + def test_a_single_edge_is_straight(self): + g, _ = build(DGraph, [("a", "b")]) + (p,) = self.arrows(self.draw(g)) + self.assertEqual(self.rad(p), 0) + + def test_parallel_edges_bow_to_either_side(self): + g, _ = build(UGraph, [("a", "b"), ("a", "b")]) + p, q = self.arrows(self.draw(g)) + self.assertEqual(self.rad(p), -self.rad(q)) + self.assertNotEqual(self.rad(p), 0) + self.assertNotEqual(self.side(p), self.side(q)) + + def test_three_parallel_edges_one_is_straight(self): + g, _ = build(UGraph, [("a", "b"), ("a", "b"), ("a", "b")]) + rads = sorted(self.rad(p) for p in self.arrows(self.draw(g))) + self.assertAlmostEqual(rads[1], 0) + self.assertAlmostEqual(rads[0], -rads[2]) + + def test_antiparallel_edges_are_separate_arcs(self): + # a->b and b->a join the same pair: two arcs, not one on the other + g, _ = build(DGraph, [("a", "b"), ("b", "a")]) + p, q = self.arrows(self.draw(g)) + self.assertNotEqual(self.side(p), self.side(q)) + mid_p = p.get_path().vertices.mean(axis=0) + mid_q = q.get_path().vertices.mean(axis=0) + self.assertGreater(np.linalg.norm(mid_p - mid_q), 0.1) + + def test_other_pairs_are_unaffected(self): + g, _ = build(UGraph, [("a", "b"), ("a", "b"), ("c", "d")]) + arrows = self.arrows(self.draw(g)) + self.assertEqual([self.rad(p) for p in arrows][2], 0) + + +class TestLoops(_Plot): + def test_a_loop_is_drawn(self): + g, _ = build(UGraph, [("a", "a")]) + (p,) = self.arrows(self.draw(g)) + # the path is in points from the vertex: a circle that passes through + # it, so it comes within a marker radius of the origin and is about a + # loop radius across + verts = p.get_path().vertices + self.assertGreater(len(verts), 4) + self.assertLess(np.linalg.norm(verts, axis=1).min(), 10) + self.assertGreater(np.ptp(verts[:, 0]) + np.ptp(verts[:, 1]), 30) + + def test_a_directed_loop_has_a_head(self): + g, _ = build(DGraph, [("a", "a")]) + (p,) = self.arrows(self.draw(g)) + self.assertIsInstance(p.get_arrowstyle(), ArrowStyle.CurveFilledB) + + def test_loops_at_one_vertex_are_different(self): + g, _ = build(UGraph, [("a", "a"), ("a", "a"), ("a", "a")]) + paths = [p.get_path().vertices for p in self.arrows(self.draw(g))] + centres = {tuple(np.round(v.mean(axis=0), 2)) for v in paths} + self.assertEqual(len(centres), 3) + + def test_a_loop_is_not_clipped_by_the_axes(self): + g, _ = build(UGraph, [("a", "a")]) + (p,) = self.arrows(self.draw(g)) + self.assertFalse(p.get_clip_on()) + + +class TestOptions(_Plot): + def setUp(self): + self.g, _ = build(UGraph, [("a", "b"), ("c", "d")]) + + def test_edge_opt_linewidth(self): + for p in self.arrows(self.draw(self.g, edge_opt=dict(linewidth=7))): + self.assertEqual(p.get_linewidth(), 7) + + def test_default_edge_linewidth(self): + for p in self.arrows(self.draw(self.g)): + self.assertEqual(p.get_linewidth(), 3) + + def test_edge_opt_that_is_not_an_edge_option_is_reported(self): + with self.assertWarns(UserWarning): + self.draw(self.g, edge_opt=dict(marker="x")) + + def test_vertex_opt_markersize(self): + ax = self.draw(self.g, vertex_opt=dict(markersize=5)) + self.assertEqual({line.get_markersize() for line in ax.lines}, {5}) + + def test_labels_default_on(self): + ax = self.draw(self.g) + self.assertEqual(sorted(t.get_text().strip() for t in ax.texts), list("abcd")) + + def test_labels_false(self): + self.assertEqual(len(self.draw(self.g, labels=False).texts), 0) + + def test_text_opt(self): + ax = self.draw(self.g, text_opt=dict(color="r", fontsize=17)) + self.assertEqual({t.get_color() for t in ax.texts}, {"r"}) + self.assertEqual({t.get_fontsize() for t in ax.texts}, {17}) + + def test_color_components_colours_each_component(self): + ax = self.draw(self.g, color_components=True) + colors = {tuple(p.get_edgecolor()) for p in self.arrows(ax)} + self.assertEqual(len(colors), 2) # a-b and c-d are two components + + def test_without_component_colors_everything_is_one_color(self): + ax = self.draw(self.g, color_components=False) + self.assertEqual(len({tuple(p.get_edgecolor()) for p in self.arrows(ax)}), 1) + self.assertEqual(len({line.get_color() for line in ax.lines}), 1) + + def test_force_2d_with_3d_coordinates(self): + g, _ = build(UGraph, [("a", "b")], dim=3) + fig = plt.figure() + ax = fig.add_subplot() + g.plot(ax=ax, force_2d=True, block=None) + self.assertEqual(len(self.arrows(ax)), 1) + + +class TestOldNames(_Plot): + """The 0.6.5 names still work, with a DeprecationWarning that names the new""" + + def setUp(self): + self.g, _ = build(UGraph, [("a", "b"), ("c", "d")]) + + def deprecated(self, old, new, **kwargs): + with self.assertWarns(DeprecationWarning) as cm: + ax = self.draw(self.g, **kwargs) + self.assertIn(old, str(cm.warning)) + self.assertIn(new, str(cm.warning)) + self.assertEqual(cm.filename, __file__.replace(".pyc", ".py")) # blames the caller + return ax + + def test_vopt(self): + ax = self.deprecated("vopt", "vertex_opt", vopt=dict(markersize=4)) + self.assertEqual({line.get_markersize() for line in ax.lines}, {4}) + + def test_eopt(self): + ax = self.deprecated("eopt", "edge_opt", eopt=dict(linewidth=9)) + self.assertEqual({p.get_linewidth() for p in self.arrows(ax)}, {9}) + + def test_text_false(self): + self.assertEqual(len(self.deprecated("text", "labels", text=False).texts), 0) + + def test_text_dict(self): + ax = self.deprecated("text", "text_opt", text=dict(color="r")) + self.assertEqual({t.get_color() for t in ax.texts}, {"r"}) + + def test_text_none_and_true_are_default_labels(self): + for value in (None, True): + with self.subTest(text=value): + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + ax = self.draw(self.g, text=value) + self.assertEqual(len(ax.texts), 4) + + def test_colorcomponents(self): + ax = self.deprecated("colorcomponents", "color_components", colorcomponents=False) + self.assertEqual(len({tuple(p.get_edgecolor()) for p in self.arrows(ax)}), 1) + + def test_force2d(self): + g, _ = build(UGraph, [("a", "b")], dim=3) + fig = plt.figure() + ax = fig.add_subplot() + with self.assertWarns(DeprecationWarning): + g.plot(ax=ax, force2d=True, block=None) + self.assertEqual(len(self.arrows(ax)), 1) + + def test_positional_arguments_still_mean_what_they_did(self): + # plot(colorcomponents, force2d, vopt, eopt, text, block, grid, ax) + fig, ax = plt.subplots() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + self.g.plot(False, False, dict(markersize=3), None, False, None, True, ax) + self.assertEqual({line.get_markersize() for line in ax.lines}, {3}) + self.assertEqual(len(ax.texts), 0) + + def test_old_and_new_together_is_an_error(self): + for kwargs in ( + dict(vopt={}, vertex_opt={}), + dict(eopt={}, edge_opt={}), + dict(colorcomponents=True, color_components=True), + dict(force2d=True, force_2d=True), + dict(text=False, labels=True), + dict(text={}, text_opt={}), + ): + with self.subTest(**{k: type(v).__name__ for k, v in kwargs.items()}): + with self.assertRaises(TypeError): + self.g.plot(block=None, **kwargs) + + +class Test3D(_Plot): + def draw3d(self, g, **kwargs): + fig = plt.figure() + ax = fig.add_subplot(projection="3d") + g.plot(ax=ax, block=None, **kwargs) + fig.canvas.draw() + return fig, ax + + def artists(self, ax, kind): + return [a for a in ax.get_children() if isinstance(a, kind)] + + def test_edges_are_3d_arrows(self): + g, _ = build(DGraph, [("a", "b"), ("b", "c")], dim=3) + fig, ax = self.draw3d(g) + arrows = self.artists(ax, _Arrow3D) + self.assertEqual(len(arrows), 2) + for a in arrows: + self.assertIsInstance(a.get_arrowstyle(), ArrowStyle.CurveFilledB) + + def test_undirected_have_no_head(self): + g, _ = build(UGraph, [("a", "b")], dim=3) + fig, ax = self.draw3d(g) + (a,) = self.artists(ax, _Arrow3D) + self.assertIsInstance(a.get_arrowstyle(), ArrowStyle.Curve) + + def test_loops_and_parallel_edges(self): + g, _ = build(DGraph, [("a", "b"), ("a", "b"), ("c", "c")], dim=3) + fig, ax = self.draw3d(g) + self.assertEqual(len(self.artists(ax, _Arrow3D)), 2) + self.assertEqual(len(self.artists(ax, _Loop3D)), 1) + rads = sorted(a.get_connectionstyle().rad for a in self.artists(ax, _Arrow3D)) + self.assertAlmostEqual(rads[0], -rads[1]) + + def test_follows_a_rotation(self): + g, _ = build(DGraph, [("a", "b")], dim=3) + fig, ax = self.draw3d(g) + (a,) = self.artists(ax, _Arrow3D) + before = np.array(a.get_path().vertices) + ax.view_init(elev=60, azim=40) + fig.canvas.draw() + after = np.array(a.get_path().vertices) + self.assertFalse(np.allclose(before, after)) + + def test_four_coordinates_use_the_first_three(self): + g, _ = build(UGraph, [("a", "b")], dim=4) + fig, ax = self.draw3d(g) + self.assertEqual(len(self.artists(ax, _Arrow3D)), 1) + + +class TestHighlight(_Plot): + def test_highlight_follows_the_arc(self): + g, (e1, e2) = build(UGraph, [("a", "b"), ("a", "b")]) + ax = self.draw(g) + before = len(self.arrows(ax)) + g.highlight_edge(e2) + arrows = self.arrows(ax) + self.assertEqual(len(arrows), before + 1) + highlight, edge = arrows[-1], arrows[1] + np.testing.assert_allclose( + highlight.get_path().vertices.mean(axis=0), + edge.get_path().vertices.mean(axis=0), + atol=0.02, + ) + + def test_highlight_a_loop(self): + g, (e,) = build(DGraph, [("a", "a")]) + self.draw(g) + g.highlight_edge(e) # does not raise + + def test_highlight_path_through_parallel_edges_takes_the_cheapest(self): + g, _ = build(DGraph, [("a", "b"), ("a", "b")]) + g.edges()[0].cost = 5 + g.edges()[1].cost = 1 + ax = self.draw(g) + g.highlight_path([g["a"], g["b"]], block=None) + highlight = self.arrows(ax)[-2] # the edge; the vertices follow as markers + np.testing.assert_allclose( + highlight.get_path().vertices.mean(axis=0), + self.arrows(ax)[1].get_path().vertices.mean(axis=0), + atol=0.02, + ) + + +if __name__ == "__main__": + unittest.main() From e8b611e055525b83e356cbd67d7a57069d494e3e Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Mon, 12 Oct 2026 13:10:46 +1000 Subject: [PATCH 2/3] refactor: the export options are color_components, like plot() export(), dotfile() and the exporters took colorcomponents, which is the 0.6.5 name of a plot() option and has no underscore. They are new in 1.0 and not released, so this is a plain rename, with no deprecation. Co-Authored-By: Claude Sonnet 5.5 --- src/pgraph/PGraph.py | 8 ++++---- src/pgraph/exporters.py | 16 ++++++++-------- tests/test_export.py | 10 +++++----- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/pgraph/PGraph.py b/src/pgraph/PGraph.py index 221d1831..5fdc54c9 100644 --- a/src/pgraph/PGraph.py +++ b/src/pgraph/PGraph.py @@ -2314,11 +2314,11 @@ def export( * - ``"dot"`` - ``.dot`` ``.gv`` - ``pos="x,y!"`` - - ``direction``, ``colorcomponents``, ``costs`` + - ``direction``, ``color_components``, ``costs`` * - ``"mermaid"`` - ``.mmd`` ``.mermaid`` - no - - ``direction``, ``colorcomponents``, ``costs``, ``fence`` + - ``direction``, ``color_components``, ``costs``, ``fence`` * - ``"graphml"`` - ``.graphml`` - ``x``, ``y``, ``coord`` @@ -2330,7 +2330,7 @@ def export( * - ``"svg"`` ``"pdf"`` ``"png"`` ... - the same - yes, via ``neato`` - - ``engine``, ``direction``, ``colorcomponents``, ``costs`` + - ``engine``, ``direction``, ``color_components``, ``costs`` The text formats need nothing beyond the standard library. The image formats run the GraphViz ``dot`` or ``neato`` executable. @@ -2463,7 +2463,7 @@ def showgraph(self, display: bool = True, **options: Any) -> str | None: Display graph as a vertex/edge diagram in a browser tab :param display: open the rendered PDF in a browser tab, defaults to True - :param options: ``engine``, ``direction``, ``colorcomponents`` and + :param options: ``engine``, ``direction``, ``color_components`` and ``costs``, passed to :meth:`export` :raises RuntimeError: GraphViz is not installed, or fails :return: path of the rendered PDF if ``display`` is False, else None diff --git a/src/pgraph/exporters.py b/src/pgraph/exporters.py index 54cf7820..6b83bb3a 100644 --- a/src/pgraph/exporters.py +++ b/src/pgraph/exporters.py @@ -211,19 +211,19 @@ def dot( graph: Graph, *, direction: str | None = None, - colorcomponents: bool = False, + color_components: bool = False, costs: bool = True, ) -> str: """ GraphViz DOT text :param direction: layout direction, ``rankdir`` in DOT, eg. ``"LR"`` - :param colorcomponents: fill vertices by connected component + :param color_components: fill vertices by connected component :param costs: label edges with their cost """ directed = _is_directed(graph) link = "->" if directed else "--" - colors = _component_colors(graph) if colorcomponents else {} + colors = _component_colors(graph) if color_components else {} lines = ["digraph {" if directed else "graph {"] if direction is not None: @@ -260,7 +260,7 @@ def mermaid( graph: Graph, *, direction: str = "TB", - colorcomponents: bool = False, + color_components: bool = False, costs: bool = True, fence: bool = False, ) -> str: @@ -268,7 +268,7 @@ def mermaid( Mermaid flowchart text :param direction: ``"TB"``, ``"BT"``, ``"LR"`` or ``"RL"`` - :param colorcomponents: fill vertices by connected component + :param color_components: fill vertices by connected component :param costs: label edges with their cost :param fence: wrap in a Markdown code fence, three backticks followed by ``mermaid`` @@ -295,7 +295,7 @@ def mermaid( else: lines.append(f" {ids[v1]} {arrow} {ids[v2]}") - if colorcomponents: + if color_components: colors = _component_colors(graph) for vertex, color in colors.items(): lines.append(f" style {ids[vertex]} fill:{color}") @@ -453,7 +453,7 @@ def render( *, engine: str | None = None, direction: str | None = None, - colorcomponents: bool = False, + color_components: bool = False, costs: bool = True, ) -> bytes: """ @@ -487,7 +487,7 @@ def render( f"{fmt} output, or export the text with format='dot'" ) - text = dot(graph, direction=direction, colorcomponents=colorcomponents, costs=costs) + text = dot(graph, direction=direction, color_components=color_components, costs=costs) result = subprocess.run( # nosec B603 nosemgrep - allow-listed program and format, no shell [exe, f"-T{fmt}"], input=text.encode("utf-8"), diff --git a/tests/test_export.py b/tests/test_export.py index 0b34c1b2..52df2ec2 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -172,11 +172,11 @@ def test_escaping(self): # every line is balanced: no raw newline inside a quoted name self.assertEqual(len(text.strip().splitlines()), 5) - def test_colorcomponents(self): + def test_color_components(self): g = UGraph() a, b, c = (g.add_vertex(name=n) for n in "abc") g.add_edge(a, b) # c is a second component - text = g.export(format="dot", colorcomponents=True) + text = g.export(format="dot", color_components=True) colors = { line.split('fillcolor="')[1].split('"')[0] for line in text.splitlines() @@ -224,11 +224,11 @@ def test_awkward_names_are_safe(self): self.assertIn('n1["x y-z]"]', text) self.assertIn("n0 --- n1", text) - def test_colorcomponents(self): + def test_color_components(self): g = UGraph() g.add_vertex(name="a") g.add_vertex(name="b") - text = g.export(format="mermaid", colorcomponents=True) + text = g.export(format="mermaid", color_components=True) self.assertEqual(sum(1 for l in text.splitlines() if "fill:#" in l), 2) @@ -465,7 +465,7 @@ def test_pdf_and_png_are_real_files(self): g, *_ = line_graph(DGraph) pdf, png = io.BytesIO(), io.BytesIO() g.export(pdf, format="pdf", direction="LR") - g.export(png, format="png", colorcomponents=True) + g.export(png, format="png", color_components=True) self.assertEqual(pdf.getvalue()[:5], b"%PDF-") self.assertEqual(png.getvalue()[:4], b"\x89PNG") From 43c57b1d59114ebad4f02e0a77a6ae98a15c1f38 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Mon, 12 Oct 2026 13:11:22 +1000 Subject: [PATCH 3/3] docs: say how plot() draws a directed graph, and the option names Co-Authored-By: Claude Sonnet 5.5 --- docs/source/visualization.rst | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/source/visualization.rst b/docs/source/visualization.rst index 06c9d0e5..95cee203 100644 --- a/docs/source/visualization.rst +++ b/docs/source/visualization.rst @@ -5,6 +5,34 @@ Rendering the graph itself, with matplotlib or via GraphViz. See :doc:`export` for exporting the graph as data or an image file (dot, Mermaid, GraphML, ELK, PNG, ...) and :doc:`representations` for its matrices. +:meth:`~pgraph.PGraph.Graph.plot` draws each edge once. A directed graph has an +arrowhead at the end of each edge, edges that join the same pair of vertices +are drawn as arcs that bow apart so that parallel and antiparallel edges can all +be seen, and a loop is drawn as a circle at its vertex. The same is true of a +3D plot, which follows a rotation of the view. + +The appearance is set by dicts of Matplotlib options: + +================= ==================================================== ============================= +option applies to default +================= ==================================================== ============================= +``vertex_opt`` the vertex marker (``plot``) ``marker="o", markersize=12`` +``edge_opt`` the edge line (``FancyArrowPatch``): ``color``, ``linewidth=3`` + ``linewidth``, ``linestyle``, ``alpha`` +``arrow_opt`` the arrowhead and the path: ``arrowstyle``, ``arrowstyle="-|>",`` + ``mutation_scale``, ``connectionstyle`` ``mutation_scale=15`` for a + directed graph, ``"-"`` + for an undirected one +``text_opt`` the vertex labels (``text``) Matplotlib's +================= ==================================================== ============================= + +``labels=False`` removes the labels, and ``color_components=False`` draws the +graph in one color rather than a color for each component. + +.. note:: The options were named ``vopt``, ``eopt``, ``text``, ``colorcomponents`` + and ``force2d`` in 0.6.5. Those names still work but give a + ``DeprecationWarning``. + .. autosummary:: :nosignatures: