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/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"]