diff --git a/hypernetx/drawing/rubber_band.py b/hypernetx/drawing/rubber_band.py index 35354f26..43ec2fdc 100644 --- a/hypernetx/drawing/rubber_band.py +++ b/hypernetx/drawing/rubber_band.py @@ -1,23 +1,33 @@ # Copyright © 2018 Battelle Memorial Institute # All rights reserved. -from hypernetx import Hypergraph -from hypernetx.drawing.util import ( - inflate, - get_set_layering, - inflate_kwargs, - transpose_inflated_kwargs, -) +from collections.abc import Iterable, Mapping, Sequence +from typing import Any import matplotlib.pyplot as plt -from matplotlib.collections import PolyCollection, EllipseCollection - import networkx as nx - - import numpy as np +from matplotlib.collections import EllipseCollection, PolyCollection +from matplotlib.patches import PathPatch +from matplotlib.path import Path +from scipy.spatial import ConvexHull, Delaunay, QhullError from scipy.spatial.distance import pdist -from scipy.spatial import ConvexHull +from shapely.geometry import ( + GeometryCollection, + LineString, + MultiPolygon, + Point, + Polygon, +) +from shapely.geometry.base import BaseGeometry +from shapely.ops import unary_union + +from hypernetx.drawing.util import ( + get_set_layering, + inflate, + inflate_kwargs, + transpose_inflated_kwargs, +) # increases the default figure size to 8in square. plt.rcParams["figure.figsize"] = (8, 8) @@ -131,7 +141,7 @@ def draw_hyper_edge_labels( params = transpose_inflated_kwargs(inflate_kwargs(H.edges, kwargs)) - for edge, s, path, params in zip(H.edges, labels, polys.get_paths(), params): + for edge, s, path, edge_params in zip(H.edges, labels, polys.get_paths(), params): theta = 0 xy = None @@ -156,11 +166,11 @@ def draw_hyper_edge_labels( # the string is a comma separated list of the edge uid ax.annotate( - s, xy, **{"rotation": theta, "ha": "center", "va": "center", **params} + s, xy, **{"rotation": theta, "ha": "center", "va": "center", **edge_params} ) -def layout_hyper_edges(H, pos, node_radius={}, dr=None, contain_hyper_edges=False): +def layout_hyper_edges(H, pos, node_radius=None, dr=None, contain_hyper_edges=False): """ Draws a convex hull for each edge in H. @@ -188,6 +198,9 @@ def layout_hyper_edges(H, pos, node_radius={}, dr=None, contain_hyper_edges=Fals A mapping from hyper edge ids to paths (Nx2 numpy matrices) """ + if node_radius is None: + node_radius = {} + if len(node_radius): r0 = min(node_radius.values()) else: @@ -230,12 +243,12 @@ def draw_hyper_edges( H, pos, ax=None, - node_radius={}, + node_radius=None, contain_hyper_edges=False, dr=None, fill_edges=False, fill_edge_alpha=-0.5, - **kwargs + **kwargs, ): """ Draws a convex hull around the nodes contained within each edge in H @@ -260,6 +273,9 @@ def draw_hyper_edges( PolyCollection a Matplotlib PolyCollection that can be further styled """ + if node_radius is None: + node_radius = {} + points = layout_hyper_edges( H, pos, node_radius=node_radius, dr=dr, contain_hyper_edges=contain_hyper_edges ) @@ -274,7 +290,7 @@ def draw_hyper_edges( return polys -def draw_hyper_nodes(H, pos, node_radius={}, r0=None, ax=None, **kwargs): +def draw_hyper_nodes(H, pos, node_radius=None, r0=None, ax=None, **kwargs): """ Draws a circle for each node in H. @@ -307,6 +323,8 @@ def draw_hyper_nodes(H, pos, node_radius={}, r0=None, ax=None, **kwargs): """ ax = ax or plt.gca() + if node_radius is None: + node_radius = {} kwargs.setdefault("facecolors", "black") r0 = r0 or get_default_radius(H, pos) @@ -320,14 +338,14 @@ def draw_hyper_nodes(H, pos, node_radius={}, r0=None, ax=None, **kwargs): units="xy", offsets=offsets, transOffset=ax.transData, - **inflate_kwargs(H, kwargs) + **inflate_kwargs(H, kwargs), ) ax.add_collection(circles) return circles -def draw_hyper_labels(H, pos, labels, node_radius={}, ax=None, **kwargs): +def draw_hyper_labels(H, pos, labels, node_radius=None, ax=None, **kwargs): """ Draws text labels for the hypergraph nodes. @@ -358,6 +376,8 @@ def draw_hyper_labels(H, pos, labels, node_radius={}, ax=None, **kwargs): """ ax = ax or plt.gca() + if node_radius is None: + node_radius = {} params = transpose_inflated_kwargs(inflate_kwargs(H.nodes, kwargs)) @@ -366,42 +386,642 @@ def draw_hyper_labels(H, pos, labels, node_radius={}, ax=None, **kwargs): ax.annotate(s, xy, **v_kwargs) +def _circumradius(triangle_points: np.ndarray) -> float: + """Return the circumradius of a triangle, or infinity if it is degenerate. + + Parameters + ---------- + triangle_points : np.ndarray + Array of shape (3, 2). + + Returns + ------- + float + Circumradius of the triangle. + """ + first, second, third = triangle_points + edge_ab = second - first + edge_ac = third - first + twice_area = abs(float(edge_ab[0] * edge_ac[1] - edge_ab[1] * edge_ac[0])) + if twice_area <= 1e-12: + return float("inf") + length_a = float(np.linalg.norm(second - third)) + length_b = float(np.linalg.norm(first - third)) + length_c = float(np.linalg.norm(first - second)) + return (length_a * length_b * length_c) / (2.0 * twice_area) + + +def _convex_hull_geometry(points: np.ndarray) -> BaseGeometry: + """Return the convex hull of a 2-d point set. + + Parameters + ---------- + points : np.ndarray + Array of shape (n, 2). Duplicate rows are ignored. + + Returns + ------- + BaseGeometry + A point, line, or polygon, depending on how many distinct locations + the input contains. + """ + unique = np.unique(np.asarray(points, dtype=float), axis=0) + if len(unique) == 1: + return Point(unique[0]) + if len(unique) == 2: + return LineString(unique) + try: + hull = ConvexHull(unique) + except QhullError: + return LineString(unique) + return Polygon(unique[hull.vertices]) + + +def get_concave_hull(points: np.ndarray, alpha: float = 0.5) -> BaseGeometry: + """Return an alpha shape of ``points``. + + The construction follows Edelsbrunner alpha shapes, the same rule used by + the ``alphashape`` library. A Delaunay triangle is kept when its + circumradius is strictly less than ``1 / alpha``. Larger ``alpha`` drops + more triangles and produces a more concave outline. ``alpha <= 0`` returns + the convex hull. + + ``alpha`` is absolute in the coordinate system of ``points``. Layouts that + place nodes in a unit box need a larger value before concavities appear. + + Parameters + ---------- + points : np.ndarray + Array of shape (n, 2). + alpha : float + Alpha-shape parameter. Values less than or equal to zero select the + convex hull. + + Returns + ------- + BaseGeometry + The hull. Falls back to the convex hull when the triangulation is + degenerate or no triangle satisfies the radius test. + + Raises + ------ + ValueError + If ``points`` does not have shape (n, 2). + """ + coordinates = np.asarray(points, dtype=float) + if coordinates.ndim != 2 or coordinates.shape[1] != 2: + raise ValueError("points must be an array of shape (n, 2)") + unique = np.unique(coordinates, axis=0) + if len(unique) < 4 or alpha <= 0: + return _convex_hull_geometry(unique) + try: + triangulation = Delaunay(unique) + except QhullError: + return _convex_hull_geometry(unique) + + radius_limit = 1.0 / alpha + kept: list[Polygon] = [] + for simplex in triangulation.simplices: + triangle_points = unique[simplex] + if _circumradius(triangle_points) < radius_limit: + triangle = Polygon(triangle_points) + if triangle.is_valid and triangle.area > 0: + kept.append(triangle) + if not kept: + return _convex_hull_geometry(unique) + hull = unary_union(kept) + if not hull.is_valid: + hull = hull.buffer(0) + return hull + + +def _as_dicts(*values: Mapping[str, Any] | None) -> tuple[dict[str, Any], ...]: + """Replace omitted mappings with fresh dictionaries. + + Parameters + ---------- + values : Mapping or None + Keyword-argument dictionaries supplied by a caller. + + Returns + ------- + tuple of dict + ``{}`` wherever a value was ``None``, otherwise the original mapping. + """ + return tuple({} if value is None else dict(value) for value in values) + + +def _with_padding(geom: BaseGeometry, pad: float) -> BaseGeometry: + """Expand ``geom`` by ``pad``. + + Points and lines become polygons. Invalid polygons are repaired. + + Parameters + ---------- + geom : BaseGeometry + Geometry to expand. + pad : float + Buffer distance in the geometry's coordinate system. + + Returns + ------- + BaseGeometry + Buffered geometry. + """ + if geom.is_empty: + return geom + if geom.geom_type in {"Point", "MultiPoint", "LineString", "MultiLineString"}: + return geom.buffer(pad) + padded = geom.buffer(pad) + if not padded.is_valid: + padded = padded.buffer(0) + return padded + + +def _outline_padding( + edge_points: np.ndarray, + member_radii: np.ndarray, + buffer_distance: float, +) -> float: + """Return the distance used to expand one edge outline. + + Parameters + ---------- + edge_points : np.ndarray + Member coordinates, shape (n, 2). + member_radii : np.ndarray + Marker radius of each member. + buffer_distance : float + Fraction of the mean pairwise distance, or the distance itself when + the edge has one node. + + Returns + ------- + float + Positive padding distance. + """ + if len(edge_points) > 1: + adaptive = buffer_distance * float(np.mean(pdist(edge_points))) + else: + adaptive = buffer_distance + radius_floor = float(np.max(member_radii)) if len(member_radii) else 0.0 + return max(adaptive, radius_floor, 1e-9) + + +def _member_hull(edge_points: np.ndarray, alpha: float) -> BaseGeometry: + """Return the unpadded outline of an edge's member coordinates. + + Parameters + ---------- + edge_points : np.ndarray + Member coordinates, shape (n, 2). + alpha : float + Alpha-shape parameter used when there are at least three points. + + Returns + ------- + BaseGeometry + A point, line, or polygon. + """ + if len(edge_points) == 1: + return Point(edge_points[0]) + if len(edge_points) == 2: + return LineString(edge_points) + return get_concave_hull(edge_points, alpha) + + +def _cut_out_nodes( + geom: BaseGeometry, + outsiders: Iterable[Any], + nodes_pos: Mapping[Any, Sequence[float]], + radii: Mapping[Any, float], + pad: float, +) -> BaseGeometry: + """Remove disks around non-members that fall inside ``geom``. + + Parameters + ---------- + geom : BaseGeometry + Padded edge outline. + outsiders : Iterable + Node ids that must not be covered. + nodes_pos : Mapping + Node id to position. + radii : Mapping + Node id to marker radius. + pad : float + Minimum radius of each removed disk. + + Returns + ------- + BaseGeometry + Outline with those disks removed. + """ + holes: list[BaseGeometry] = [] + for node in outsiders: + if node not in nodes_pos: + continue + point = Point(nodes_pos[node]) + if not geom.contains(point): + continue + holes.append(point.buffer(max(float(radii.get(node, 0.0)), pad))) + if not holes: + return geom + cut = geom.difference(unary_union(holes)) + if not cut.is_valid: + cut = cut.buffer(0) + return cut + + +def create_rubber_band( + nodes_pos: Mapping[Any, Sequence[float]], + edge_nodes: Iterable[Any], + buffer_distance: float = 0.1, + alpha: float = 0.5, + other_nodes: Iterable[Any] | None = None, + node_radius: Mapping[Any, float] | None = None, +) -> BaseGeometry: + """Build a deterministic outline around the nodes of one hyperedge. + + The outline is an alpha shape of the member nodes, expanded so node + markers sit inside it. Markers for ``other_nodes`` that land inside that + outline are cut out. The same inputs always produce the same geometry. + + Parameters + ---------- + nodes_pos : Mapping + Node id to ``(x, y)`` position. Only ids that are drawn as hypergraph + nodes should be included in ``other_nodes``; bipartite edge anchors + stored in a layout dictionary are not nodes. + edge_nodes : Iterable + Nodes that belong to the hyperedge. + buffer_distance : float + For two or more nodes, fraction of their mean pairwise distance used + as padding. For a single node, the padding distance itself. + alpha : float + Alpha-shape parameter forwarded to :func:`get_concave_hull`. + other_nodes : Iterable or None + Nodes that must not be covered. ``None`` uses every key of + ``nodes_pos`` that is not a member of the edge. + node_radius : Mapping or None + Node id to marker radius. The padding is at least the largest member + radius, and each excluded node is cut out by a disk of its own radius + or the padding, whichever is larger. + + Returns + ------- + BaseGeometry + A polygon or multipolygon. Empty when ``edge_nodes`` is empty. + """ + members = list(edge_nodes) + if not members: + return Polygon() + + radii = node_radius or {} + edge_points = np.asarray([nodes_pos[node] for node in members], dtype=float) + member_radii = np.asarray( + [float(radii.get(node, 0.0)) for node in members], dtype=float + ) + pad = _outline_padding(edge_points, member_radii, buffer_distance) + geom = _with_padding(_member_hull(edge_points, alpha), pad) + + if other_nodes is None: + member_ids = set(members) + outsiders: Iterable[Any] = [ + node for node in nodes_pos if node not in member_ids + ] + else: + outsiders = other_nodes + geom = _cut_out_nodes(geom, outsiders, nodes_pos, radii, pad) + if geom.is_empty: + geom = unary_union( + [ + Point(point).buffer(max(float(radius), pad)) + for point, radius in zip(edge_points, member_radii) + ] + ) + return geom + + +def _iter_polygons(geom: BaseGeometry) -> Iterable[Polygon]: + """Yield polygons stored directly or inside a collection. + + Parameters + ---------- + geom : BaseGeometry + Geometry that may contain polygons. + + Yields + ------ + Polygon + Each polygon component. + """ + if geom.is_empty: + return + if isinstance(geom, Polygon): + yield geom + elif isinstance(geom, (MultiPolygon, GeometryCollection)): + for part in geom.geoms: + yield from _iter_polygons(part) + + +def _geometry_to_path(geom: BaseGeometry) -> Path | None: + """Convert a polygonal geometry, including holes, to a Matplotlib path. + + Parameters + ---------- + geom : BaseGeometry + Polygon, multipolygon, or collection. + + Returns + ------- + Path or None + ``None`` when ``geom`` contains no polygon. + """ + + def add_ring( + ring: Any, vertices: list[tuple[float, float]], codes: list[int] + ) -> None: + coords = np.asarray(ring.coords, dtype=float) + if len(coords) < 2: + return + vertices.extend((float(x), float(y)) for x, y in coords) + codes.append(int(Path.MOVETO)) + codes.extend([int(Path.LINETO)] * (len(coords) - 2)) + codes.append(int(Path.CLOSEPOLY)) + + vertices: list[tuple[float, float]] = [] + codes: list[int] = [] + for polygon in _iter_polygons(geom): + add_ring(polygon.exterior, vertices, codes) + for interior in polygon.interiors: + add_ring(interior, vertices, codes) + if not vertices: + return None + return Path(vertices, codes) + + +def _with_fill_alpha(color: Any, fill_edge_alpha: float) -> Any: + """Shift the alpha channel of an RGBA color. + + Parameters + ---------- + color : Any + Matplotlib color. Only length-4 numeric colors are changed. + fill_edge_alpha : float + Amount added to the alpha channel, then clipped to ``[0, 1]``. + + Returns + ------- + Any + Adjusted RGBA color, or ``color`` unchanged when it is not RGBA. + """ + try: + array = np.asarray(color, dtype=float) + except (TypeError, ValueError): + return color + if array.shape != (4,): + return color + adjusted = array.copy() + adjusted[3] = float(np.clip(adjusted[3] + fill_edge_alpha, 0.0, 1.0)) + return adjusted + + +def _patch_style( + style: Mapping[str, Any], + fill_edges: bool, + fill_edge_alpha: float, +) -> dict[str, Any]: + """Translate PolyCollection style keys into PathPatch keyword arguments. + + Parameters + ---------- + style : Mapping + Style for one edge, including ``edgecolors`` and ``facecolors``. + fill_edges : bool + When True and no face color was set, fill with the edge color. + fill_edge_alpha : float + Alpha added to that fill color. Negative values make the fill lighter. + + Returns + ------- + dict + Keyword arguments for :class:`matplotlib.patches.PathPatch`. + """ + patch_kwargs = dict(style) + edgecolor = patch_kwargs.pop("edgecolors", "black") + facecolor = patch_kwargs.pop("facecolors", "none") + if fill_edges and facecolor in {"none", None}: + facecolor = _with_fill_alpha(edgecolor, fill_edge_alpha) + patch_kwargs["edgecolor"] = edgecolor + patch_kwargs["facecolor"] = facecolor + return patch_kwargs + + +def _boundary_anchor(geom: BaseGeometry) -> tuple[np.ndarray, float]: + """Return a label point on the longest exterior segment and its angle. + + Parameters + ---------- + geom : BaseGeometry + Edge outline. + + Returns + ------- + tuple + Midpoint of the longest exterior edge and a text angle in degrees, + folded into ``[-90, 90]``. + """ + polygons = list(_iter_polygons(geom)) + if not polygons: + return np.zeros(2), 0.0 + polygon = max(polygons, key=lambda item: item.area) + vertices = np.asarray(polygon.exterior.coords, dtype=float) + if len(vertices) < 2: + return vertices[0], 0.0 + lengths = ((vertices[:-1] - vertices[1:]) ** 2).sum(axis=1) + index = int(lengths.argmax()) + start = vertices[index] + end = vertices[index + 1] + delta = end - start + theta = 360.0 * float(np.arctan2(delta[1], delta[0])) / (2.0 * np.pi) + theta = (theta + 360.0) % 360.0 + while theta > 90.0: + theta -= 180.0 + return (start + end) / 2.0, theta + + +def draw_concave_hyper_edges( + H: Any, + pos: Mapping[Any, Sequence[float]], + node_radius: Mapping[Any, float] | None = None, + concavity: float = 0.5, + buffer_distance: float = 0.1, + ax: Any = None, + fill_edges: bool = False, + fill_edge_alpha: float = -0.5, + **kwargs: Any, +) -> list[BaseGeometry]: + """Draw one alpha-shape outline for each edge. + + Parameters + ---------- + H : hnx.Hypergraph + The entity to be drawn. + pos : Mapping + Node positions. Edge ids may also be present and are used only to + place an empty edge. + node_radius : Mapping or None + Node id to marker radius. + concavity : float + Alpha-shape parameter. See :func:`get_concave_hull`. + buffer_distance : float + Padding forwarded to :func:`create_rubber_band`. + ax : Axis + Matplotlib axis. The current axis is used when this is omitted. + fill_edges : bool + Fill each outline with a more transparent copy of its edge color when + no face color was given. + fill_edge_alpha : float + Amount added to the fill alpha. Typically negative. + kwargs : dict + Keyword arguments forwarded to the patch for each edge. Sequence + values with one entry per edge, including the ``edgecolors`` array + from :func:`add_edge_defaults`, are applied per edge. + + Returns + ------- + list of BaseGeometry + One geometry per edge, in iteration order. Missing empty edges are + empty polygons. + """ + ax = ax or plt.gca() + styles = transpose_inflated_kwargs(inflate_kwargs(H.edges, kwargs)) + node_ids = list(H.nodes) + geometries: list[BaseGeometry] = [] + for edge, style in zip(H.edges, styles): + members = list(H.edges[edge]) + if members: + outsiders = [node for node in node_ids if node not in members] + geom = create_rubber_band( + pos, + members, + buffer_distance=buffer_distance, + alpha=concavity, + other_nodes=outsiders, + node_radius=node_radius, + ) + elif edge in pos: + radius = 4.0 * (min(node_radius.values()) if node_radius else 1.0) + geom = Point(pos[edge]).buffer(radius) + else: + geometries.append(Polygon()) + continue + geometries.append(geom) + path = _geometry_to_path(geom) + if path is not None: + ax.add_patch( + PathPatch( + path, + **_patch_style(style, fill_edges, fill_edge_alpha), + ) + ) + return geometries + + +def draw_concave_edge_labels( + H: Any, + pos: Mapping[Any, Sequence[float]], + labels: Iterable[Any], + geometries: Sequence[BaseGeometry], + edge_labels_on_edge: bool = True, + ax: Any = None, + **kwargs: Any, +) -> None: + """Annotate concave edge outlines. + + Parameters + ---------- + H : hnx.Hypergraph + The entity to be drawn. + pos : Mapping + Node and edge positions. Used when labels are drawn inside an edge. + labels : Iterable + One label per edge, in the same order as ``H.edges``. + geometries : Sequence + Outlines returned by :func:`draw_concave_hyper_edges`. + edge_labels_on_edge : bool + When True, place the label on the longest boundary segment. Otherwise + place it at the edge's layout position, or at the member centroid when + that position is absent. + ax : Axis + Matplotlib axis. The current axis is used when this is omitted. + kwargs : dict + Keyword arguments forwarded to ``Axes.annotate``. + """ + ax = ax or plt.gca() + params = transpose_inflated_kwargs(inflate_kwargs(H.edges, kwargs)) + for edge, label, geom, edge_kwargs in zip(H.edges, labels, geometries, params): + members = list(H.edges[edge]) + theta = 0.0 + if edge_labels_on_edge and not geom.is_empty: + xy, theta = _boundary_anchor(geom) + elif edge in pos: + xy = np.asarray(pos[edge], dtype=float) + elif members: + xy = np.mean( + [np.asarray(pos[node], dtype=float) for node in members], axis=0 + ) + else: + continue + ax.annotate( + label, + xy, + **{"rotation": theta, "ha": "center", "va": "center", **edge_kwargs}, + ) + + def draw( H, pos=None, layout=nx.spring_layout, - layout_kwargs={}, + layout_kwargs=None, ax=None, node_radius=None, fill_edges=False, fill_edge_alpha=-0.5, - edges_kwargs={}, - nodes_kwargs={}, + edges_kwargs=None, + nodes_kwargs=None, edge_labels_on_edge=True, edge_labels=None, - edge_labels_kwargs={}, + edge_labels_kwargs=None, node_labels=None, - node_labels_kwargs={}, + node_labels_kwargs=None, with_edge_labels=True, with_node_labels=True, node_label_alpha=0.35, edge_label_alpha=0.35, with_additional_edges=None, contain_hyper_edges=False, - additional_edges_kwargs={}, + additional_edges_kwargs=None, return_pos=False, + convex: bool = True, + concavity: float = 0.5, + buffer_distance: float = 0.1, ): """ Draw a hypergraph as a Matplotlib figure By default this will draw a colorful "rubber band" like hypergraph, where convex hulls represent edges and are drawn around the nodes they contain. + Set ``convex`` to False to draw an alpha shape instead. The outline can + then bend around, and cut out, nodes that are not members of the edge. This is a convenience function that wraps calls with sensible parameters to the following lower-level drawing functions: - * draw_hyper_edges, - * draw_hyper_edge_labels, + * draw_hyper_edges or draw_concave_hyper_edges, + * draw_hyper_edge_labels or draw_concave_edge_labels, * draw_hyper_labels, and * draw_hyper_nodes @@ -472,10 +1092,39 @@ def draw( ... contain_hyper_edges: bool whether the rubber band shoudl be drawn around the location of the edge in the bipartite graph. This may be invisibile unless "with_additional_edges" contains this information. + convex: bool + When True, each edge is the convex hull of its nodes. When False, each + edge is an alpha shape. Nodes that are not members of the edge are cut + out of that shape when they fall inside it. + concavity: float + Alpha-shape parameter used when ``convex`` is False. Triangles whose + circumradius is greater than or equal to ``1 / concavity`` are removed, + so larger values are more concave. Values less than or equal to 0 keep + the convex hull. The value is in coordinate units: a spring layout in a + unit box often needs a larger value, such as 4, before bays appear. + buffer_distance: float + When ``convex`` is False, fraction of the mean pairwise distance among + an edge's nodes used to expand the outline. A one-node edge is expanded + by this distance directly. """ ax = ax or plt.gca() + ( + layout_kwargs, + edges_kwargs, + nodes_kwargs, + edge_labels_kwargs, + node_labels_kwargs, + additional_edges_kwargs, + ) = _as_dicts( + layout_kwargs, + edges_kwargs, + nodes_kwargs, + edge_labels_kwargs, + node_labels_kwargs, + additional_edges_kwargs, + ) if pos is None: pos = layout_node_link(H, with_additional_edges, layout=layout, **layout_kwargs) @@ -496,36 +1145,66 @@ def draw( # however, we need to copy this to prevent side-effects edges_kwargs = add_edge_defaults(H, edges_kwargs) - polys = draw_hyper_edges( - H, - pos, - node_radius=node_radius, - ax=ax, - contain_hyper_edges=contain_hyper_edges, - fill_edges=fill_edges, - fill_edge_alpha=fill_edge_alpha, - **edges_kwargs - ) + geometries: list[BaseGeometry] = [] + if convex: + polys = draw_hyper_edges( + H, + pos, + node_radius=node_radius, + ax=ax, + contain_hyper_edges=contain_hyper_edges, + fill_edges=fill_edges, + fill_edge_alpha=fill_edge_alpha, + **edges_kwargs, + ) + else: + polys = None + geometries = draw_concave_hyper_edges( + H, + pos, + node_radius=node_radius, + concavity=concavity, + buffer_distance=buffer_distance, + ax=ax, + fill_edges=fill_edges, + fill_edge_alpha=fill_edge_alpha, + **edges_kwargs, + ) if with_additional_edges: nx.draw_networkx_edges( with_additional_edges, pos=pos, ax=ax, - **inflate_kwargs(with_additional_edges.edges(), additional_edges_kwargs) + **inflate_kwargs(with_additional_edges.edges(), additional_edges_kwargs), ) if with_edge_labels: - draw_hyper_edge_labels( - H, - pos, - inflate(H.edges, list(H.edges) if edge_labels is None else edge_labels), - polys, - backgroundcolor=(1, 1, 1, edge_label_alpha), - ax=ax, - edge_labels_on_edge=edge_labels_on_edge, - **{"color": polys.get_edgecolors(), **edge_labels_kwargs} + edge_label_values = inflate( + H.edges, list(H.edges) if edge_labels is None else edge_labels ) + if convex: + draw_hyper_edge_labels( + H, + pos, + edge_label_values, + polys, + backgroundcolor=(1, 1, 1, edge_label_alpha), + ax=ax, + edge_labels_on_edge=edge_labels_on_edge, + **{"color": polys.get_edgecolors(), **edge_labels_kwargs}, + ) + else: + draw_concave_edge_labels( + H, + pos, + edge_label_values, + geometries, + backgroundcolor=(1, 1, 1, edge_label_alpha), + ax=ax, + edge_labels_on_edge=edge_labels_on_edge, + **{"color": edges_kwargs["edgecolors"], **edge_labels_kwargs}, + ) if with_node_labels: draw_hyper_labels( @@ -540,13 +1219,13 @@ def draw( "textcoords": "offset points", "backgroundcolor": (1, 1, 1, node_label_alpha), **node_labels_kwargs, - } + }, ) draw_hyper_nodes(H, pos, node_radius=node_radius, ax=ax, **nodes_kwargs) if len(H.nodes) == 1: - x, y = pos[list(H.nodes)[0]] + x, y = pos[next(iter(H.nodes))] s = 20 ax.axis([x - s, x + s, y - s, y + s]) diff --git a/pyproject.toml b/pyproject.toml index 000b5731..76359a67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ bitsets = ">0.8.0" plotly = ">=6.3.0" celluloid = ">=0.2.0" oat-python = ">=0.1.1" +shapely = ">=2.0" [tool.poetry.extras] embeddings = ["torch","torchvision"] diff --git a/requirements.txt b/requirements.txt index 49a94a53..4fecf0d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ hypernetx fastjsonschema +shapely # autocommand==2.2.2 # Installed as dependency for jaraco.text # celluloid==0.2.0 # Installed as dependency for hypernetx # certifi==2025.8.3 # Installed as dependency for requests diff --git a/tests/drawing/conftest.py b/tests/drawing/conftest.py new file mode 100644 index 00000000..389f0619 --- /dev/null +++ b/tests/drawing/conftest.py @@ -0,0 +1,5 @@ +"""Force a non-interactive Matplotlib backend for drawing tests.""" + +import matplotlib + +matplotlib.use("Agg") diff --git a/tests/drawing/test_rubber_band.py b/tests/drawing/test_rubber_band.py new file mode 100644 index 00000000..4bb85589 --- /dev/null +++ b/tests/drawing/test_rubber_band.py @@ -0,0 +1,229 @@ +"""Tests for convex and concave rubber-band drawing.""" + +from collections.abc import Iterator + +import matplotlib.pyplot as plt +import numpy as np +import pytest +from matplotlib.collections import PolyCollection +from matplotlib.patches import PathPatch +from shapely.geometry import Point, Polygon +from shapely.geometry.base import BaseGeometry + +from hypernetx import Hypergraph +from hypernetx.drawing.rubber_band import create_rubber_band, draw, get_concave_hull + + +@pytest.fixture(autouse=True) +def _close_figures() -> Iterator[None]: + """Close figures created while drawing.""" + yield + plt.close("all") + + +def _polygons(geometry: BaseGeometry) -> list[Polygon]: + """Return polygon components of a shapely geometry. + + Parameters + ---------- + geometry : BaseGeometry + Polygon or multipolygon produced by a rubber band. + + Returns + ------- + list of Polygon + Polygon parts. A single polygon is returned as a one-item list. + """ + if isinstance(geometry, Polygon): + return [geometry] + if geometry.geom_type == "MultiPolygon": + return list(geometry.geoms) + return [] + + +def _u_shaped_points() -> np.ndarray: + """Return a point set with a wide rectangular bay. + + Local spacing is 1, so an alpha of 0.5 keeps those triangles + (circumradius about 0.7) and drops triangles that span the bay. + + Returns + ------- + np.ndarray + Array of shape (n, 2). + """ + points = [] + for x in (0, 1): + for y in range(4): + points.append((x, y)) + for x in (2, 3, 4): + for y in (2, 3): + points.append((x, y)) + for x in (5, 6): + for y in range(4): + points.append((x, y)) + return np.asarray(points, dtype=float) + + +def test_get_concave_hull_rejects_bad_shape() -> None: + """Reject point arrays that are not two-dimensional coordinates.""" + with pytest.raises(ValueError, match="shape"): + get_concave_hull(np.ones((4, 3))) + + +def test_nonpositive_alpha_is_the_convex_hull() -> None: + """An alpha of zero returns the convex hull.""" + square = np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]) + hull = get_concave_hull(square, alpha=0.0) + assert hull.area == pytest.approx(1.0) + + +def test_alpha_shape_is_more_concave_than_the_convex_hull() -> None: + """A wide bay is excluded from the alpha shape and kept by the convex hull.""" + points = _u_shaped_points() + concave = get_concave_hull(points, alpha=0.5) + convex = get_concave_hull(points, alpha=0.0) + assert concave.area < convex.area + + +def test_rubber_band_covers_members_and_cuts_out_interior_nodes() -> None: + """Member corners stay covered and a node in the middle is removed.""" + positions = { + "a": (0.0, 0.0), + "b": (2.0, 0.0), + "c": (2.0, 2.0), + "d": (0.0, 2.0), + "outsider": (1.0, 1.0), + } + radii = {node: 0.05 for node in positions} + geometry = create_rubber_band( + positions, + ["a", "b", "c", "d"], + buffer_distance=0.1, + alpha=0.0, + other_nodes=["outsider"], + node_radius=radii, + ) + for node in ("a", "b", "c", "d"): + assert geometry.contains(Point(positions[node])) + assert not geometry.covers(Point(positions["outsider"])) + assert any(len(polygon.interiors) > 0 for polygon in _polygons(geometry)) + + +def test_rubber_band_is_deterministic() -> None: + """Repeated calls with the same arguments return the same geometry.""" + positions = {"a": (0.0, 0.0), "b": (1.0, 0.2), "c": (0.2, 1.0), "d": (0.4, 0.4)} + radii = {node: 0.02 for node in positions} + first = create_rubber_band( + positions, + ["a", "b", "c"], + buffer_distance=0.15, + alpha=1.0, + other_nodes=["d"], + node_radius=radii, + ) + second = create_rubber_band( + positions, + ["a", "b", "c"], + buffer_distance=0.15, + alpha=1.0, + other_nodes=["d"], + node_radius=radii, + ) + assert first.equals(second) + + +def test_one_and_two_node_edges_cover_their_nodes() -> None: + """Edges with fewer than three nodes become a disk or a capsule.""" + positions = {"a": (0.0, 0.0), "b": (1.0, 0.0)} + disk = create_rubber_band(positions, ["a"], buffer_distance=0.2, other_nodes=[]) + capsule = create_rubber_band( + positions, ["a", "b"], buffer_distance=0.2, other_nodes=[] + ) + assert disk.contains(Point(positions["a"])) + assert capsule.contains(Point(positions["a"])) + assert capsule.contains(Point(positions["b"])) + assert create_rubber_band(positions, []).is_empty + + +def test_draw_convex_uses_polycollection() -> None: + """The default drawing path keeps the convex-hull collection.""" + hypergraph = Hypergraph({"e1": {0, 1, 2}, "e2": {1, 2, 3}}) + positions = { + 0: (0.0, 0.0), + 1: (1.0, 0.2), + 2: (1.0, 1.0), + 3: (2.0, 0.4), + "e1": (0.7, 0.4), + "e2": (1.3, 0.5), + } + _, axis = plt.subplots() + returned = draw( + hypergraph, + pos=positions, + ax=axis, + with_edge_labels=True, + with_node_labels=True, + return_pos=True, + ) + assert returned == positions + assert any(isinstance(artist, PolyCollection) for artist in axis.collections) + assert not any(isinstance(artist, PathPatch) for artist in axis.patches) + + +def test_draw_concave_cuts_layout_nodes_and_labels() -> None: + """Concave drawing adds one patch per edge and can place labels inside.""" + hypergraph = Hypergraph({"e1": {0, 1, 2, 3}, "e2": {2, 3, 4}}) + positions = { + 0: (0.0, 0.0), + 1: (1.0, 0.1), + 2: (1.1, 1.0), + 3: (0.1, 1.0), + 4: (2.0, 0.4), + "e1": (0.5, 0.5), + "e2": (1.4, 0.6), + } + _, axis = plt.subplots() + draw( + hypergraph, + pos=positions, + ax=axis, + convex=False, + concavity=0.5, + with_edge_labels=True, + with_node_labels=True, + edge_labels_on_edge=False, + fill_edges=True, + ) + patches = [artist for artist in axis.patches if isinstance(artist, PathPatch)] + assert len(patches) == len(hypergraph.edges) + assert len(axis.texts) >= len(hypergraph.edges) + len(hypergraph.nodes) + + +def test_draw_concave_one_node_and_layout() -> None: + """A one-node edge and a computed layout both draw without error.""" + single = Hypergraph({"e": [1]}) + _, axis = plt.subplots() + draw( + single, + pos={1: (0.0, 0.0), "e": (0.2, 0.0)}, + ax=axis, + convex=False, + with_edge_labels=False, + with_node_labels=False, + ) + assert any(isinstance(artist, PathPatch) for artist in axis.patches) + + hypergraph = Hypergraph({0: (1, 2, 3), 1: (2, 3, 4)}) + _, layout_axis = plt.subplots() + positions = draw( + hypergraph, + ax=layout_axis, + convex=False, + concavity=4.0, + return_pos=True, + with_edge_labels=False, + with_node_labels=False, + ) + assert set(hypergraph.nodes).issubset(positions) + assert layout_axis.patches