import hypernetx as hnx
from hypernetx.drawing.util import inflate_kwargs, inflate_labels, inflate
from . import network_simplex as ns
from . import crossings
from hypernetx.drawing.rubber_band import add_edge_defaults
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
from scipy.interpolate import Akima1DInterpolator
from matplotlib.collections import LineCollection, PolyCollection, EllipseCollection
from collections import OrderedDict
from itertools import combinations
from heapdict import heapdict
EDGE_WIDTH = 0.5
class Layout:
def __init__(self, H, edge_order=None, node_order=None, seed=123456789):
self.H = H
self.seed = seed
combined_order = spectral_ordering_with_collapse(H.bipartite(), seed=seed)
def create_order(entity_set, override):
if override is None:
return [v for v in combined_order if v in entity_set]
return override
self.node_order = create_order(self.H.nodes, node_order)
self.edge_order = create_order(self.H.edges, edge_order)
# mapping from edges to x-coordinate
self.x = {e: i for i, e in enumerate(self.edge_order)}
self.line_endpoints = {}
for v in self.H.nodes:
xs = list(map(self.x.get, self.H.nodes[v]))
self.line_endpoints[v] = (min(xs), max(xs))
# default y is just a straight line
self.y = {
(v, x): i
for i, v in enumerate(self.node_order)
for x in self.get_line_coordinates(v)
}
def get_line_coordinates(self, v):
start, end = self.line_endpoints[v]
return range(start, end + 1)
def incidence_order(self):
return [(e, v) for e in self.H.edges() for v in self.H.edges[e]]
def segment_order(self):
def iter_node_segments(v):
edges = sorted(self.H.nodes[v], key=self.x.get)
return zip(edges[:-1], edges[1:])
return [
((e1, v), (e2, v)) for v in self.H.nodes for e1, e2 in iter_node_segments(v)
]
def get_storylines(self, r=0.125, n=50, **kwargs):
def smooth(func, x1, x2):
x = np.linspace(x1, x2, n)
return np.vstack((x, func(x))).T
def interpolate(seg):
((e1, v), (e2, v2)) = seg
assert v == v2
start = self.x[e1]
end = self.x[e2]
assert start < end
points = np.array(
[
(i + dr, self.y[v, i])
for i in range(start, end + 1)
for dr in (-r, r)
]
)
points[0, 0] += r
points[-1, 0] -= r
func = Akima1DInterpolator(*points.T)
return np.vstack(
[
[(x1, y1), (x2, y2)] if y1 == y2 else smooth(func, x1, x2)
for (x1, y1), (x2, y2) in zip(points[:-1], points[1:])
]
)
return LineCollection(map(interpolate, self.segment_order()), **kwargs)
def get_edges(self, r=0.25, y_cap_scale=2, **kwargs):
theta = np.linspace(0, np.pi, 21)
half_circle = np.array([r * np.cos(theta), r * y_cap_scale * np.sin(theta)]).T
def make_edge(e):
x = self.x[e]
ys = [self.y[(v, x)] for v in self.H.edges[e]]
y1 = min(ys)
y2 = max(ys)
return np.vstack(
[
half_circle * np.array([1, -1]) + np.array([x, y1]),
(half_circle + np.array([x, y2]))[::-1],
]
)
return PolyCollection(map(make_edge, self.H.edges), **kwargs)
def get_incidences(self, ax=None, r=0.125, **kwargs):
ax = ax or plt.gca()
offsets = np.array(
[(self.x[e], self.y[v, self.x[e]]) for e, v in self.incidence_order()]
)
sizes = 2 * r
return EllipseCollection(
widths=sizes,
heights=sizes,
angles=0,
units="x",
offsets=offsets,
transOffset=ax.transData,
**kwargs,
)
def get_node_labels_xy(self):
def get_left_coord(v):
start, _ = self.line_endpoints[v]
return (start, self.y[v, start])
return np.array(list(map(get_left_coord, self.H.nodes())))
def get_edge_labels_xy(self):
return np.array(
[
(self.x[e], min(self.y[v, self.x[e]] for v in self.H.edges[e]))
for e in self.H.edges()
]
)
def suggest_size(self, xscale=0.5, yscale=0.25):
return np.array(
[
xscale * (len(self.x) + 1),
yscale * (max(self.y.values()) - min(self.y.values()) + 3),
]
)
def collapse_graph(G, mapping=None, partition=None, create_using=nx.Graph, **kwargs):
"""
Construct a new graph from an input graph where sets of nodes are collapsed into super nodes
Parameters
----------
mapping: dict
node -> collapsed node mapping
partition: list
list of lists of nodes
create_using: nx.Graph, nx.DiGraph, etc.
how to construct the new collapsed graph
**kwargs: dict
aggregation functions to weight the collapsed graph
Returns
-------
networkx.Graph
the collapsed graph
"""
assert (mapping is not None) ^ (
partition is not None
), "Exactly one of mapping or partition must not be None."
if partition is not None:
mapping = {v: i for i, p in enumerate(partition) for v in p}
# ensure nodes left out of partition are included in mapping
n = max(mapping.values()) + 1
for v in G:
if v not in mapping:
mapping[v] = n
n += 1
Gc = create_using()
Gc.add_nodes_from(set(mapping.values()))
for u, v, d in G.edges(data=True):
up = mapping[u]
vp = mapping[v]
if up != vp:
if not Gc.has_edge(up, vp):
Gc.add_edge(up, vp)
# add all the properties from the edge
for key, value in d.items():
Gc.get_edge_data(up, vp).setdefault(key, []).append(value)
# aggregate all the edge properties (kwargs defines the aggregator)
for _, _, d in Gc.edges(data=True):
for key, func in kwargs.items():
d[key] = func(d[key])
return Gc, mapping
def spectral_ordering_with_collapse(G, **kwargs):
"""
Order the nodes in the graph using the networkx.spectral_ordering on the collapsed graph
This collapses the input graph before ordering in order to better handle
certain cases where spectral ordering leads to obviously fix-able crossings or
other misorderings.
Parameters
----------
G: networkx.Graph
the graph to order
**kwargs: dict
aggregators to pass to collapse_graph
Returns
-------
list
ordered nodes of G
"""
S = nx.Graph()
S.add_nodes_from(G)
# Finds cases that look like this: ( )--(u)--(v)--( )
# or special caes where u or v is an endpoint.
# These cases can be found just using the degree of u and v.
# If the degree of each is 2 or less, then there is at most
# 1 more edge that connects u and v to the rest of the graph,
# so we can collapse that edge.
S.add_edges_from(
[(u, v) for u, v in G.edges() if G.degree(u) <= 2 and G.degree(v) <= 2]
)
partition = list(nx.connected_components(S))
Gc, _ = collapse_graph(G, partition=partition)
return [v for i in nx.spectral_ordering(Gc, **kwargs) for v in partition[i]]
class SvenStoryline(Layout):
def __init__(
self,
*args,
debug=False,
allow_node_crossings=True,
allow_edge_crossings=True,
min_network_simplex_weight=0.01,
dummy_non_dummy_weight=0.01,
**kwargs,
):
super().__init__(*args, **kwargs)
self.allow_node_crossings = allow_node_crossings
self.allow_edge_crossings = allow_edge_crossings
self.dummy_non_dummy_weight = dummy_non_dummy_weight
self.min_network_simplex_weight = min_network_simplex_weight
self.G = self.get_storyline_graph()
assert (
allow_node_crossings or allow_edge_crossings
), "At least one of allow_node_crossings and allow_edge_crossings must be True."
if allow_node_crossings:
if allow_edge_crossings:
self.order = self.get_combined_order()
else:
self.order = self.get_order_without_edge_crossings()
else:
self.order = self.get_order_without_node_crossings()
self.crossing_reducer = crossings.LocalCrossingReducer(
self.order, weight=self.can_swap
)
self.levels = self.crossing_reducer(None if allow_node_crossings else 0)
self.y_init = {v: i for l in self.levels for i, v in enumerate(l)}
self.children = self.get_straightened_nodes()
self.Gp, self.parents = self.get_constraint_graph()
if debug:
plt.figure()
self.draw_initial_layout()
plt.figure()
self.draw_parent_graph()
self.solver = ns.NetworkSimplex(self.Gp)
self.xp = self.get_parent_x()
self.yp = self.solver()
self.y = {v: self.yp[p] for v, p in self.parents.items()}
def get_edge_endpoints(e):
x = self.x[e]
ys = [self.y[(v, x)] for v in self.H.edges[e]]
return min(ys), max(ys)
self.edge_endpoints = {e: get_edge_endpoints(e) for e in self.H.edges}
def can_swap(self, x, u, v):
e = self.edge_order[x]
return not ((e in self.H.nodes[u]) ^ (e in self.H.nodes[v]))
def get_combined_order(self):
return [
v
for v in spectral_ordering_with_collapse(self.G, seed=self.seed)
if v not in self.x
]
def get_order_without_edge_crossings(self):
partition = [
[e, *[(v, i) for v in self.H.edges[e]]]
for i, e in enumerate(self.edge_order)
]
Gc, mapping = collapse_graph(self.G, partition=partition)
self.G_without_edge_crossings = Gc
y0 = {v: i for i, v in enumerate(self.node_order)}
self.order = order = spectral_ordering_with_collapse(Gc, seed=self.seed)
y = {v: i for i, v in enumerate(order)}
return sorted(
[v for v in self.G if v not in self.x],
key=lambda v: (y[mapping[v]], y0[v[0]]),
)
def get_order_without_node_crossings(self):
return [(v, i) for v in self.node_order for i in self.get_line_coordinates(v)]
def get_constraint_graph(self):
G = nx.DiGraph()
for i, lev in enumerate(map(list, self.levels)):
# handles singleton edges that wouldn't be covered by the loop below
G.add_node((lev[0], i))
has_node = self.H.edges[self.edge_order[i]].__contains__
for u, v in zip(lev[:-1], lev[1:]):
u_in_edge = has_node(u)
v_in_edge = has_node(v)
G.add_edge(
(u, i),
(v, i),
weight=max(
float(u_in_edge and v_in_edge), self.min_network_simplex_weight
),
length=(
1 if self.allow_edge_crossings else (u_in_edge ^ v_in_edge) + 1
),
)
Gc, parents = collapse_graph(
G,
partition=self.children.values(),
create_using=nx.DiGraph,
weight=sum,
length=max,
)
return Gc, parents
def create_levels(self, order):
levels = [OrderedDict() for i in range(len(self.edge_order))]
for k in order:
if k not in self.x:
v, i = k
levels[i][v] = len(levels[i])
return levels
def get_storyline_graph(self):
G = nx.Graph()
for v in self.H.nodes:
start, end = self.line_endpoints[v]
G.add_node((v, start)) # just in case start == end
for i in range(start, end):
G.add_edge((v, i), (v, i + 1))
for e in self.H.nodes[v]:
G.add_edge((v, self.x[e]), e)
return G
def is_dummy_node(self, v):
name, idx = v
e = self.edge_order[idx]
return name not in self.H.edges[e]
def get_straightened_nodes(self):
self.Gc = Gc = get_crossing_graph(self.levels)
# can set the weight to a small value, or to zero
for (v, i), d in self.Gc.nodes(data=True):
left = self.is_dummy_node((v, i))
right = self.is_dummy_node((v, i + 1))
d["weight"] = self.dummy_non_dummy_weight if (not left) and right else 1.0
# clear nodes with weight of zero, which could happen if `dummy_non_dummy_weight` is passed as 0.0
zero_weight = [v for v, d in self.Gc.nodes(data=True) if d["weight"] == 0.0]
self.Gc.remove_nodes_from(zero_weight)
# merges = independent_set_maximal_resample(Gc, self.seed)
# self.merges = merges = independent_set_maximum(Gc)
self.merges = merges = greedy_mwis(Gc)
G = nx.Graph()
for i, l in enumerate(self.levels):
for v in l:
G.add_node((v, i))
for v in merges:
Gc.nodes[v]["mis"] = True
for v, i in merges:
G.add_edge((v, i), (v, i + 1))
return {
i: sorted(ci, key=lambda d: d[1])
for i, ci in enumerate(nx.connected_components(G))
}
def get_parent_x(self):
return {i: np.mean([xk for k, xk in ci]) for i, ci in self.children.items()}
# debug visualizations
def draw_initial_layout(self):
G = self.G.copy()
G.remove_nodes_from(self.H.edges())
pos = {
(v, i): (i, j)
for i, lev in enumerate(self.levels)
for j, v in enumerate(lev)
}
nx.draw(G, pos, with_labels=True, node_color="white", node_size=500)
def draw_parent_graph(self, **kwargs):
labels = {
i: f"{ci[0][0]} [{ci[0][1]}-{ci[-1][1]}]" for i, ci in self.children.items()
}
self.solver.draw_layering(labels=labels, x=self.xp, **kwargs)
def draw_layering(self, initial=False):
if initial:
self.solver.draw_layering(self.solver.L_init, self.solver.T_init, x=self.xp)
violations = self.solver.violations_initial
else:
self.solver.draw_layering(x=self.xp)
violations = self.solver.violations_final
plt.title(f'Violations: {repr(violations) if len(violations) else "none"}')
def evaluate_aesthetic_criteria(self):
self.node_wiggles = {
v: [
abs(self.y[v, i + 1] - self.y[v, i])
for i in range(*self.line_endpoints[v])
]
for v in self.H.nodes()
}
self.node_efficiency_sum = np.hstack(list(self.node_wiggles.values())).sum()
self.node_wiggles_sum = (np.hstack(list(self.node_wiggles.values())) > 0).sum()
self.node_crossing_sum = self.Gc.number_of_edges()
self.edge_efficiency = {
e: 1 + ymax - ymin - len(self.H.edges[e])
for e, (ymin, ymax) in self.edge_endpoints.items()
}
self.edge_efficiency_sum = np.sum(list(self.edge_efficiency.values()))
def count_edge_crossings(e):
i = self.x[e]
ymin, ymax = self.edge_endpoints[e]
return sum(
ymin < self.y[v, i] and self.y[v, i] < ymax
for v in self.levels[i]
if v not in self.H.edges[e]
)
self.edge_crossings = {e: count_edge_crossings(e) for e in self.H.edges}
self.edge_crossings_sum = sum(self.edge_crossings.values())
self.aesthetic_str = f"Node Crossings: {self.node_crossing_sum}; Edge Crossings: {self.edge_crossings_sum}; Edge efficiency: {self.edge_efficiency_sum}; Node Efficiency: {self.node_efficiency_sum}"
def get_crossing_graph(levels):
"""
Returns a graph where an edge in the graph represents a crossing.
A crossing is simply two nodes found in adjacent levels having a different
order in one level compared to the other.
Parameters
----------
levels: list of lists
The levels to search for crossings in
Returns
-------
networkx.Graph
the crossing graph
"""
G = nx.Graph()
for i in range(len(levels) - 1):
left = levels[i]
right = levels[i + 1]
can_cross = [v for v in left if v in right]
for v in can_cross:
G.add_node((v, i))
for u, v in combinations(can_cross, 2):
if (left[u] - left[v]) * (right[u] - right[v]) < 0:
G.add_edge((u, i), (v, i))
return G
# def independent_set_maximal_resample(G, seed=123456789):
# def retry_mis(Gi):
# n = Gi.number_of_edges()
# n_retries = max(1, int(np.ceil(np.log2(n + 1))))
# sets = [nx.maximal_independent_set(Gi, seed=seed + i) for i in range(n_retries)]
# mis = max(sets, key=len)
# if n_retries > 1:
# lens = list(map(len, sets))
# print(f'R{n} = {n_retries} : {max(lens) - min(lens)}')
# print(mis)
# return mis
# return [
# v for ci in nx.connected_components(G) for v in retry_mis(nx.subgraph(G, ci))
# ]
# def independent_set_maximum(G):
# return [
# v
# for ci in nx.connected_components(G)
# for v in nx.approximation.maximum_independent_set(nx.subgraph(G, ci))
# ]
def greedy_mwis(G, weight="weight", copy=True):
"""
An approximate solution to the maximum weighted independent set (MWIS) problem.
The maximum weighted independent set problem aims to find the set of nodes
with the largest weight in a graph where no node in the set is connected to
any other node. Each node is assumed to have an arbitrary positive weight.
This algorithm uses a greedy heuristic based on the graph laplacian. The
node with the largest laplacian is added to the set first and removed from
the graph. Then the laplacian is recomputed and the next node is chosen.
Parameters
----------
G: networkx.Graph
the graph to solve
weight: str
field where weights are stored on nodes in the graph
copy: bool
conststruct a copy of the input graph, because the algorithm destroys the graph during the optimization
Returns
-------
set
a subset of the nodes in G and an approximate solution to the MWIS problem
"""
if copy:
G = G.copy()
def get_weight(v):
return G.nodes[v].get(weight, 1)
scores = heapdict(
{u: -(get_weight(u) - sum(map(get_weight, G[u]))) for u in G.nodes()}
)
s = set()
while len(scores):
k, wk = scores.popitem()
s.add(k)
nbrs = list(G[k])
for u in nbrs:
wu = get_weight(u)
for v in G[u]:
if v != k:
scores[v] -= wu
for u in nbrs:
del scores[u]
G.remove_nodes_from([k, *nbrs])
return s
[docs]
def draw_incidence_storyline(
H,
layout=None,
layout_kwargs={},
ax=None,
fig=None,
auto_size=1.0,
y_cap_scale=2,
edge_order=None,
node_order=None,
node_labels=None,
edge_labels=None,
with_node_labels=True,
with_edge_labels=True,
fill_edges=False,
fill_edge_alpha=-0.5,
edges_kwargs={},
nodes_kwargs={},
segments_kwargs={},
edge_labels_kwargs={},
node_labels_kwargs={},
edge_labels_on_axis=True,
incidences_kwargs={},
):
"""
Draw a hypergraph as a Storyline with Matplotlib--useful for edge ordered hypergraphs
This drawing has four main elements:
* Nodes: drawn as horizontal-ish lines that can bend
* Edges: drawin as vertical rounded rectangles
* Incidences: the intersection of a node and an edge drawn as a circle
* Segments: the portion of a node beetween two incidences
These elements can be flexibly encoded using the same approach for the
`hypernetx.draw` method. For example, the following code will set the color
of all nodes to pink:
>>> hypernetx.draw_incidence_storyline(
>>> H,
>>> edges_kwargs=dict(color='pink')
>>> )
More on this can be found in the tutorials.
The default layout algorithm is SvenStoryline, which is based on "Storyline
Visualization of Events on a Network" (SVEN) [1]. This algorithm can be
controlled by passing parameters into the `layout_kwargs` parameter.
For example:
* allow_node_crossings: set to True to allow storylines to cross each other
* allow_edge_crossings: set to True to allow storylines to cross edges
One of the two above parameters must be true.
Custom layouts should inherit the Layout class and are passed in using the
layout parameter.
Parameters
----------
H: hnx.Hypergraph
the entity to be drawn
layout: function
layout algorithm to compute
layout_kwargs: dict
keyword arguments passed to layout function
ax: matplotlib.axis.Axis
axis on which the plot is rendered; if None, uses plt.gca()
fig: matplotlib.figure.Figure
figure on which the plot is rendered; if None, uses plt.gcf()
auto_size: float
automatically size and scale the figure based on layout; if None, the figure is not resize
y_cap_scale: float
adjust pointy-ness of circles drawn on top of edges to adjust distortion caused by figure aspect ratio
edge_order: list
order to place edges on the x-axis
node_order: list
suggested ordering of nodes--not necessarily respected because nodes' lines can cross
node_labels: list, dict, or function
labels to use for nodes
edge_labels: list, dict, or function
labels to use for edges
with_edge_labels: bool
set to False to make edge labels invisible
with_node_labels: bool
set to False to make node labels invisible
fill_edges: bool
set to True to fill set the facecolor of edges to a lighter version of the edgecolor if no facecolor is otherwise specified
fill_edge_alpha: float
amount to add to the alpha channel when filling edges. Should be between -1 and 0, causing a decrease in alpha
edges_kwargs: dict
keyword arguments passed to matplotlib.collections.PolyCollection for edges
nodes_kwargs: dict
keyword arguments passed to matplotlib.collections.LineCollection for nodes
segments_kwargs: dict
keyword arguments passed to matplotlib.collections.LineCollection for segments (overrides nodes_kwargs)
edge_labels_kwargs: dict
keyword arguments passed to matplotlib.annotate for edge labels
node_labels_kwargs: dict
keyword argumetns passed to matplotlib.annotate for node labels
edge_labels_on_axis: bool
when True, draws edge labels on x-axis ticks; otherwise draws edge labels close to the bottom of the edge
incidences_kwargs: dict
keyword arguments passed to matplotlib.collections.EllipseCollection for incidences
Returns
-------
Layout
the layout object used to position the storylines
References
----------
[1] Arendt, Dustin L., and Leslie M. Blaha. "SVEN: Informative visual representation of complex dynamic structure." arXiv preprint arXiv:1412.6706 (2014).
"""
ax = ax or plt.gca()
edges_kwargs = add_edge_defaults(H, edges_kwargs)
default_node_color = "black"
if layout is None:
layout = SvenStoryline(
H, edge_order=edge_order, node_order=node_order, **layout_kwargs
)
edges = layout.get_edges(
y_cap_scale=y_cap_scale, zorder=1, **inflate_kwargs(H.edges, edges_kwargs)
)
if fill_edges is not False:
if fill_edges is True:
color = edges.get_edgecolors() + np.array([0, 0, 0, fill_edge_alpha])
else:
alpha = np.array(
[
[0, 0, 0, fill_edge_alpha if a else -1]
for a in inflate(H.edges, fill_edges)
]
)
color = edges.get_edgecolors() + alpha
edges.set_facecolors(color)
# inflate nodes_kwargs to use for defaults for incidences and segments
nodes_kwargs_inflated = inflate_kwargs(
H.nodes,
{
"edgecolors": default_node_color,
"facecolors": default_node_color,
**nodes_kwargs,
},
)
nodes_kwargs_dicts = {
key: dict(zip(H.nodes, values)) for key, values in nodes_kwargs_inflated.items()
}
segment_order = layout.segment_order()
default_segments_kwargs = {
k: [v[seg[0][1]] for seg in segment_order]
for k, v in nodes_kwargs_dicts.items()
}
storylines = layout.get_storylines(
zorder=2,
**inflate_kwargs(
segment_order,
{
**default_segments_kwargs,
**segments_kwargs,
"facecolors": "none", # storylines should never have a face color
},
),
)
incidence_order = layout.incidence_order()
default_incidences_kwargs = {
k: [d[v] for _, v in incidence_order] for k, d in nodes_kwargs_dicts.items()
}
incidences = layout.get_incidences(
ax=ax,
zorder=3,
**{
**default_incidences_kwargs,
**inflate_kwargs(incidence_order, incidences_kwargs),
},
)
for c in (edges, storylines, incidences):
ax.add_collection(c)
if with_node_labels:
default_text_kwargs = {
"ha": "right",
"va": "center",
"xytext": (-3, 0),
"textcoords": "offset pixels",
}
offset_xy = np.array([-0.5 * EDGE_WIDTH, 0])
node_xy = layout.get_node_labels_xy()
node_labels_and_kwargs = inflate_labels(
list(H.nodes), node_labels, node_labels_kwargs
)
for (s, kwargs), xy in zip(node_labels_and_kwargs, node_xy):
ax.annotate(s, xy + offset_xy, **{**default_text_kwargs, **kwargs})
if with_edge_labels:
default_text_kwargs = {
"ha": "center",
"va": "top",
"xytext": (0, -2),
"textcoords": "offset pixels",
}
offset_xy = np.array([0, -y_cap_scale * EDGE_WIDTH / 2])
edge_xy = layout.get_edge_labels_xy()
edge_labels_and_kwargs = inflate_labels(
list(H.edges), edge_labels, edge_labels_kwargs
)
if edge_labels_on_axis:
ax.xaxis.set_ticks(
edge_xy[:, 0],
[labels for labels, _ in edge_labels_and_kwargs],
**edge_labels_kwargs,
)
else:
for (s, kwargs), xy in zip(edge_labels_and_kwargs, edge_xy):
ax.annotate(s, xy + offset_xy, **{**default_text_kwargs, **kwargs})
ax.xaxis.set_ticks([], [])
ax.spines["bottom"].set_visible(False)
for i in ["left", "right", "top"]:
ax.spines[i].set_visible(False)
ax.autoscale_view()
ax.yaxis.set_ticks([], [])
if auto_size:
suggested_size = layout.suggest_size()
actual_size = auto_size * suggested_size
(fig or plt.gcf()).set_size_inches(*actual_size)
return layout
[docs]
def draw_incidence_upset(H, *, node_order=None, edge_order=None, **kwargs):
"""
Draw a hypergraph as an UpSet [1] like diagram with Matplotlib
This uses a spectral ordering of the nodes and edges calculated by `Layout`
to determine the position of nodes and edges. It is similar to the UpSet
visulization technique, and is a special case of the storyline layout
technique.
Parameters
----------
H: hypernetx.Hypergraph
node_order: list
order to place nodes on the y-axis
edge_order: list
order to place edges on the x-axis
**kwargs: dict
passed through to hypernetx.drawing.draw_incidence_storyline
Returns
-------
Layout
the layout object used to position the storylines
References
----------
[1] Lex, Alexander, et al. "UpSet: visualization of intersecting sets." IEEE transactions on visualization and computer graphics 20.12 (2014): 1983-1992.
"""
return draw_incidence_storyline(
H, layout=Layout(H, node_order=node_order, edge_order=edge_order), **kwargs
)