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: diff --git a/src/pgraph/PGraph.py b/src/pgraph/PGraph.py index c82e50c9..ae0e02b7 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 @@ -1709,58 +1801,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 @@ -1785,8 +1997,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 @@ -1799,75 +2053,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) @@ -1977,12 +2243,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( @@ -2077,11 +2355,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`` @@ -2093,7 +2371,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. @@ -2226,7 +2504,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") 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()