plotform 0.1.0__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.
- plotform/__init__.py +53 -0
- plotform/api.py +47 -0
- plotform/assets/fonts/PlotformMono-OFL.txt +93 -0
- plotform/assets/fonts/PlotformMono-Regular.ttf +0 -0
- plotform/assets/fonts/PlotformMono-TRADEMARKS.md +17 -0
- plotform/assets/fonts/PlotformSans-OFL.txt +93 -0
- plotform/assets/fonts/PlotformSans-Rounded.ttf +0 -0
- plotform/assets/fonts/PlotformSans-TRADEMARKS.md +17 -0
- plotform/assets/fonts/README.md +30 -0
- plotform/colorbar_utils.py +75 -0
- plotform/fonts.py +56 -0
- plotform/legend_utils.py +199 -0
- plotform/palette.py +103 -0
- plotform/rounded.py +519 -0
- plotform/theme.py +168 -0
- plotform/three_d.py +40 -0
- plotform-0.1.0.dist-info/METADATA +267 -0
- plotform-0.1.0.dist-info/RECORD +21 -0
- plotform-0.1.0.dist-info/WHEEL +5 -0
- plotform-0.1.0.dist-info/licenses/LICENSE +24 -0
- plotform-0.1.0.dist-info/top_level.txt +1 -0
plotform/rounded.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
"""Exact capsule pills, squircles, and circularly rounded bar helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from matplotlib.container import BarContainer
|
|
7
|
+
from matplotlib.patches import PathPatch
|
|
8
|
+
from matplotlib.path import Path
|
|
9
|
+
from matplotlib.transforms import IdentityTransform
|
|
10
|
+
|
|
11
|
+
from .palette import PRIMARY, WHITE
|
|
12
|
+
|
|
13
|
+
# Cubic Bezier constant for a quarter circle.
|
|
14
|
+
_CIRCLE_KAPPA = 0.5522847498307936
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def squircle_path(
|
|
18
|
+
x: float,
|
|
19
|
+
y: float,
|
|
20
|
+
width: float,
|
|
21
|
+
height: float,
|
|
22
|
+
*,
|
|
23
|
+
exponent: float = 4.0,
|
|
24
|
+
points: int = 256,
|
|
25
|
+
) -> Path:
|
|
26
|
+
"""Return a closed superellipse path for rounded panels, not capsules."""
|
|
27
|
+
if width < 0 or height < 0:
|
|
28
|
+
raise ValueError("width and height must be nonnegative")
|
|
29
|
+
if exponent < 2:
|
|
30
|
+
raise ValueError("exponent must be at least 2")
|
|
31
|
+
if points < 32:
|
|
32
|
+
raise ValueError("points must be at least 32")
|
|
33
|
+
|
|
34
|
+
theta = np.linspace(0.0, 2.0 * np.pi, points, endpoint=False)
|
|
35
|
+
power = 2.0 / exponent
|
|
36
|
+
cos = np.cos(theta)
|
|
37
|
+
sin = np.sin(theta)
|
|
38
|
+
px = x + width / 2 + width / 2 * np.sign(cos) * np.abs(cos) ** power
|
|
39
|
+
py = y + height / 2 + height / 2 * np.sign(sin) * np.abs(sin) ** power
|
|
40
|
+
vertices = np.column_stack((px, py))
|
|
41
|
+
vertices = np.vstack((vertices, vertices[0]))
|
|
42
|
+
codes = [Path.MOVETO] + [Path.LINETO] * (points - 1) + [Path.CLOSEPOLY]
|
|
43
|
+
return Path(vertices, codes)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _rectangle_path(t0, t1, baseline, end):
|
|
47
|
+
vertices = [(t0, baseline), (t0, end), (t1, end), (t1, baseline), (t0, baseline)]
|
|
48
|
+
codes = [Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY]
|
|
49
|
+
return Path(vertices, codes)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _vertical_rounded_path(t0, t1, baseline, end, radius, ends):
|
|
53
|
+
"""Build a bar from straight lines and exact circular Bezier arcs.
|
|
54
|
+
|
|
55
|
+
With a full-width radius and ``ends='both'``, this is a stadium: two
|
|
56
|
+
semicircles joined by straight sides. Every arc control point uses the
|
|
57
|
+
standard cubic quarter-circle construction.
|
|
58
|
+
"""
|
|
59
|
+
if radius <= 1e-9 or np.isclose(baseline, end) or np.isclose(t0, t1):
|
|
60
|
+
return _rectangle_path(t0, t1, baseline, end)
|
|
61
|
+
|
|
62
|
+
sign = 1.0 if end >= baseline else -1.0
|
|
63
|
+
k = _CIRCLE_KAPPA
|
|
64
|
+
start = (t0, baseline + sign * radius) if ends == "both" else (t0, baseline)
|
|
65
|
+
vertices = [start]
|
|
66
|
+
codes = [Path.MOVETO]
|
|
67
|
+
|
|
68
|
+
def line(point):
|
|
69
|
+
vertices.append(point)
|
|
70
|
+
codes.append(Path.LINETO)
|
|
71
|
+
|
|
72
|
+
def curve(control1, control2, point):
|
|
73
|
+
vertices.extend((control1, control2, point))
|
|
74
|
+
codes.extend((Path.CURVE4, Path.CURVE4, Path.CURVE4))
|
|
75
|
+
|
|
76
|
+
# Left straight side, then the free-end left quarter circle.
|
|
77
|
+
top_center_y = end - sign * radius
|
|
78
|
+
line((t0, top_center_y))
|
|
79
|
+
curve(
|
|
80
|
+
(t0, top_center_y + sign * k * radius),
|
|
81
|
+
(t0 + (1 - k) * radius, end),
|
|
82
|
+
(t0 + radius, end),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Straight free edge (zero length for a full semicircle), then the right arc.
|
|
86
|
+
line((t1 - radius, end))
|
|
87
|
+
curve(
|
|
88
|
+
(t1 - (1 - k) * radius, end),
|
|
89
|
+
(t1, top_center_y + sign * k * radius),
|
|
90
|
+
(t1, top_center_y),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if ends == "both":
|
|
94
|
+
bottom_center_y = baseline + sign * radius
|
|
95
|
+
line((t1, bottom_center_y))
|
|
96
|
+
curve(
|
|
97
|
+
(t1, bottom_center_y - sign * k * radius),
|
|
98
|
+
(t1 - (1 - k) * radius, baseline),
|
|
99
|
+
(t1 - radius, baseline),
|
|
100
|
+
)
|
|
101
|
+
line((t0 + radius, baseline))
|
|
102
|
+
curve(
|
|
103
|
+
(t0 + (1 - k) * radius, baseline),
|
|
104
|
+
(t0, bottom_center_y - sign * k * radius),
|
|
105
|
+
(t0, bottom_center_y),
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
line((t1, baseline))
|
|
109
|
+
|
|
110
|
+
vertices.append(start)
|
|
111
|
+
codes.append(Path.CLOSEPOLY)
|
|
112
|
+
return Path(vertices, codes)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def capsule_path(x: float, y: float, width: float, height: float) -> Path:
|
|
116
|
+
"""Return an exact capsule/stadium outline.
|
|
117
|
+
|
|
118
|
+
The cap radius is exactly half the smaller dimension. Horizontal capsules
|
|
119
|
+
have semicircular left/right ends; vertical capsules have semicircular
|
|
120
|
+
top/bottom ends. Straight segments join the arcs at matching tangents.
|
|
121
|
+
"""
|
|
122
|
+
if width < 0 or height < 0:
|
|
123
|
+
raise ValueError("width and height must be nonnegative")
|
|
124
|
+
if np.isclose(width, 0) or np.isclose(height, 0):
|
|
125
|
+
return _rectangle_path(x, x + width, y, y + height)
|
|
126
|
+
radius = min(width, height) / 2
|
|
127
|
+
return _vertical_rounded_path(x, x + width, y, y + height, radius, "both")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class SquircleBox:
|
|
131
|
+
"""A Matplotlib annotation box style backed by a superellipse path."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, pad: float = 0.55, exponent: float = 4.0, points: int = 256):
|
|
134
|
+
self.pad = pad
|
|
135
|
+
self.exponent = exponent
|
|
136
|
+
self.points = points
|
|
137
|
+
|
|
138
|
+
def __call__(self, x0, y0, width, height, mutation_size):
|
|
139
|
+
pad = mutation_size * self.pad
|
|
140
|
+
return squircle_path(
|
|
141
|
+
x0 - pad,
|
|
142
|
+
y0 - pad,
|
|
143
|
+
width + 2 * pad,
|
|
144
|
+
height + 2 * pad,
|
|
145
|
+
exponent=self.exponent,
|
|
146
|
+
points=self.points,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class CapsuleBox:
|
|
151
|
+
"""An exact capsule annotation box with UI-style text metrics.
|
|
152
|
+
|
|
153
|
+
``height`` is a multiple of the font mutation size. ``None`` derives the
|
|
154
|
+
height from the text instead. Single-character labels expand to circles
|
|
155
|
+
rather than becoming narrow vertical ovals.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
pad: float = 0.65,
|
|
161
|
+
height: float | None = 1.9,
|
|
162
|
+
min_aspect: float = 1.0,
|
|
163
|
+
circle_threshold: float = 1.2,
|
|
164
|
+
):
|
|
165
|
+
if pad < 0:
|
|
166
|
+
raise ValueError("pad must be nonnegative")
|
|
167
|
+
if height is not None and height <= 0:
|
|
168
|
+
raise ValueError("height must be positive or None")
|
|
169
|
+
if min_aspect <= 0 or circle_threshold < 1:
|
|
170
|
+
raise ValueError("min_aspect must be positive and circle_threshold at least 1")
|
|
171
|
+
self.pad = pad
|
|
172
|
+
self.height = height
|
|
173
|
+
self.min_aspect = min_aspect
|
|
174
|
+
self.circle_threshold = circle_threshold
|
|
175
|
+
|
|
176
|
+
def __call__(self, x0, y0, width, height, mutation_size):
|
|
177
|
+
pad = mutation_size * self.pad
|
|
178
|
+
center_x = x0 + width / 2
|
|
179
|
+
center_y = y0 + height / 2
|
|
180
|
+
capsule_width = width + 2 * pad
|
|
181
|
+
capsule_height = (
|
|
182
|
+
mutation_size * self.height if self.height is not None else height + 2 * pad
|
|
183
|
+
)
|
|
184
|
+
if capsule_width <= self.circle_threshold * capsule_height:
|
|
185
|
+
capsule_width = capsule_height
|
|
186
|
+
else:
|
|
187
|
+
capsule_width = max(capsule_width, self.min_aspect * capsule_height)
|
|
188
|
+
return capsule_path(
|
|
189
|
+
center_x - capsule_width / 2,
|
|
190
|
+
center_y - capsule_height / 2,
|
|
191
|
+
capsule_width,
|
|
192
|
+
capsule_height,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def add_squircle(
|
|
197
|
+
ax,
|
|
198
|
+
x: float,
|
|
199
|
+
y: float,
|
|
200
|
+
width: float,
|
|
201
|
+
height: float,
|
|
202
|
+
*,
|
|
203
|
+
exponent: float = 4.0,
|
|
204
|
+
transform=None,
|
|
205
|
+
**kwargs,
|
|
206
|
+
) -> PathPatch:
|
|
207
|
+
"""Add a superellipse panel in data coordinates by default."""
|
|
208
|
+
patch = PathPatch(
|
|
209
|
+
squircle_path(x, y, width, height, exponent=exponent),
|
|
210
|
+
transform=transform or ax.transData,
|
|
211
|
+
**kwargs,
|
|
212
|
+
)
|
|
213
|
+
ax.add_patch(patch)
|
|
214
|
+
return patch
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def add_pill(
|
|
218
|
+
ax,
|
|
219
|
+
x: float,
|
|
220
|
+
y: float,
|
|
221
|
+
text: str,
|
|
222
|
+
*,
|
|
223
|
+
transform=None,
|
|
224
|
+
facecolor: str = PRIMARY,
|
|
225
|
+
textcolor: str = WHITE,
|
|
226
|
+
edgecolor: str = "none",
|
|
227
|
+
pad: float = 0.65,
|
|
228
|
+
height: float | None = 1.9,
|
|
229
|
+
fontsize: float | None = None,
|
|
230
|
+
**kwargs,
|
|
231
|
+
):
|
|
232
|
+
"""Add centered text in an exact capsule.
|
|
233
|
+
|
|
234
|
+
Coordinates use data space by default. Pass ``transform=ax.transAxes`` for
|
|
235
|
+
axes-fraction coordinates. ``height`` is relative to the font size; use
|
|
236
|
+
``height=None`` to derive it from the text metrics instead.
|
|
237
|
+
"""
|
|
238
|
+
bbox = {
|
|
239
|
+
"boxstyle": CapsuleBox(pad=pad, height=height),
|
|
240
|
+
"facecolor": facecolor,
|
|
241
|
+
"edgecolor": edgecolor,
|
|
242
|
+
"linewidth": kwargs.pop("linewidth", 0.8),
|
|
243
|
+
}
|
|
244
|
+
return ax.annotate(
|
|
245
|
+
text,
|
|
246
|
+
(x, y),
|
|
247
|
+
transform=transform or ax.transData,
|
|
248
|
+
ha="center",
|
|
249
|
+
va="center",
|
|
250
|
+
color=textcolor,
|
|
251
|
+
fontsize=fontsize,
|
|
252
|
+
bbox=bbox,
|
|
253
|
+
**kwargs,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _data_rectangle(center, length, thickness, baseline, orientation):
|
|
258
|
+
t0 = center - thickness / 2
|
|
259
|
+
t1 = center + thickness / 2
|
|
260
|
+
end = baseline + length
|
|
261
|
+
vertices = np.array([(t0, baseline), (t0, end), (t1, end), (t1, baseline), (t0, baseline)])
|
|
262
|
+
if orientation == "horizontal":
|
|
263
|
+
vertices = vertices[:, ::-1]
|
|
264
|
+
return Path(vertices, [Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO, Path.CLOSEPOLY])
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class RoundedBarPatch(PathPatch):
|
|
268
|
+
"""A circularly rounded bar recalculated in display space at draw time."""
|
|
269
|
+
|
|
270
|
+
def __init__(
|
|
271
|
+
self,
|
|
272
|
+
center,
|
|
273
|
+
length,
|
|
274
|
+
thickness,
|
|
275
|
+
baseline,
|
|
276
|
+
*,
|
|
277
|
+
radius,
|
|
278
|
+
ends,
|
|
279
|
+
orientation,
|
|
280
|
+
**kwargs,
|
|
281
|
+
):
|
|
282
|
+
self.center = float(center)
|
|
283
|
+
self.length = float(length)
|
|
284
|
+
self.thickness = float(thickness)
|
|
285
|
+
self.baseline = float(baseline)
|
|
286
|
+
self.radius = radius
|
|
287
|
+
self.ends = ends
|
|
288
|
+
self.orientation = orientation
|
|
289
|
+
self.strict_full = False
|
|
290
|
+
self.data_path = _data_rectangle(
|
|
291
|
+
self.center, self.length, self.thickness, self.baseline, self.orientation
|
|
292
|
+
)
|
|
293
|
+
super().__init__(self.data_path, transform=IdentityTransform(), **kwargs)
|
|
294
|
+
|
|
295
|
+
def get_path(self):
|
|
296
|
+
ax = getattr(self, "axes", None)
|
|
297
|
+
if ax is None:
|
|
298
|
+
return self.data_path
|
|
299
|
+
|
|
300
|
+
if self.orientation == "vertical":
|
|
301
|
+
corners = np.array(
|
|
302
|
+
[
|
|
303
|
+
(self.center - self.thickness / 2, self.baseline),
|
|
304
|
+
(self.center + self.thickness / 2, self.baseline),
|
|
305
|
+
(self.center, self.baseline + self.length),
|
|
306
|
+
(self.center, self.baseline),
|
|
307
|
+
(self.center + 1, self.baseline),
|
|
308
|
+
]
|
|
309
|
+
)
|
|
310
|
+
else:
|
|
311
|
+
corners = np.array(
|
|
312
|
+
[
|
|
313
|
+
(self.baseline, self.center - self.thickness / 2),
|
|
314
|
+
(self.baseline, self.center + self.thickness / 2),
|
|
315
|
+
(self.baseline + self.length, self.center),
|
|
316
|
+
(self.baseline, self.center),
|
|
317
|
+
(self.baseline, self.center + 1),
|
|
318
|
+
]
|
|
319
|
+
)
|
|
320
|
+
pixels = ax.transData.transform(corners)
|
|
321
|
+
if not np.isfinite(pixels).all():
|
|
322
|
+
return self.data_path
|
|
323
|
+
|
|
324
|
+
if self.orientation == "vertical":
|
|
325
|
+
t0, t1 = sorted((pixels[0, 0], pixels[1, 0]))
|
|
326
|
+
baseline, end = pixels[0, 1], pixels[2, 1]
|
|
327
|
+
unit_scale = abs(pixels[4, 0] - pixels[3, 0])
|
|
328
|
+
else:
|
|
329
|
+
t0, t1 = sorted((pixels[0, 1], pixels[1, 1]))
|
|
330
|
+
baseline, end = pixels[0, 0], pixels[2, 0]
|
|
331
|
+
unit_scale = abs(pixels[4, 1] - pixels[3, 1])
|
|
332
|
+
|
|
333
|
+
thickness = t1 - t0
|
|
334
|
+
length = abs(end - baseline)
|
|
335
|
+
if length <= 1e-9 or thickness <= 1e-9:
|
|
336
|
+
path = _rectangle_path(t0, t1, baseline, end)
|
|
337
|
+
return (
|
|
338
|
+
Path(path.vertices[:, ::-1], path.codes)
|
|
339
|
+
if self.orientation == "horizontal"
|
|
340
|
+
else path
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
length_limit = length if self.ends == "free" else length / 2
|
|
344
|
+
full_radius = thickness / 2
|
|
345
|
+
if self.radius in {"full", "fit"}:
|
|
346
|
+
if self.radius == "full" and self.strict_full and full_radius > length_limit + 1e-6:
|
|
347
|
+
requirement = (
|
|
348
|
+
"bar length >= bar thickness"
|
|
349
|
+
if self.ends == "both"
|
|
350
|
+
else "bar length >= half the bar thickness"
|
|
351
|
+
)
|
|
352
|
+
raise ValueError(
|
|
353
|
+
f"An exact full capsule requires {requirement} in display units; "
|
|
354
|
+
"use radius='fit' to clamp short bars instead."
|
|
355
|
+
)
|
|
356
|
+
radius = full_radius if self.radius == "full" else min(full_radius, length_limit)
|
|
357
|
+
else:
|
|
358
|
+
radius = min(float(self.radius) * unit_scale, full_radius, length_limit)
|
|
359
|
+
|
|
360
|
+
path = _vertical_rounded_path(t0, t1, baseline, end, radius, self.ends)
|
|
361
|
+
if self.orientation == "horizontal":
|
|
362
|
+
path = Path(path.vertices[:, ::-1], path.codes)
|
|
363
|
+
return path
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _validate_bar_options(radius, ends):
|
|
367
|
+
if isinstance(radius, str):
|
|
368
|
+
if radius not in {"full", "fit"}:
|
|
369
|
+
raise ValueError("radius must be 'full', 'fit', or a nonnegative number")
|
|
370
|
+
else:
|
|
371
|
+
value = float(radius)
|
|
372
|
+
if not np.isfinite(value) or value < 0:
|
|
373
|
+
raise ValueError("radius must be 'full', 'fit', or a nonnegative number")
|
|
374
|
+
if ends not in {"free", "both"}:
|
|
375
|
+
raise ValueError("ends must be 'free' or 'both'")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _rounded_bars(
|
|
379
|
+
ax,
|
|
380
|
+
positions,
|
|
381
|
+
lengths,
|
|
382
|
+
*,
|
|
383
|
+
thickness,
|
|
384
|
+
baseline,
|
|
385
|
+
radius,
|
|
386
|
+
ends,
|
|
387
|
+
orientation,
|
|
388
|
+
color,
|
|
389
|
+
label,
|
|
390
|
+
**kwargs,
|
|
391
|
+
):
|
|
392
|
+
_validate_bar_options(radius, ends)
|
|
393
|
+
positions, lengths, baseline, thickness = np.broadcast_arrays(
|
|
394
|
+
np.asarray(positions, dtype=float),
|
|
395
|
+
np.asarray(lengths, dtype=float),
|
|
396
|
+
np.asarray(baseline, dtype=float),
|
|
397
|
+
np.asarray(thickness, dtype=float),
|
|
398
|
+
)
|
|
399
|
+
if np.any(thickness < 0):
|
|
400
|
+
raise ValueError("bar thickness must be nonnegative")
|
|
401
|
+
|
|
402
|
+
if color is not None and "facecolor" in kwargs:
|
|
403
|
+
raise TypeError("pass either color or facecolor, not both")
|
|
404
|
+
facecolor = color if color is not None else kwargs.pop("facecolor", None)
|
|
405
|
+
if facecolor is None:
|
|
406
|
+
facecolor = ax._get_patches_for_fill.get_next_color()
|
|
407
|
+
|
|
408
|
+
if positions.size == 0:
|
|
409
|
+
container = BarContainer([], datavalues=np.array([]), orientation=orientation, label=label)
|
|
410
|
+
ax.add_container(container)
|
|
411
|
+
return container
|
|
412
|
+
|
|
413
|
+
patches = []
|
|
414
|
+
for position, length, base, wide in zip(
|
|
415
|
+
positions.ravel(), lengths.ravel(), baseline.ravel(), thickness.ravel(), strict=True
|
|
416
|
+
):
|
|
417
|
+
patch = RoundedBarPatch(
|
|
418
|
+
position,
|
|
419
|
+
length,
|
|
420
|
+
wide,
|
|
421
|
+
base,
|
|
422
|
+
radius=radius,
|
|
423
|
+
ends=ends,
|
|
424
|
+
orientation=orientation,
|
|
425
|
+
facecolor=facecolor,
|
|
426
|
+
**kwargs,
|
|
427
|
+
)
|
|
428
|
+
ax.add_patch(patch)
|
|
429
|
+
patches.append(patch)
|
|
430
|
+
|
|
431
|
+
container = BarContainer(
|
|
432
|
+
patches,
|
|
433
|
+
datavalues=lengths.ravel(),
|
|
434
|
+
orientation=orientation,
|
|
435
|
+
label=label,
|
|
436
|
+
)
|
|
437
|
+
ax.add_container(container)
|
|
438
|
+
vertices = np.vstack([patch.data_path.vertices for patch in patches])
|
|
439
|
+
ax.update_datalim(vertices)
|
|
440
|
+
ax.autoscale_view()
|
|
441
|
+
for patch in patches:
|
|
442
|
+
patch.strict_full = True
|
|
443
|
+
return container
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def rounded_bar(
|
|
447
|
+
ax,
|
|
448
|
+
x,
|
|
449
|
+
height,
|
|
450
|
+
width=0.8,
|
|
451
|
+
*,
|
|
452
|
+
bottom=0.0,
|
|
453
|
+
radius="full",
|
|
454
|
+
ends="free",
|
|
455
|
+
color=None,
|
|
456
|
+
label=None,
|
|
457
|
+
**kwargs,
|
|
458
|
+
):
|
|
459
|
+
"""Draw vertical bars with circular caps.
|
|
460
|
+
|
|
461
|
+
``radius='full'`` creates an exact semicircular free cap and requires enough
|
|
462
|
+
display length. ``radius='fit'`` clamps short bars. Numeric radii produce
|
|
463
|
+
circular rounded corners. ``ends='both'`` also rounds the baseline.
|
|
464
|
+
"""
|
|
465
|
+
return _rounded_bars(
|
|
466
|
+
ax,
|
|
467
|
+
x,
|
|
468
|
+
height,
|
|
469
|
+
thickness=width,
|
|
470
|
+
baseline=bottom,
|
|
471
|
+
radius=radius,
|
|
472
|
+
ends=ends,
|
|
473
|
+
orientation="vertical",
|
|
474
|
+
color=color,
|
|
475
|
+
label=label,
|
|
476
|
+
**kwargs,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def rounded_barh(
|
|
481
|
+
ax,
|
|
482
|
+
y,
|
|
483
|
+
width,
|
|
484
|
+
height=0.8,
|
|
485
|
+
*,
|
|
486
|
+
left=0.0,
|
|
487
|
+
radius="full",
|
|
488
|
+
ends="free",
|
|
489
|
+
color=None,
|
|
490
|
+
label=None,
|
|
491
|
+
**kwargs,
|
|
492
|
+
):
|
|
493
|
+
"""Draw horizontal bars with the same options as ``rounded_bar``."""
|
|
494
|
+
return _rounded_bars(
|
|
495
|
+
ax,
|
|
496
|
+
y,
|
|
497
|
+
width,
|
|
498
|
+
thickness=height,
|
|
499
|
+
baseline=left,
|
|
500
|
+
radius=radius,
|
|
501
|
+
ends=ends,
|
|
502
|
+
orientation="horizontal",
|
|
503
|
+
color=color,
|
|
504
|
+
label=label,
|
|
505
|
+
**kwargs,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
__all__ = [
|
|
510
|
+
"CapsuleBox",
|
|
511
|
+
"RoundedBarPatch",
|
|
512
|
+
"SquircleBox",
|
|
513
|
+
"add_pill",
|
|
514
|
+
"add_squircle",
|
|
515
|
+
"capsule_path",
|
|
516
|
+
"rounded_bar",
|
|
517
|
+
"rounded_barh",
|
|
518
|
+
"squircle_path",
|
|
519
|
+
]
|
plotform/theme.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Global and scoped application of the Plotform theme."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
|
|
8
|
+
import matplotlib as mpl
|
|
9
|
+
from cycler import cycler
|
|
10
|
+
from matplotlib import style
|
|
11
|
+
|
|
12
|
+
from .fonts import MONO_FONT, SANS_FONT, register_fonts
|
|
13
|
+
from .palette import (
|
|
14
|
+
AXIS,
|
|
15
|
+
BASE_COLORS,
|
|
16
|
+
DIVERGING_CMAP,
|
|
17
|
+
GRID,
|
|
18
|
+
INK,
|
|
19
|
+
MUTED,
|
|
20
|
+
MUTED_AXIS,
|
|
21
|
+
PRIMARY,
|
|
22
|
+
SCALAR_CMAP,
|
|
23
|
+
WHITE,
|
|
24
|
+
get_palette,
|
|
25
|
+
register_colormaps,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _rc_params(n_colors: int, gray_decorations: bool) -> dict:
|
|
30
|
+
register_fonts()
|
|
31
|
+
register_colormaps()
|
|
32
|
+
axis_color = MUTED_AXIS if gray_decorations else AXIS
|
|
33
|
+
tick_label_color = MUTED if gray_decorations else INK
|
|
34
|
+
return {
|
|
35
|
+
# Typography. Font weight is deliberately left at Matplotlib's default.
|
|
36
|
+
"font.family": "sans-serif",
|
|
37
|
+
"font.sans-serif": [SANS_FONT, "DejaVu Sans", "Arial", "sans-serif"],
|
|
38
|
+
"font.monospace": [MONO_FONT, "DejaVu Sans Mono", "monospace"],
|
|
39
|
+
"font.size": 10.5,
|
|
40
|
+
"mathtext.fontset": "dejavusans",
|
|
41
|
+
"text.color": INK,
|
|
42
|
+
# Figure and output.
|
|
43
|
+
"figure.facecolor": WHITE,
|
|
44
|
+
"figure.edgecolor": WHITE,
|
|
45
|
+
"figure.figsize": (8.0, 5.0),
|
|
46
|
+
"figure.dpi": 120,
|
|
47
|
+
"figure.constrained_layout.use": True,
|
|
48
|
+
"figure.titlesize": 17.0,
|
|
49
|
+
"savefig.facecolor": WHITE,
|
|
50
|
+
"savefig.edgecolor": WHITE,
|
|
51
|
+
"savefig.dpi": 800,
|
|
52
|
+
"savefig.bbox": "tight",
|
|
53
|
+
"savefig.pad_inches": 0.15,
|
|
54
|
+
"savefig.transparent": False,
|
|
55
|
+
"pdf.fonttype": 42,
|
|
56
|
+
"ps.fonttype": 42,
|
|
57
|
+
"svg.fonttype": "path",
|
|
58
|
+
# Axes and minimalist decoration.
|
|
59
|
+
"axes.facecolor": WHITE,
|
|
60
|
+
"axes.edgecolor": axis_color,
|
|
61
|
+
"axes.linewidth": 0.8,
|
|
62
|
+
"axes.labelcolor": INK,
|
|
63
|
+
"axes.labelsize": 10.5,
|
|
64
|
+
"axes.labelpad": 8.0,
|
|
65
|
+
"axes.titlesize": 15.0,
|
|
66
|
+
"axes.titlecolor": INK,
|
|
67
|
+
"axes.titlelocation": "left",
|
|
68
|
+
"axes.titlepad": 14.0,
|
|
69
|
+
"axes.spines.top": False,
|
|
70
|
+
"axes.spines.right": False,
|
|
71
|
+
"axes.spines.left": True,
|
|
72
|
+
"axes.spines.bottom": True,
|
|
73
|
+
"axes.prop_cycle": cycler("color", get_palette(n_colors)),
|
|
74
|
+
"axes.grid": True,
|
|
75
|
+
"axes.grid.axis": "y",
|
|
76
|
+
"axes.axisbelow": True,
|
|
77
|
+
"axes.unicode_minus": True,
|
|
78
|
+
"grid.color": GRID,
|
|
79
|
+
"grid.linewidth": 0.8,
|
|
80
|
+
"grid.alpha": 1.0,
|
|
81
|
+
"xtick.color": axis_color,
|
|
82
|
+
"xtick.labelcolor": tick_label_color,
|
|
83
|
+
"xtick.labelsize": 9.5,
|
|
84
|
+
"xtick.direction": "out",
|
|
85
|
+
"xtick.major.size": 3.0,
|
|
86
|
+
"xtick.major.width": 0.8,
|
|
87
|
+
"xtick.major.pad": 6.0,
|
|
88
|
+
"xtick.top": False,
|
|
89
|
+
"ytick.color": axis_color,
|
|
90
|
+
"ytick.labelcolor": tick_label_color,
|
|
91
|
+
"ytick.labelsize": 9.5,
|
|
92
|
+
"ytick.direction": "out",
|
|
93
|
+
"ytick.major.size": 3.0,
|
|
94
|
+
"ytick.major.width": 0.8,
|
|
95
|
+
"ytick.major.pad": 6.0,
|
|
96
|
+
"ytick.right": False,
|
|
97
|
+
# Data ink.
|
|
98
|
+
"lines.linewidth": 2.0,
|
|
99
|
+
"lines.markersize": 6.5,
|
|
100
|
+
"lines.solid_capstyle": "round",
|
|
101
|
+
"lines.solid_joinstyle": "round",
|
|
102
|
+
"lines.dash_capstyle": "round",
|
|
103
|
+
"lines.dash_joinstyle": "round",
|
|
104
|
+
"lines.markeredgewidth": 0.0,
|
|
105
|
+
"scatter.marker": "o",
|
|
106
|
+
"scatter.edgecolors": "none",
|
|
107
|
+
"patch.linewidth": 0.8,
|
|
108
|
+
"patch.edgecolor": WHITE,
|
|
109
|
+
"patch.antialiased": True,
|
|
110
|
+
"errorbar.capsize": 3.0,
|
|
111
|
+
# Legends and common plot families.
|
|
112
|
+
"legend.frameon": False,
|
|
113
|
+
"legend.fontsize": 9.5,
|
|
114
|
+
"legend.labelcolor": tick_label_color,
|
|
115
|
+
"legend.labelspacing": 0.7,
|
|
116
|
+
"legend.handlelength": 2.0,
|
|
117
|
+
"legend.handletextpad": 0.7,
|
|
118
|
+
"legend.borderaxespad": 0.5,
|
|
119
|
+
"boxplot.boxprops.color": PRIMARY,
|
|
120
|
+
"boxplot.boxprops.linewidth": 1.4,
|
|
121
|
+
"boxplot.whiskerprops.color": MUTED,
|
|
122
|
+
"boxplot.whiskerprops.linewidth": 1.2,
|
|
123
|
+
"boxplot.capprops.color": MUTED,
|
|
124
|
+
"boxplot.capprops.linewidth": 1.2,
|
|
125
|
+
"boxplot.medianprops.color": INK,
|
|
126
|
+
"boxplot.medianprops.linewidth": 1.5,
|
|
127
|
+
"boxplot.flierprops.marker": "o",
|
|
128
|
+
"boxplot.flierprops.markersize": 4.0,
|
|
129
|
+
"boxplot.flierprops.markerfacecolor": PRIMARY,
|
|
130
|
+
"boxplot.flierprops.markeredgecolor": "none",
|
|
131
|
+
"image.cmap": SCALAR_CMAP,
|
|
132
|
+
"contour.negative_linestyle": "dashed",
|
|
133
|
+
# Exposed for users who prefer the diverging map without importing it.
|
|
134
|
+
"axes.formatter.use_mathtext": False,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def apply_theme(n_colors: int = len(BASE_COLORS), *, gray_decorations: bool = False) -> None:
|
|
139
|
+
"""Apply the theme globally, resetting Matplotlib to defaults first.
|
|
140
|
+
|
|
141
|
+
Parameters
|
|
142
|
+
----------
|
|
143
|
+
n_colors : int, default: 8
|
|
144
|
+
Number of colors in the default categorical cycle. Values above eight
|
|
145
|
+
use the deterministic palette extension.
|
|
146
|
+
gray_decorations : bool, default: False
|
|
147
|
+
Use the previous muted-gray axes and tick labels. Gridlines stay light
|
|
148
|
+
in both modes; the default uses black axes and tick labels.
|
|
149
|
+
"""
|
|
150
|
+
params = _rc_params(n_colors, gray_decorations)
|
|
151
|
+
style.use("default")
|
|
152
|
+
mpl.rcParams.update(params)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@contextmanager
|
|
156
|
+
def theme(n_colors: int = len(BASE_COLORS), *, gray_decorations: bool = False) -> Iterator[None]:
|
|
157
|
+
"""Temporarily apply the theme and restore previous settings on exit.
|
|
158
|
+
|
|
159
|
+
This is useful for applications that create figures with multiple themes.
|
|
160
|
+
Font registration and colormap registration remain process-global.
|
|
161
|
+
"""
|
|
162
|
+
params = _rc_params(n_colors, gray_decorations)
|
|
163
|
+
with style.context("default"):
|
|
164
|
+
mpl.rcParams.update(params)
|
|
165
|
+
yield
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
__all__ = ["apply_theme", "theme", "PRIMARY", "DIVERGING_CMAP"]
|