iplotx 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iplotx/__init__.py +2 -0
- iplotx/edge/arrow.py +122 -0
- iplotx/edge/common.py +47 -0
- iplotx/edge/directed.py +149 -0
- iplotx/edge/label.py +50 -0
- iplotx/edge/undirected.py +447 -0
- iplotx/groups.py +141 -0
- iplotx/heuristics.py +114 -0
- iplotx/importing.py +13 -0
- iplotx/network.py +507 -0
- iplotx/plotting.py +104 -0
- iplotx/styles.py +186 -0
- iplotx/typing.py +41 -0
- iplotx/utils/geometry.py +227 -0
- iplotx/utils/matplotlib.py +136 -0
- iplotx/version.py +1 -0
- iplotx/vertex.py +112 -0
- iplotx-0.0.1.dist-info/METADATA +39 -0
- iplotx-0.0.1.dist-info/RECORD +20 -0
- iplotx-0.0.1.dist-info/WHEEL +5 -0
iplotx/styles.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from typing import Union, Sequence, Hashable
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
style_leaves = (
|
|
9
|
+
"edgecolor",
|
|
10
|
+
"facecolor",
|
|
11
|
+
"linewidth",
|
|
12
|
+
"linestyle",
|
|
13
|
+
"alpha",
|
|
14
|
+
"zorder",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
default = {
|
|
19
|
+
"vertex": {
|
|
20
|
+
"size": 20,
|
|
21
|
+
"facecolor": "black",
|
|
22
|
+
"marker": "o",
|
|
23
|
+
"label": {
|
|
24
|
+
"horizontalalignment": "center",
|
|
25
|
+
"verticalalignment": "center",
|
|
26
|
+
"hpadding": 18,
|
|
27
|
+
"vpadding": 12,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
"edge": {
|
|
31
|
+
"linewidth": 1.5,
|
|
32
|
+
"linestyle": "-",
|
|
33
|
+
"color": "black",
|
|
34
|
+
"curved": False,
|
|
35
|
+
"offset": 3,
|
|
36
|
+
"tension": 1,
|
|
37
|
+
"label": {
|
|
38
|
+
"horizontalalignment": "center",
|
|
39
|
+
"verticalalignment": "center",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
"arrow": {
|
|
43
|
+
"marker": "|>",
|
|
44
|
+
"width": 8,
|
|
45
|
+
"color": "black",
|
|
46
|
+
},
|
|
47
|
+
"grouping": {
|
|
48
|
+
"facecolor": ["grey", "steelblue", "tomato"],
|
|
49
|
+
"edgecolor": "black",
|
|
50
|
+
"linewidth": 1.5,
|
|
51
|
+
"alpha": 0.5,
|
|
52
|
+
"vertexpadding": 25,
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
hollow = deepcopy(default)
|
|
57
|
+
hollow["vertex"]["color"] = None
|
|
58
|
+
hollow["vertex"]["facecolor"] = "none"
|
|
59
|
+
hollow["vertex"]["edgecolor"] = "black"
|
|
60
|
+
hollow["vertex"]["linewidth"] = 1.5
|
|
61
|
+
hollow["vertex"]["marker"] = "r"
|
|
62
|
+
hollow["vertex"]["size"] = "label"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
styles = {
|
|
66
|
+
"default": default,
|
|
67
|
+
"hollow": hollow,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
stylename = "default"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
current = deepcopy(styles["default"])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_stylename():
|
|
78
|
+
"""Return the name of the current iplotx style."""
|
|
79
|
+
return str(stylename)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_style(name: str = ""):
|
|
83
|
+
namelist = name.split(".")
|
|
84
|
+
style = styles
|
|
85
|
+
for i, namei in enumerate(namelist):
|
|
86
|
+
if (i == 0) and (namei == ""):
|
|
87
|
+
style = current
|
|
88
|
+
else:
|
|
89
|
+
try:
|
|
90
|
+
style = style[namei]
|
|
91
|
+
except KeyError:
|
|
92
|
+
raise KeyError(f"Style not found: {name}")
|
|
93
|
+
|
|
94
|
+
style = deepcopy(style)
|
|
95
|
+
return style
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# The following is inspired by matplotlib's style library
|
|
99
|
+
# https://github.com/matplotlib/matplotlib/blob/v3.10.3/lib/matplotlib/style/core.py#L45
|
|
100
|
+
def use(style: Union[str, dict, Sequence]):
|
|
101
|
+
"""Use iplotx style setting for a style specification.
|
|
102
|
+
|
|
103
|
+
The style name of 'default' is reserved for reverting back to
|
|
104
|
+
the default style settings.
|
|
105
|
+
|
|
106
|
+
Parameters:
|
|
107
|
+
style: A style specification, currently either a name of an existing style
|
|
108
|
+
or a dict with specific parts of the style to override. The string
|
|
109
|
+
"default" resets the style to the default one. If this is a sequence,
|
|
110
|
+
each style is applied in order.
|
|
111
|
+
"""
|
|
112
|
+
global current
|
|
113
|
+
|
|
114
|
+
def _update(style: dict, current: dict):
|
|
115
|
+
for key, value in style.items():
|
|
116
|
+
if key not in current:
|
|
117
|
+
current[key] = value
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Style leaves are by definition not to be recurred into
|
|
121
|
+
if isinstance(value, dict) and (key not in style_leaves):
|
|
122
|
+
_update(value, current[key])
|
|
123
|
+
elif value is None:
|
|
124
|
+
del current[key]
|
|
125
|
+
else:
|
|
126
|
+
current[key] = value
|
|
127
|
+
|
|
128
|
+
if isinstance(style, (dict, str)):
|
|
129
|
+
styles = [style]
|
|
130
|
+
else:
|
|
131
|
+
styles = style
|
|
132
|
+
|
|
133
|
+
for style in styles:
|
|
134
|
+
if style == "default":
|
|
135
|
+
reset()
|
|
136
|
+
else:
|
|
137
|
+
if isinstance(style, str):
|
|
138
|
+
current = get_style(style)
|
|
139
|
+
else:
|
|
140
|
+
_update(style, current)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def reset():
|
|
144
|
+
"""Reset to default style."""
|
|
145
|
+
global current
|
|
146
|
+
current = deepcopy(styles["default"])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@contextmanager
|
|
150
|
+
def stylecontext(style: Union[str, dict, Sequence]):
|
|
151
|
+
current = get_style()
|
|
152
|
+
try:
|
|
153
|
+
use(style)
|
|
154
|
+
yield
|
|
155
|
+
finally:
|
|
156
|
+
use(current)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def rotate_style(
|
|
160
|
+
style,
|
|
161
|
+
index: Union[int, None] = None,
|
|
162
|
+
id: Union[Hashable, None] = None,
|
|
163
|
+
props=style_leaves,
|
|
164
|
+
):
|
|
165
|
+
if (index is None) and (id is None):
|
|
166
|
+
raise ValueError(
|
|
167
|
+
"At least one of 'index' or 'id' must be provided to rotate_style."
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
style = deepcopy(style)
|
|
171
|
+
|
|
172
|
+
for prop in props:
|
|
173
|
+
val = style.get(prop, None)
|
|
174
|
+
if val is None:
|
|
175
|
+
continue
|
|
176
|
+
# NOTE: this assumes that these properties are leaves of the style tree
|
|
177
|
+
# Btw: dict includes defaultdict, Couter, etc.
|
|
178
|
+
if (id is not None) and isinstance(val, (dict, pd.Series)):
|
|
179
|
+
# This works on both dict-like and Series
|
|
180
|
+
style[prop] = val[id]
|
|
181
|
+
elif (index is not None) and isinstance(
|
|
182
|
+
val, (tuple, list, np.ndarray, pd.Index, pd.Series)
|
|
183
|
+
):
|
|
184
|
+
style[prop] = np.asarray(val)[index % len(val)]
|
|
185
|
+
|
|
186
|
+
return style
|
iplotx/typing.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import Union, Sequence
|
|
2
|
+
from numpy import ndarray
|
|
3
|
+
from pandas import DataFrame
|
|
4
|
+
|
|
5
|
+
from .importing import igraph, networkx
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
igraphGraph = igraph.Graph if igraph is None else None
|
|
9
|
+
if networkx is not None:
|
|
10
|
+
from networkx import Graph as networkxGraph
|
|
11
|
+
from networkx import DiGraph as networkxDiGraph
|
|
12
|
+
from networkx import MultiGraph as networkxMultiGraph
|
|
13
|
+
from networkx import MultiDiGraph as networkxMultiDiGraph
|
|
14
|
+
|
|
15
|
+
networkxOmniGraph = Union[
|
|
16
|
+
networkxGraph, networkxDiGraph, networkxMultiGraph, networkxMultiDiGraph
|
|
17
|
+
]
|
|
18
|
+
else:
|
|
19
|
+
networkxOmniGraph = None
|
|
20
|
+
|
|
21
|
+
if igraphGraph is not None and networkxOmniGraph is not None:
|
|
22
|
+
GraphType = Union[igraphGraph, networkxOmniGraph]
|
|
23
|
+
elif igraphGraph is not None:
|
|
24
|
+
GraphType = igraphGraph
|
|
25
|
+
else:
|
|
26
|
+
GraphType = networkxOmniGraph
|
|
27
|
+
|
|
28
|
+
LayoutType = Union[str, Sequence[Sequence[float]], ndarray, DataFrame]
|
|
29
|
+
|
|
30
|
+
if (igraph is not None) and (networkx is not None):
|
|
31
|
+
# networkx returns generators of sets, igraph has its own classes
|
|
32
|
+
# additionally, one can put list of memberships
|
|
33
|
+
GroupingType = Union[
|
|
34
|
+
Sequence[set],
|
|
35
|
+
igraph.clustering.Clustering,
|
|
36
|
+
igraph.clustering.VertexClustering,
|
|
37
|
+
igraph.clustering.Cover,
|
|
38
|
+
igraph.clustering.VertexCover,
|
|
39
|
+
Sequence[int],
|
|
40
|
+
Sequence[str],
|
|
41
|
+
]
|
iplotx/utils/geometry.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from math import tan, atan2
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
# See also this link for the general answer (using scipy to compute coefficients):
|
|
6
|
+
# https://stackoverflow.com/questions/12643079/b%C3%A9zier-curve-fitting-with-scipy
|
|
7
|
+
def _evaluate_squared_bezier(points, t):
|
|
8
|
+
"""Evaluate a squared Bezier curve at t."""
|
|
9
|
+
p0, p1, p2 = points
|
|
10
|
+
return (1 - t) ** 2 * p0 + 2 * (1 - t) * t * p1 + t**2 * p2
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _evaluate_cubic_bezier(points, t):
|
|
14
|
+
"""Evaluate a cubic Bezier curve at t."""
|
|
15
|
+
p0, p1, p2, p3 = points
|
|
16
|
+
return (
|
|
17
|
+
(1 - t) ** 3 * p0
|
|
18
|
+
+ 3 * (1 - t) ** 2 * t * p1
|
|
19
|
+
+ 3 * (1 - t) * t**2 * p2
|
|
20
|
+
+ t**3 * p3
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def convex_hull(points):
|
|
25
|
+
"""Compute the convex hull of a set of 2D points."""
|
|
26
|
+
from ..importing import igraph
|
|
27
|
+
|
|
28
|
+
points = np.asarray(points)
|
|
29
|
+
|
|
30
|
+
# igraph's should be faster in 2D
|
|
31
|
+
if igraph is not None:
|
|
32
|
+
hull_idx = igraph.convex_hull(list(points))
|
|
33
|
+
else:
|
|
34
|
+
try:
|
|
35
|
+
from scipy.spatial import ConvexHull
|
|
36
|
+
|
|
37
|
+
hull_idx = ConvexHull(points).vertices
|
|
38
|
+
except ImportError:
|
|
39
|
+
hull_idx = _convex_hull_Graham_scan(points)
|
|
40
|
+
|
|
41
|
+
return hull_idx
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# see also: https://github.com/igraph/igraph/blob/075be76c92b99ca4c95ad9207bcc1af6d471c85e/src/misc/other.c#L116
|
|
45
|
+
# Compared to that C implementation, this is a bit more vectorised and messes less with memory as usual when
|
|
46
|
+
# optimising Python/numpy code
|
|
47
|
+
def _convex_hull_Graham_scan(points):
|
|
48
|
+
"""Compute the indices for the convex hull of a set of 2D points using Graham's scan algorithm."""
|
|
49
|
+
if len(points) < 4:
|
|
50
|
+
# NOTE: for an exact triangle, this does not guarantee chirality. Should be ok anyway
|
|
51
|
+
return np.arange(len(points))
|
|
52
|
+
|
|
53
|
+
points = np.asarray(points)
|
|
54
|
+
|
|
55
|
+
# Find pivot (bottom left corner)
|
|
56
|
+
miny_idx = np.flatnonzero(points[:, 1] == points[:, 1].min())
|
|
57
|
+
pivot_idx = miny_idx[points[miny_idx, 0].argmin()]
|
|
58
|
+
|
|
59
|
+
# Compute angles against that pivot, ensuring the pivot itself last
|
|
60
|
+
angles = np.arctan2(
|
|
61
|
+
points[:, 1] - points[pivot_idx, 1], points[:, 0] - points[pivot_idx, 0]
|
|
62
|
+
)
|
|
63
|
+
angles[pivot_idx] = np.inf
|
|
64
|
+
|
|
65
|
+
# Sort points by angle
|
|
66
|
+
order = np.argsort(angles)
|
|
67
|
+
|
|
68
|
+
# Whenever two points have the same angle, keep the furthest one from the pivot
|
|
69
|
+
# whenever an index is discarded from "order", set it to -1
|
|
70
|
+
j = 0
|
|
71
|
+
last_idx = order[0]
|
|
72
|
+
pivot_idx = order[-1]
|
|
73
|
+
for i in range(1, len(order)):
|
|
74
|
+
next_idx = order[i]
|
|
75
|
+
if angles[last_idx] == angles[next_idx]:
|
|
76
|
+
dlast = np.linalg.norm(points[last_idx] - points[pivot_idx])
|
|
77
|
+
dnext = np.linalg.norm(points[next_idx] - points[pivot_idx])
|
|
78
|
+
# Ignore the new point, it's inside
|
|
79
|
+
if dlast > dnext:
|
|
80
|
+
order[i] = -1
|
|
81
|
+
# Ignore the old point, it's inside
|
|
82
|
+
# The new one has a chance (depending on who comes next)
|
|
83
|
+
else:
|
|
84
|
+
order[j] = -1
|
|
85
|
+
last_idx = next_idx
|
|
86
|
+
j = i
|
|
87
|
+
# New angle found: this point automatically gets a chance
|
|
88
|
+
# (depending on who comes next). This also means that the
|
|
89
|
+
# last point (last_idx before reassignment) will make it into
|
|
90
|
+
# the hull
|
|
91
|
+
else:
|
|
92
|
+
last_idx = next_idx
|
|
93
|
+
j += 1
|
|
94
|
+
|
|
95
|
+
# Construct the hull from all indices that are not -1
|
|
96
|
+
order = order[order != -1]
|
|
97
|
+
jorder = len(order) - 1
|
|
98
|
+
stack = []
|
|
99
|
+
j = 0
|
|
100
|
+
last_idx = -1
|
|
101
|
+
before_last_idx = -1
|
|
102
|
+
while jorder > -1:
|
|
103
|
+
next_idx = order[jorder]
|
|
104
|
+
|
|
105
|
+
# If doing a correct turn (right), add the point to the hull
|
|
106
|
+
# if doing a wrong turn (left), backtrack and skip
|
|
107
|
+
|
|
108
|
+
# At the beginning, assume it's a good turn to start collecting points
|
|
109
|
+
if j < 2:
|
|
110
|
+
cp = -1
|
|
111
|
+
else:
|
|
112
|
+
cp = (points[last_idx, 0] - points[before_last_idx, 0]) * (
|
|
113
|
+
points[next_idx, 1] - points[before_last_idx, 1]
|
|
114
|
+
) - (points[next_idx, 0] - points[before_last_idx, 0]) * (
|
|
115
|
+
points[last_idx, 1] - points[before_last_idx, 1]
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# turning correctly or accumulating: add to the stack
|
|
119
|
+
if cp < 0:
|
|
120
|
+
jorder -= 1
|
|
121
|
+
stack.append(next_idx)
|
|
122
|
+
j += 1
|
|
123
|
+
before_last_idx = last_idx
|
|
124
|
+
last_idx = next_idx
|
|
125
|
+
|
|
126
|
+
# wrong turn: backtrack, excise wrong point and move to next vertex
|
|
127
|
+
else:
|
|
128
|
+
del stack[-1]
|
|
129
|
+
j -= 1
|
|
130
|
+
last_idx = before_last_idx
|
|
131
|
+
before_last_idx = stack[j - 2] if j >= 2 else -1
|
|
132
|
+
|
|
133
|
+
stack = np.asarray(stack)
|
|
134
|
+
|
|
135
|
+
return stack
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _compute_group_path_with_vertex_padding(
|
|
139
|
+
points,
|
|
140
|
+
transform,
|
|
141
|
+
vertexpadding=10,
|
|
142
|
+
):
|
|
143
|
+
"""Offset path for a group based on vertex padding.
|
|
144
|
+
|
|
145
|
+
At the input, the structure is [v1, v1, v1, ..., vn, vn, vn, v1]
|
|
146
|
+
|
|
147
|
+
# NOTE: this would look better as a cubic Bezier, but ok for now.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
# Transform into figure coordinates
|
|
151
|
+
trans = transform.transform
|
|
152
|
+
trans_inv = transform.inverted().transform
|
|
153
|
+
points = trans(points)
|
|
154
|
+
|
|
155
|
+
# Find the vertex centers, to recompute the offsets from scratch
|
|
156
|
+
# Independent whether this is a first call or a later draw,
|
|
157
|
+
# finding the vertex center can be done at once
|
|
158
|
+
# 0. .->.vcenter
|
|
159
|
+
# | | ^
|
|
160
|
+
# | | |
|
|
161
|
+
# 1.--.2 .--.
|
|
162
|
+
# singleton group
|
|
163
|
+
s2 = 0.96
|
|
164
|
+
if len(points) == 9:
|
|
165
|
+
points[:] = 0.5 * (points[0] + points[4])
|
|
166
|
+
points[0] += np.array([0, -1]) * vertexpadding
|
|
167
|
+
points[1] += np.array([-s2, -s2]) * vertexpadding
|
|
168
|
+
points[2] += np.array([-1, 0]) * vertexpadding
|
|
169
|
+
points[3] += np.array([-s2, s2]) * vertexpadding
|
|
170
|
+
points[4] += np.array([0, 1]) * vertexpadding
|
|
171
|
+
points[5] += np.array([s2, s2]) * vertexpadding
|
|
172
|
+
points[6] += np.array([1, 0]) * vertexpadding
|
|
173
|
+
points[7] += np.array([s2, -s2]) * vertexpadding
|
|
174
|
+
points[8] += np.array([0, -1]) * vertexpadding
|
|
175
|
+
else:
|
|
176
|
+
# doublet group are a bit different from triangles+
|
|
177
|
+
if len(points) == 11:
|
|
178
|
+
# points per vertex
|
|
179
|
+
ppv = 5
|
|
180
|
+
points[:-1:ppv] = 0.5 * (points[:-1:ppv] + points[ppv - 1 : -1 : ppv])
|
|
181
|
+
else:
|
|
182
|
+
ppv = 3
|
|
183
|
+
points[:-1:ppv] = (
|
|
184
|
+
points[:-1:ppv] + points[ppv - 1 : -1 : ppv] - points[1:-1:ppv]
|
|
185
|
+
)
|
|
186
|
+
for j in range(1, ppv):
|
|
187
|
+
points[j:-1:ppv] = points[:-1:ppv]
|
|
188
|
+
points[-1] = points[0]
|
|
189
|
+
|
|
190
|
+
# Compute all shift vectors by diff, arctan2, then add 90 degrees, tan, norm
|
|
191
|
+
# This maintains chirality
|
|
192
|
+
# NOTE: the last point is just going back to the beginning, this
|
|
193
|
+
# is a quirk or how mpl's closed paths work
|
|
194
|
+
|
|
195
|
+
# Normalised diff
|
|
196
|
+
vpoints = points[:-1:ppv].copy()
|
|
197
|
+
vpoints[0] -= points[-2]
|
|
198
|
+
vpoints[1:] -= points[:-1:ppv][:-1]
|
|
199
|
+
vpoints = (vpoints.T / np.sqrt((vpoints**2).sum(axis=1))).T
|
|
200
|
+
|
|
201
|
+
# Rotate by 90 degrees
|
|
202
|
+
vpads = vpoints @ np.array([[0, 1], [-1, 0]])
|
|
203
|
+
|
|
204
|
+
# Permute diff for the end
|
|
205
|
+
vpads_perm = np.zeros_like(vpads)
|
|
206
|
+
vpads_perm[:-1] = vpads[1:]
|
|
207
|
+
vpads_perm[-1] = vpads[0]
|
|
208
|
+
|
|
209
|
+
# Shift the points
|
|
210
|
+
if ppv == 3:
|
|
211
|
+
points[:-1:ppv] += vpads * vertexpadding
|
|
212
|
+
points[1:-1:ppv] += (vpads + vpads_perm) * vertexpadding
|
|
213
|
+
points[2:-1:ppv] += vpads_perm * vertexpadding
|
|
214
|
+
else:
|
|
215
|
+
points[:-1:ppv] += vpads * vertexpadding
|
|
216
|
+
points[1:-1:ppv] += (vpads + vpoints) * vertexpadding
|
|
217
|
+
points[2:-1:ppv] += vpoints * vertexpadding
|
|
218
|
+
points[3:-1:ppv] += (vpads_perm + vpoints) * vertexpadding
|
|
219
|
+
points[4:-1:ppv] += vpads_perm * vertexpadding
|
|
220
|
+
|
|
221
|
+
# mpl's quirky closed-path thing
|
|
222
|
+
points[-1] = points[0]
|
|
223
|
+
|
|
224
|
+
# Transform back to data coordinates
|
|
225
|
+
points = trans_inv(points)
|
|
226
|
+
|
|
227
|
+
return points
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from functools import wraps, partial
|
|
2
|
+
from math import atan2
|
|
3
|
+
import matplotlib as mpl
|
|
4
|
+
|
|
5
|
+
from .geometry import (
|
|
6
|
+
_evaluate_squared_bezier,
|
|
7
|
+
_evaluate_cubic_bezier,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# NOTE: https://github.com/networkx/grave/blob/main/grave/grave.py
|
|
12
|
+
def _stale_wrapper(func):
|
|
13
|
+
"""Decorator to manage artist state."""
|
|
14
|
+
|
|
15
|
+
@wraps(func)
|
|
16
|
+
def inner(self, *args, **kwargs):
|
|
17
|
+
try:
|
|
18
|
+
func(self, *args, **kwargs)
|
|
19
|
+
finally:
|
|
20
|
+
self.stale = False
|
|
21
|
+
|
|
22
|
+
return inner
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _forwarder(forwards, cls=None):
|
|
26
|
+
"""Decorator to forward specific methods to Artist children."""
|
|
27
|
+
if cls is None:
|
|
28
|
+
return partial(_forwarder, forwards)
|
|
29
|
+
|
|
30
|
+
def make_forward(name):
|
|
31
|
+
def method(self, *args, **kwargs):
|
|
32
|
+
ret = getattr(cls.mro()[1], name)(self, *args, **kwargs)
|
|
33
|
+
for c in self.get_children():
|
|
34
|
+
getattr(c, name)(*args, **kwargs)
|
|
35
|
+
return ret
|
|
36
|
+
|
|
37
|
+
return method
|
|
38
|
+
|
|
39
|
+
for f in forwards:
|
|
40
|
+
method = make_forward(f)
|
|
41
|
+
method.__name__ = f
|
|
42
|
+
method.__doc__ = "broadcasts {} to children".format(f)
|
|
43
|
+
setattr(cls, f, method)
|
|
44
|
+
|
|
45
|
+
return cls
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _additional_set_methods(attributes, cls=None):
|
|
49
|
+
"""Decorator to add specific set methods for children properties."""
|
|
50
|
+
if cls is None:
|
|
51
|
+
return partial(_additional_set_methods, attributes)
|
|
52
|
+
|
|
53
|
+
def make_setter(name):
|
|
54
|
+
def method(self, value):
|
|
55
|
+
self.set(**{name: value})
|
|
56
|
+
|
|
57
|
+
return method
|
|
58
|
+
|
|
59
|
+
for attr in attributes:
|
|
60
|
+
desc = attr.replace("_", " ")
|
|
61
|
+
method = make_setter(attr)
|
|
62
|
+
method.__name__ = f"set_{attr}"
|
|
63
|
+
method.__doc__ = f"Set {desc}."
|
|
64
|
+
setattr(cls, f"set_{attr}", method)
|
|
65
|
+
|
|
66
|
+
return cls
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# FIXME: this method appears quite inconsistent, would be better to improve.
|
|
70
|
+
# The issue is that to really know the size of a label on screen, we need to
|
|
71
|
+
# render it first. Therefore, we should render the labels, then render the
|
|
72
|
+
# vertices. Leaving for now, since this can be styled manually which covers
|
|
73
|
+
# many use cases.
|
|
74
|
+
def _get_label_width_height(text, hpadding=18, vpadding=12, **kwargs):
|
|
75
|
+
"""Get the bounding box size for a text with certain properties."""
|
|
76
|
+
forbidden_props = ["horizontalalignment", "verticalalignment", "ha", "va"]
|
|
77
|
+
for prop in forbidden_props:
|
|
78
|
+
if prop in kwargs:
|
|
79
|
+
del kwargs[prop]
|
|
80
|
+
|
|
81
|
+
path = mpl.textpath.TextPath((0, 0), text, **kwargs)
|
|
82
|
+
boundingbox = path.get_extents()
|
|
83
|
+
width = boundingbox.width + hpadding
|
|
84
|
+
height = boundingbox.height + vpadding
|
|
85
|
+
return (width, height)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _compute_mid_coord(path):
|
|
89
|
+
"""Compute mid point of an edge, straight or curved."""
|
|
90
|
+
# Distinguish between straight and curved paths
|
|
91
|
+
if path.codes[-1] == mpl.path.Path.LINETO:
|
|
92
|
+
return path.vertices.mean(axis=0)
|
|
93
|
+
|
|
94
|
+
# Cubic Bezier
|
|
95
|
+
if path.codes[-1] == mpl.path.Path.CURVE4:
|
|
96
|
+
return _evaluate_cubic_bezier(path.vertices, 0.5)
|
|
97
|
+
|
|
98
|
+
# Square Bezier
|
|
99
|
+
if path.codes[-1] == mpl.path.Path.CURVE3:
|
|
100
|
+
return _evaluate_squared_bezier(path.vertices, 0.5)
|
|
101
|
+
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"Curve type not straight and not squared/cubic Bezier, cannot compute mid point."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _compute_group_path_with_vertex_padding(
|
|
108
|
+
points,
|
|
109
|
+
transform,
|
|
110
|
+
vertexpadding=10,
|
|
111
|
+
):
|
|
112
|
+
"""Offset path for a group based on vertex padding.
|
|
113
|
+
|
|
114
|
+
At the input, the structure is [v1, v1, v1, v2, v2, v2, ...]
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
# Transform into figure coordinates
|
|
118
|
+
trans = transform.transform
|
|
119
|
+
trans_inv = transform.inverted().transform
|
|
120
|
+
points = trans(points)
|
|
121
|
+
|
|
122
|
+
npoints = len(points) // 3
|
|
123
|
+
vprev = points[-1]
|
|
124
|
+
mprev = atan2(points[0, 1] - vprev[1], points[0, 0] - vprev[0])
|
|
125
|
+
for i, vcur in enumerate(points[::3]):
|
|
126
|
+
vnext = points[(i + 1) * 3]
|
|
127
|
+
mnext = atan2(vnext[1] - vcur[1], vnext[0] - vcur[0])
|
|
128
|
+
|
|
129
|
+
mprev_orth = -1 / mprev
|
|
130
|
+
points[i * 3] = vcur + vertexpadding * mprev_orth
|
|
131
|
+
|
|
132
|
+
vprev = vcur
|
|
133
|
+
mprev = mnext
|
|
134
|
+
|
|
135
|
+
points = trans_inv(points)
|
|
136
|
+
return points
|
iplotx/version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
iplotx/vertex.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from matplotlib.transforms import IdentityTransform
|
|
3
|
+
from matplotlib.collections import PatchCollection
|
|
4
|
+
from matplotlib.patches import (
|
|
5
|
+
Ellipse,
|
|
6
|
+
Circle,
|
|
7
|
+
RegularPolygon,
|
|
8
|
+
Rectangle,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class VertexCollection(PatchCollection):
|
|
13
|
+
"""Collection of vertex patches for plotting.
|
|
14
|
+
|
|
15
|
+
This class takes additional keyword arguments compared to PatchCollection:
|
|
16
|
+
|
|
17
|
+
@param vertex_builder: A list of vertex builders to construct the visual
|
|
18
|
+
vertices. This is updated if the size of the vertices is changed.
|
|
19
|
+
@param size_callback: A function to be triggered after vertex sizes are
|
|
20
|
+
changed. Typically this redraws the edges.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, *args, **kwargs):
|
|
24
|
+
super().__init__(*args, **kwargs)
|
|
25
|
+
|
|
26
|
+
def get_sizes(self):
|
|
27
|
+
"""Same as get_size."""
|
|
28
|
+
return self.get_size()
|
|
29
|
+
|
|
30
|
+
def get_size(self):
|
|
31
|
+
"""Get vertex sizes.
|
|
32
|
+
|
|
33
|
+
If width and height are unequal, get the largest of the two.
|
|
34
|
+
|
|
35
|
+
@return: An array of vertex sizes.
|
|
36
|
+
"""
|
|
37
|
+
import numpy as np
|
|
38
|
+
|
|
39
|
+
sizes = []
|
|
40
|
+
for path in self.get_paths():
|
|
41
|
+
bbox = path.get_extents()
|
|
42
|
+
mins, maxs = bbox.min, bbox.max
|
|
43
|
+
width, height = maxs - mins
|
|
44
|
+
size = max(width, height)
|
|
45
|
+
sizes.append(size)
|
|
46
|
+
return np.array(sizes)
|
|
47
|
+
|
|
48
|
+
def set_size(self, sizes):
|
|
49
|
+
"""Set vertex sizes.
|
|
50
|
+
|
|
51
|
+
This rescales the current vertex symbol/path linearly, using this
|
|
52
|
+
value as the largest of width and height.
|
|
53
|
+
|
|
54
|
+
@param sizes: A sequence of vertex sizes or a single size.
|
|
55
|
+
"""
|
|
56
|
+
paths = self._paths
|
|
57
|
+
try:
|
|
58
|
+
iter(sizes)
|
|
59
|
+
except TypeError:
|
|
60
|
+
sizes = [sizes] * len(paths)
|
|
61
|
+
|
|
62
|
+
sizes = list(sizes)
|
|
63
|
+
current_sizes = self.get_sizes()
|
|
64
|
+
for path, cursize in zip(paths, current_sizes):
|
|
65
|
+
# Circular use of sizes
|
|
66
|
+
size = sizes.pop(0)
|
|
67
|
+
sizes.append(size)
|
|
68
|
+
# Rescale the path for this vertex
|
|
69
|
+
path.vertices *= size / cursize
|
|
70
|
+
|
|
71
|
+
self.stale = True
|
|
72
|
+
|
|
73
|
+
def set_sizes(self, sizes):
|
|
74
|
+
"""Same as set_size."""
|
|
75
|
+
self.set_size(sizes)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def stale(self):
|
|
79
|
+
return super().stale
|
|
80
|
+
|
|
81
|
+
@stale.setter
|
|
82
|
+
def stale(self, val):
|
|
83
|
+
PatchCollection.stale.fset(self, val)
|
|
84
|
+
if val and hasattr(self, "stale_callback_post"):
|
|
85
|
+
self.stale_callback_post(self)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def make_patch(marker: str, size, **kwargs):
|
|
89
|
+
"""Make a patch of the given marker shape and size."""
|
|
90
|
+
forbidden_props = ["label"]
|
|
91
|
+
for prop in forbidden_props:
|
|
92
|
+
if prop in kwargs:
|
|
93
|
+
kwargs.pop(prop)
|
|
94
|
+
|
|
95
|
+
if isinstance(size, (int, float)):
|
|
96
|
+
size = (size, size)
|
|
97
|
+
|
|
98
|
+
if marker in ("o", "circle"):
|
|
99
|
+
return Circle((0, 0), size[0] / 2, **kwargs)
|
|
100
|
+
elif marker in ("s", "square", "r", "rectangle"):
|
|
101
|
+
return Rectangle((-size[0] / 2, -size[1] / 2), size[0], size[1], **kwargs)
|
|
102
|
+
elif marker in ("^", "triangle"):
|
|
103
|
+
return RegularPolygon((0, 0), numVertices=3, radius=size[0] / 2, **kwargs)
|
|
104
|
+
elif marker in ("d", "diamond"):
|
|
105
|
+
return make_patch("s", size[0], angle=45, **kwargs)
|
|
106
|
+
elif marker in ("v", "triangle_down"):
|
|
107
|
+
return RegularPolygon(
|
|
108
|
+
(0, 0), numVertices=3, radius=size[0] / 2, orientation=np.pi, **kwargs
|
|
109
|
+
)
|
|
110
|
+
elif marker in ("e", "ellipse"):
|
|
111
|
+
return Ellipse((0, 0), size[0] / 2, size[1] / 2, **kwargs)
|
|
112
|
+
raise KeyError(f"Unknown marker: {marker}")
|