iplotx 0.1.0__py3-none-any.whl → 0.2.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 +22 -1
- iplotx/edge/__init__.py +623 -0
- iplotx/edge/arrow.py +220 -10
- iplotx/edge/geometry.py +392 -0
- iplotx/edge/ports.py +47 -0
- iplotx/groups.py +93 -45
- iplotx/ingest/__init__.py +155 -0
- iplotx/ingest/heuristics.py +209 -0
- iplotx/ingest/providers/network/igraph.py +96 -0
- iplotx/ingest/providers/network/networkx.py +133 -0
- iplotx/ingest/providers/tree/biopython.py +105 -0
- iplotx/ingest/providers/tree/cogent3.py +112 -0
- iplotx/ingest/providers/tree/ete4.py +112 -0
- iplotx/ingest/providers/tree/skbio.py +112 -0
- iplotx/ingest/typing.py +100 -0
- iplotx/label.py +162 -0
- iplotx/layout.py +139 -0
- iplotx/network.py +161 -379
- iplotx/plotting.py +157 -56
- iplotx/style.py +391 -0
- iplotx/tree.py +312 -0
- iplotx/typing.py +55 -41
- iplotx/utils/geometry.py +128 -81
- iplotx/utils/internal.py +3 -0
- iplotx/utils/matplotlib.py +58 -38
- iplotx/utils/style.py +1 -0
- iplotx/version.py +5 -1
- iplotx/vertex.py +305 -55
- iplotx-0.2.1.dist-info/METADATA +88 -0
- iplotx-0.2.1.dist-info/RECORD +31 -0
- iplotx/edge/common.py +0 -47
- iplotx/edge/directed.py +0 -149
- iplotx/edge/label.py +0 -50
- iplotx/edge/undirected.py +0 -447
- iplotx/heuristics.py +0 -114
- iplotx/importing.py +0 -13
- iplotx/styles.py +0 -186
- iplotx-0.1.0.dist-info/METADATA +0 -47
- iplotx-0.1.0.dist-info/RECORD +0 -20
- {iplotx-0.1.0.dist-info → iplotx-0.2.1.dist-info}/WHEEL +0 -0
iplotx/tree.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
from typing import (
|
|
2
|
+
Optional,
|
|
3
|
+
Sequence,
|
|
4
|
+
)
|
|
5
|
+
from collections.abc import Hashable
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import matplotlib as mpl
|
|
10
|
+
|
|
11
|
+
from .style import (
|
|
12
|
+
get_style,
|
|
13
|
+
rotate_style,
|
|
14
|
+
)
|
|
15
|
+
from .utils.matplotlib import (
|
|
16
|
+
_stale_wrapper,
|
|
17
|
+
_forwarder,
|
|
18
|
+
_build_cmap_fun,
|
|
19
|
+
)
|
|
20
|
+
from .ingest import (
|
|
21
|
+
ingest_tree_data,
|
|
22
|
+
)
|
|
23
|
+
from .vertex import (
|
|
24
|
+
VertexCollection,
|
|
25
|
+
)
|
|
26
|
+
from .edge import (
|
|
27
|
+
EdgeCollection,
|
|
28
|
+
make_stub_patch as make_undirected_edge_patch,
|
|
29
|
+
)
|
|
30
|
+
from .label import (
|
|
31
|
+
LabelCollection,
|
|
32
|
+
)
|
|
33
|
+
from .network import (
|
|
34
|
+
_update_from_internal,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@_forwarder(
|
|
39
|
+
(
|
|
40
|
+
"set_clip_path",
|
|
41
|
+
"set_clip_box",
|
|
42
|
+
"set_snap",
|
|
43
|
+
"set_sketch_params",
|
|
44
|
+
"set_animated",
|
|
45
|
+
"set_picker",
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
class TreeArtist(mpl.artist.Artist):
|
|
49
|
+
"""Artist for plotting trees."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
tree,
|
|
54
|
+
layout="horizontal",
|
|
55
|
+
orientation="right",
|
|
56
|
+
directed: bool | str = False,
|
|
57
|
+
vertex_labels: Optional[
|
|
58
|
+
bool | list[str] | dict[Hashable, str] | pd.Series
|
|
59
|
+
] = None,
|
|
60
|
+
edge_labels: Optional[Sequence] = None,
|
|
61
|
+
transform: mpl.transforms.Transform = mpl.transforms.IdentityTransform(),
|
|
62
|
+
offset_transform: Optional[mpl.transforms.Transform] = None,
|
|
63
|
+
):
|
|
64
|
+
"""Initialize the TreeArtist.
|
|
65
|
+
|
|
66
|
+
Parameters:
|
|
67
|
+
tree: The tree to plot.
|
|
68
|
+
layout: The layout to use for the tree. Can be "horizontal", "vertical", or "radial".
|
|
69
|
+
orientation: The orientation of the tree layout. Can be "right" or "left" (for
|
|
70
|
+
horizontal and radial layouts) and "descending" or "ascending" (for vertical
|
|
71
|
+
layouts).
|
|
72
|
+
directed: Whether the tree is directed. Can be a boolean or a string with the
|
|
73
|
+
following choices: "parent" or "child".
|
|
74
|
+
vertex_labels: Labels for the vertices. Can be a list, dictionary, or pandas Series.
|
|
75
|
+
edge_labels: Labels for the edges. Can be a sequence of strings.
|
|
76
|
+
transform: The transform to apply to the tree artist. This is usually the identity.
|
|
77
|
+
offset_transform: The offset transform to apply to the tree artist. This is
|
|
78
|
+
usually `ax.transData`.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
self.tree = tree
|
|
82
|
+
self._ipx_internal_data = ingest_tree_data(
|
|
83
|
+
tree,
|
|
84
|
+
layout,
|
|
85
|
+
orientation=orientation,
|
|
86
|
+
directed=directed,
|
|
87
|
+
vertex_labels=vertex_labels,
|
|
88
|
+
edge_labels=edge_labels,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
super().__init__()
|
|
92
|
+
|
|
93
|
+
# This is usually the identity (which scales poorly with dpi)
|
|
94
|
+
self.set_transform(transform)
|
|
95
|
+
|
|
96
|
+
# This is usually transData
|
|
97
|
+
self.set_offset_transform(offset_transform)
|
|
98
|
+
|
|
99
|
+
zorder = get_style(".network").get("zorder", 1)
|
|
100
|
+
self.set_zorder(zorder)
|
|
101
|
+
|
|
102
|
+
self._add_vertices()
|
|
103
|
+
self._add_edges()
|
|
104
|
+
|
|
105
|
+
def get_children(self) -> tuple[mpl.artist.Artist]:
|
|
106
|
+
"""Get the children of this artist.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The artists for vertices and edges.
|
|
110
|
+
"""
|
|
111
|
+
return (self._vertices, self._edges)
|
|
112
|
+
|
|
113
|
+
def set_figure(self, fig) -> None:
|
|
114
|
+
"""Set the figure for this artist and its children.
|
|
115
|
+
|
|
116
|
+
Parameters:
|
|
117
|
+
fig: the figure to set for this artist and its children.
|
|
118
|
+
"""
|
|
119
|
+
super().set_figure(fig)
|
|
120
|
+
for child in self.get_children():
|
|
121
|
+
child.set_figure(fig)
|
|
122
|
+
|
|
123
|
+
def get_offset_transform(self):
|
|
124
|
+
"""Get the offset transform (for vertices/edges)."""
|
|
125
|
+
return self._offset_transform
|
|
126
|
+
|
|
127
|
+
def set_offset_transform(self, offset_transform):
|
|
128
|
+
"""Set the offset transform (for vertices/edges)."""
|
|
129
|
+
self._offset_transform = offset_transform
|
|
130
|
+
|
|
131
|
+
def get_layout(self, kind="vertex"):
|
|
132
|
+
"""Get vertex or edge layout."""
|
|
133
|
+
layout_columns = [
|
|
134
|
+
f"_ipx_layout_{i}" for i in range(self._ipx_internal_data["ndim"])
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
if kind == "vertex":
|
|
138
|
+
layout = self._ipx_internal_data["vertex_df"][layout_columns]
|
|
139
|
+
return layout
|
|
140
|
+
|
|
141
|
+
elif kind == "edge":
|
|
142
|
+
return self._ipx_internal_data["edge_df"][layout_columns]
|
|
143
|
+
else:
|
|
144
|
+
raise ValueError(f"Unknown layout kind: {kind}. Use 'vertex' or 'edge'.")
|
|
145
|
+
|
|
146
|
+
def get_datalim(self, transData, pad=0.15):
|
|
147
|
+
"""Get limits on x/y axes based on the graph layout data.
|
|
148
|
+
|
|
149
|
+
Parameters:
|
|
150
|
+
transData (Transform): The transform to use for the data.
|
|
151
|
+
pad (float): Padding to add to the limits. Default is 0.05.
|
|
152
|
+
Units are a fraction of total axis range before padding.
|
|
153
|
+
"""
|
|
154
|
+
layout = self.get_layout().values
|
|
155
|
+
|
|
156
|
+
if len(layout) == 0:
|
|
157
|
+
return mpl.transforms.Bbox([[0, 0], [1, 1]])
|
|
158
|
+
|
|
159
|
+
bbox = self._vertices.get_datalim(transData)
|
|
160
|
+
|
|
161
|
+
edge_bbox = self._edges.get_datalim(transData)
|
|
162
|
+
bbox = mpl.transforms.Bbox.union([bbox, edge_bbox])
|
|
163
|
+
|
|
164
|
+
bbox = bbox.expanded(sw=(1.0 + pad), sh=(1.0 + pad))
|
|
165
|
+
return bbox
|
|
166
|
+
|
|
167
|
+
def _get_label_series(self, kind: str) -> Optional[pd.Series]:
|
|
168
|
+
if "label" in self._ipx_internal_data[f"{kind}_df"].columns:
|
|
169
|
+
return self._ipx_internal_data[f"{kind}_df"]["label"]
|
|
170
|
+
else:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def get_vertices(self) -> VertexCollection:
|
|
174
|
+
"""Get VertexCollection artist."""
|
|
175
|
+
return self._vertices
|
|
176
|
+
|
|
177
|
+
def get_edges(self) -> EdgeCollection:
|
|
178
|
+
"""Get EdgeCollection artist."""
|
|
179
|
+
return self._edges
|
|
180
|
+
|
|
181
|
+
def get_vertex_labels(self) -> LabelCollection:
|
|
182
|
+
"""Get list of vertex label artists."""
|
|
183
|
+
return self._vertices.get_labels()
|
|
184
|
+
|
|
185
|
+
def get_edge_labels(self) -> LabelCollection:
|
|
186
|
+
"""Get list of edge label artists."""
|
|
187
|
+
return self._edges.get_labels()
|
|
188
|
+
|
|
189
|
+
def _add_vertices(self) -> None:
|
|
190
|
+
"""Add vertices to the tree."""
|
|
191
|
+
self._vertices = VertexCollection(
|
|
192
|
+
layout=self.get_layout(),
|
|
193
|
+
layout_coordinate_system=self._ipx_internal_data.get(
|
|
194
|
+
"layout_coordinate_system",
|
|
195
|
+
"catesian",
|
|
196
|
+
),
|
|
197
|
+
style=get_style(".vertex"),
|
|
198
|
+
labels=self._get_label_series("vertex"),
|
|
199
|
+
transform=self.get_transform(),
|
|
200
|
+
offset_transform=self.get_offset_transform(),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def _add_edges(self) -> None:
|
|
204
|
+
"""Add edges to the network artist.
|
|
205
|
+
|
|
206
|
+
NOTE: UndirectedEdgeCollection and ArrowCollection are both subclasses of
|
|
207
|
+
PatchCollection. When used with a cmap/norm, they set their facecolor
|
|
208
|
+
according to the cmap, even though most likely we only want the edgecolor
|
|
209
|
+
set that way. It can make for funny looking plots that are not uninteresting
|
|
210
|
+
but mostly niche at this stage. Therefore we sidestep the whole cmap thing
|
|
211
|
+
here.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
labels = self._get_label_series("edge")
|
|
215
|
+
edge_style = get_style(".edge")
|
|
216
|
+
|
|
217
|
+
if "cmap" in edge_style:
|
|
218
|
+
cmap_fun = _build_cmap_fun(
|
|
219
|
+
edge_style["color"],
|
|
220
|
+
edge_style["cmap"],
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
cmap_fun = None
|
|
224
|
+
|
|
225
|
+
edge_df = self._ipx_internal_data["edge_df"].set_index(
|
|
226
|
+
["_ipx_source", "_ipx_target"]
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if "cmap" in edge_style:
|
|
230
|
+
colorarray = []
|
|
231
|
+
edgepatches = []
|
|
232
|
+
adjacent_vertex_ids = []
|
|
233
|
+
waypoints = []
|
|
234
|
+
for i, (vid1, vid2) in enumerate(edge_df.index):
|
|
235
|
+
edge_stylei = rotate_style(edge_style, index=i, key=(vid1, vid2))
|
|
236
|
+
|
|
237
|
+
# FIXME:: Improve this logic. We have three layers of priority:
|
|
238
|
+
# 1. Explicitely set in the style of "plot"
|
|
239
|
+
# 2. Internal through network attributes
|
|
240
|
+
# 3. Default styles
|
|
241
|
+
# Because 1 and 3 are merged as a style context on the way in,
|
|
242
|
+
# it's hard to squeeze 2 in the middle. For now, we will assume
|
|
243
|
+
# the priority order is 2-1-3 instead (internal property is
|
|
244
|
+
# highest priority).
|
|
245
|
+
# This is also why we cannot shift this logic further into the
|
|
246
|
+
# EdgeCollection class, which is oblivious of NetworkArtist's
|
|
247
|
+
# internal data. In fact, one would argue this needs to be
|
|
248
|
+
# pushed outwards to deal with the wrong ordering.
|
|
249
|
+
_update_from_internal(edge_stylei, edge_df.iloc[i], kind="edge")
|
|
250
|
+
|
|
251
|
+
if cmap_fun is not None:
|
|
252
|
+
colorarray.append(edge_stylei["color"])
|
|
253
|
+
edge_stylei["color"] = cmap_fun(edge_stylei["color"])
|
|
254
|
+
|
|
255
|
+
# Tree layout determines waypoints
|
|
256
|
+
waypointsi = edge_stylei.pop("waypoints", None)
|
|
257
|
+
if waypointsi is None:
|
|
258
|
+
layout_name = self._ipx_internal_data["layout_name"]
|
|
259
|
+
if layout_name == "horizontal":
|
|
260
|
+
waypointsi = "x0y1"
|
|
261
|
+
elif layout_name == "vertical":
|
|
262
|
+
waypointsi = "y0y0"
|
|
263
|
+
elif layout_name == "radial":
|
|
264
|
+
waypointsi = "r0a1"
|
|
265
|
+
else:
|
|
266
|
+
waypointsi = "none"
|
|
267
|
+
waypoints.append(waypointsi)
|
|
268
|
+
|
|
269
|
+
# These are not the actual edges drawn, only stubs to establish
|
|
270
|
+
# the styles which are then fed into the dynamic, optimised
|
|
271
|
+
# factory (the collection) below
|
|
272
|
+
patch = make_undirected_edge_patch(
|
|
273
|
+
**edge_stylei,
|
|
274
|
+
)
|
|
275
|
+
edgepatches.append(patch)
|
|
276
|
+
adjacent_vertex_ids.append((vid1, vid2))
|
|
277
|
+
|
|
278
|
+
if "cmap" in edge_style:
|
|
279
|
+
vmin = np.min(colorarray)
|
|
280
|
+
vmax = np.max(colorarray)
|
|
281
|
+
norm = mpl.colors.Normalize(vmin=vmin, vmax=vmax)
|
|
282
|
+
edge_style["norm"] = norm
|
|
283
|
+
|
|
284
|
+
edge_style["waypoints"] = waypoints
|
|
285
|
+
|
|
286
|
+
# NOTE: Trees are directed is their "directed" property is True, "child", or "parent"
|
|
287
|
+
self._edges = EdgeCollection(
|
|
288
|
+
edgepatches,
|
|
289
|
+
vertex_ids=adjacent_vertex_ids,
|
|
290
|
+
vertex_collection=self._vertices,
|
|
291
|
+
labels=labels,
|
|
292
|
+
transform=self.get_offset_transform(),
|
|
293
|
+
style=edge_style,
|
|
294
|
+
directed=bool(self._ipx_internal_data["directed"]),
|
|
295
|
+
)
|
|
296
|
+
if "cmap" in edge_style:
|
|
297
|
+
self._edges.set_array(colorarray)
|
|
298
|
+
|
|
299
|
+
@_stale_wrapper
|
|
300
|
+
def draw(self, renderer) -> None:
|
|
301
|
+
"""Draw each of the children, with some buffering mechanism."""
|
|
302
|
+
if not self.get_visible():
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
# FIXME: Callbacks on stale vertices/edges??
|
|
306
|
+
|
|
307
|
+
# NOTE: looks like we have to manage the zorder ourselves
|
|
308
|
+
# this is kind of funny actually
|
|
309
|
+
children = list(self.get_children())
|
|
310
|
+
children.sort(key=lambda x: x.zorder)
|
|
311
|
+
for art in children:
|
|
312
|
+
art.draw(renderer)
|
iplotx/typing.py
CHANGED
|
@@ -1,41 +1,55 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
1
|
+
"""
|
|
2
|
+
Typing hints for iplotx.
|
|
3
|
+
|
|
4
|
+
Some of these types are legit, others are just Any but renamed to improve readability throughout
|
|
5
|
+
the codebase.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import (
|
|
9
|
+
Union,
|
|
10
|
+
Sequence,
|
|
11
|
+
Any,
|
|
12
|
+
TypeVar,
|
|
13
|
+
)
|
|
14
|
+
from collections.abc import Hashable
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# NOTE: GraphType is supposed to indicate any kind of graph object that is accepted by
|
|
20
|
+
# iplotx's functions, e.g. igraph.Graph or networkx.Graph and subclasses. It is not
|
|
21
|
+
# quite possible to really statically type it because providers can add their own
|
|
22
|
+
# types - together with protocols to process them - at runtime.
|
|
23
|
+
# Nonetheless, for increased readibility we define separately-named types in this
|
|
24
|
+
# module to be used throughout the codebase.
|
|
25
|
+
GraphType = Any
|
|
26
|
+
TreeType = Any
|
|
27
|
+
|
|
28
|
+
# NOTE: The commented ones are not a mistake: they are supported but cannot be
|
|
29
|
+
# statically typed if the user has no igraph installed (it's a soft dependency).
|
|
30
|
+
LayoutType = Union[
|
|
31
|
+
str,
|
|
32
|
+
Sequence[Sequence[float]],
|
|
33
|
+
np.ndarray,
|
|
34
|
+
pd.DataFrame,
|
|
35
|
+
# igraph.Layout,
|
|
36
|
+
]
|
|
37
|
+
GroupingType = Union[
|
|
38
|
+
Sequence[set],
|
|
39
|
+
Sequence[int],
|
|
40
|
+
Sequence[str],
|
|
41
|
+
# igraph.clustering.Clustering,
|
|
42
|
+
# igraph.clustering.VertexClustering,
|
|
43
|
+
# igraph.clustering.Cover,
|
|
44
|
+
# igraph.clustering.VertexCover,
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
T = TypeVar("T")
|
|
49
|
+
LeafProperty = Union[
|
|
50
|
+
T,
|
|
51
|
+
Sequence[T],
|
|
52
|
+
dict[Hashable, T],
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
Pair = tuple[T, T]
|
iplotx/utils/geometry.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from math import
|
|
1
|
+
from math import atan2
|
|
2
2
|
import numpy as np
|
|
3
3
|
|
|
4
4
|
|
|
@@ -21,22 +21,56 @@ def _evaluate_cubic_bezier(points, t):
|
|
|
21
21
|
)
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def _evaluate_cubic_bezier_derivative(points, t):
|
|
25
|
+
"""Evaluate the derivative of a cubic Bezier curve at t."""
|
|
26
|
+
p0, p1, p2, p3 = points
|
|
27
|
+
# (dx / dt, dy / dt) is the parametric gradient
|
|
28
|
+
# to get the angle from this, one can just atanh(dy/dt, dx/dt)
|
|
29
|
+
# This is equivalent to computing the actual bezier curve
|
|
30
|
+
# at low t, of course, which is the geometric interpretation
|
|
31
|
+
# (obviously, division by t is irrelenant)
|
|
32
|
+
return (
|
|
33
|
+
3 * p0 * (1 - t) ** 2
|
|
34
|
+
+ 3 * p1 * (1 - t) * (-3 * t + 1)
|
|
35
|
+
+ 3 * p2 * t * (2 - 3 * t)
|
|
36
|
+
+ 3 * p3 * t**2
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
24
40
|
def convex_hull(points):
|
|
25
|
-
"""Compute the convex hull of a set of 2D points.
|
|
26
|
-
|
|
41
|
+
"""Compute the convex hull of a set of 2D points.
|
|
42
|
+
|
|
43
|
+
This is guaranteed to return the vertices clockwise.
|
|
27
44
|
|
|
45
|
+
(Therefore, (v[i+1] - v[i]) rotated *left* by pi/2 points *outwards* of the convex hull.)
|
|
46
|
+
"""
|
|
28
47
|
points = np.asarray(points)
|
|
48
|
+
if len(points) < 3:
|
|
49
|
+
return np.arange(len(points))
|
|
50
|
+
|
|
51
|
+
hull_idx = None
|
|
29
52
|
|
|
30
53
|
# igraph's should be faster in 2D
|
|
31
|
-
|
|
54
|
+
try:
|
|
55
|
+
import igraph
|
|
56
|
+
|
|
32
57
|
hull_idx = igraph.convex_hull(list(points))
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
from scipy.spatial import ConvexHull
|
|
58
|
+
except ImportError:
|
|
59
|
+
pass
|
|
36
60
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
61
|
+
# Otherwise, try scipy
|
|
62
|
+
try:
|
|
63
|
+
from scipy.spatial import ConvexHull
|
|
64
|
+
|
|
65
|
+
# NOTE: scipy guarantees counterclockwise ordering in 2D
|
|
66
|
+
# https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.ConvexHull.html
|
|
67
|
+
hull_idx = ConvexHull(points).vertices[::-1]
|
|
68
|
+
except ImportError:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
# Last resort: our own Graham scan
|
|
72
|
+
if hull_idx is None:
|
|
73
|
+
hull_idx = _convex_hull_Graham_scan(points)
|
|
40
74
|
|
|
41
75
|
return hull_idx
|
|
42
76
|
|
|
@@ -45,11 +79,10 @@ def convex_hull(points):
|
|
|
45
79
|
# Compared to that C implementation, this is a bit more vectorised and messes less with memory as usual when
|
|
46
80
|
# optimising Python/numpy code
|
|
47
81
|
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))
|
|
82
|
+
"""Compute the indices for the convex hull of a set of 2D points using Graham's scan algorithm.
|
|
52
83
|
|
|
84
|
+
NOTE: This works from 3 points upwards, guaranteed clockwise.
|
|
85
|
+
"""
|
|
53
86
|
points = np.asarray(points)
|
|
54
87
|
|
|
55
88
|
# Find pivot (bottom left corner)
|
|
@@ -136,9 +169,13 @@ def _convex_hull_Graham_scan(points):
|
|
|
136
169
|
|
|
137
170
|
|
|
138
171
|
def _compute_group_path_with_vertex_padding(
|
|
172
|
+
hull,
|
|
139
173
|
points,
|
|
140
174
|
transform,
|
|
141
175
|
vertexpadding=10,
|
|
176
|
+
points_per_curve=30,
|
|
177
|
+
# TODO: check how dpi affects this
|
|
178
|
+
dpi=72.0,
|
|
142
179
|
):
|
|
143
180
|
"""Offset path for a group based on vertex padding.
|
|
144
181
|
|
|
@@ -146,82 +183,92 @@ def _compute_group_path_with_vertex_padding(
|
|
|
146
183
|
|
|
147
184
|
# NOTE: this would look better as a cubic Bezier, but ok for now.
|
|
148
185
|
"""
|
|
186
|
+
# Short form
|
|
187
|
+
ppc = points_per_curve
|
|
188
|
+
|
|
189
|
+
# No padding, set degenerate path
|
|
190
|
+
if vertexpadding == 0:
|
|
191
|
+
for j, point in enumerate(hull):
|
|
192
|
+
points[ppc * j : ppc * (j + 1)] = point
|
|
193
|
+
points[-1] = points[0]
|
|
194
|
+
return points
|
|
149
195
|
|
|
150
196
|
# Transform into figure coordinates
|
|
151
197
|
trans = transform.transform
|
|
152
198
|
trans_inv = transform.inverted().transform
|
|
153
199
|
points = trans(points)
|
|
154
200
|
|
|
155
|
-
#
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
201
|
+
# Singleton: draw a circle around it
|
|
202
|
+
if len(hull) == 1:
|
|
203
|
+
|
|
204
|
+
# NOTE: linspace is double inclusive, which covers CLOSEPOLY
|
|
205
|
+
thetas = np.linspace(
|
|
206
|
+
-np.pi,
|
|
207
|
+
np.pi,
|
|
208
|
+
len(points),
|
|
209
|
+
)
|
|
210
|
+
# NOTE: dpi scaling might need to happen here
|
|
211
|
+
perimeter = vertexpadding * np.vstack([np.cos(thetas), np.sin(thetas)]).T
|
|
212
|
+
return trans_inv(trans(hull[0]) + perimeter)
|
|
213
|
+
|
|
214
|
+
# Doublet: draw two semicircles
|
|
215
|
+
if len(hull) == 2:
|
|
216
|
+
|
|
217
|
+
# Unit vector connecting the two points
|
|
218
|
+
dv = trans(hull[0]) - trans(hull[1])
|
|
219
|
+
dv = dv / np.sqrt((dv**2).sum())
|
|
220
|
+
|
|
221
|
+
# Draw a semicircle
|
|
222
|
+
angles = np.linspace(-0.5 * np.pi, 0.5 * np.pi, 30)
|
|
223
|
+
vs = np.array([np.cos(angles), -np.sin(angles), np.sin(angles), np.cos(angles)])
|
|
224
|
+
vs = vs.T.reshape((len(angles), 2, 2))
|
|
225
|
+
vs = np.matmul(dv, vs)
|
|
226
|
+
|
|
227
|
+
# NOTE: dpi scaling might need to happen here
|
|
228
|
+
semicircle1 = vertexpadding * vs
|
|
229
|
+
semicircle2 = vertexpadding * np.matmul(vs, -np.diag((1, 1)))
|
|
230
|
+
|
|
231
|
+
# Put it together
|
|
232
|
+
vs1 = trans_inv(trans(hull[0]) + semicircle1)
|
|
233
|
+
vs2 = trans_inv(trans(hull[1]) + semicircle2)
|
|
234
|
+
points[:ppc] = vs1
|
|
235
|
+
points[ppc:-1] = vs2
|
|
188
236
|
points[-1] = points[0]
|
|
237
|
+
return points
|
|
189
238
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
239
|
+
# At least three points, i.e. a nondegenerate convex hull
|
|
240
|
+
nsides = len(hull)
|
|
241
|
+
for i, point1 in enumerate(hull):
|
|
242
|
+
point0 = hull[i - 1]
|
|
243
|
+
point2 = hull[(i + 1) % nsides]
|
|
220
244
|
|
|
221
|
-
|
|
222
|
-
|
|
245
|
+
# NOTE: this can be optimised by computing things once
|
|
246
|
+
# unit vector to previous point
|
|
247
|
+
dv0 = trans(point1) - trans(point0)
|
|
248
|
+
dv0 = dv0 / np.sqrt((dv0**2).sum())
|
|
223
249
|
|
|
224
|
-
|
|
225
|
-
|
|
250
|
+
# unit vector to next point
|
|
251
|
+
dv2 = trans(point2) - trans(point1)
|
|
252
|
+
dv2 = dv2 / np.sqrt((dv2**2).sum())
|
|
226
253
|
|
|
254
|
+
# span the angles
|
|
255
|
+
theta0 = atan2(dv0[1], dv0[0])
|
|
256
|
+
theta2 = atan2(dv2[1], dv2[0])
|
|
257
|
+
|
|
258
|
+
# The worst that can happen is that we go exactly backwards, i.e. theta2 == theta0 + np.pi
|
|
259
|
+
# if it's more than that, we are on the inside of the convex hull due to the periodicity of atan2
|
|
260
|
+
if theta2 - theta0 > np.pi:
|
|
261
|
+
theta2 -= 2 * np.pi
|
|
262
|
+
|
|
263
|
+
# angles is from the point of view of the first vector, dv0
|
|
264
|
+
angles = np.linspace(theta0 + np.pi / 2, theta2 + np.pi / 2, ppc)
|
|
265
|
+
vs = np.array([np.cos(angles), np.sin(angles)]).T
|
|
266
|
+
|
|
267
|
+
# NOTE: dpi scaling might need to happen here
|
|
268
|
+
chunkcircle = vertexpadding * vs
|
|
269
|
+
|
|
270
|
+
vs1 = trans_inv(trans(point1) + chunkcircle)
|
|
271
|
+
points[i * ppc : (i + 1) * ppc] = vs1
|
|
272
|
+
|
|
273
|
+
points[-1] = points[0]
|
|
227
274
|
return points
|
iplotx/utils/internal.py
ADDED