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/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
+ ]
@@ -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}")