qc-core 0.0.1__tar.gz

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.
qc_core-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.3
2
+ Name: qc-core
3
+ Version: 0.0.1
4
+ Summary: Core operations that are used across the QuantClimate stack.
5
+ Author: Thomas Pinder
6
+ Author-email: Thomas Pinder <thomas@quantclimate.com>
7
+ Requires-Dist: matplotlib>=3.11.1
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+
11
+ # qc-core
12
+
13
+ Shared Python utilities for QuantClimate. The package includes the Calibrated Ledger Matplotlib style and plotting helpers.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pip install qc-core
19
+ ```
20
+
21
+ qc-core requires Python 3.11 or later.
22
+
23
+ ## Matplotlib style
24
+
25
+ Load the packaged style globally:
26
+
27
+ ```python
28
+ import qc_core
29
+
30
+ qc_core.use_ledger_style()
31
+ ```
32
+
33
+ Use the plotting module for the complete plotting API:
34
+
35
+ ```python
36
+ from qc_core import plotting
37
+
38
+ plotting.use_ledger_style()
39
+ ```
40
+
41
+ `qc_core.style_path()` returns the installed style file's path.
@@ -0,0 +1,31 @@
1
+ # qc-core
2
+
3
+ Shared Python utilities for QuantClimate. The package includes the Calibrated Ledger Matplotlib style and plotting helpers.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install qc-core
9
+ ```
10
+
11
+ qc-core requires Python 3.11 or later.
12
+
13
+ ## Matplotlib style
14
+
15
+ Load the packaged style globally:
16
+
17
+ ```python
18
+ import qc_core
19
+
20
+ qc_core.use_ledger_style()
21
+ ```
22
+
23
+ Use the plotting module for the complete plotting API:
24
+
25
+ ```python
26
+ from qc_core import plotting
27
+
28
+ plotting.use_ledger_style()
29
+ ```
30
+
31
+ `qc_core.style_path()` returns the installed style file's path.
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "qc-core"
3
+ version = "0.0.1"
4
+ description = "Core operations that are used across the QuantClimate stack."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = ["matplotlib>=3.11.1"]
8
+
9
+ [[project.authors]]
10
+ name = "Thomas Pinder"
11
+ email = "thomas@quantclimate.com"
12
+
13
+ [project.scripts]
14
+ qc-core = "qc_core:main"
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.11.32,<0.12.0"]
18
+ build-backend = "uv_build"
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "qc-core"
3
+ version = "0.0.1"
4
+ description = "Core operations that are used across the QuantClimate stack."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Thomas Pinder", email = "thomas@quantclimate.com" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "matplotlib>=3.11.1",
12
+ ]
13
+
14
+ [project.scripts]
15
+ qc-core = "qc_core:main"
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.11.32,<0.12.0"]
19
+ build-backend = "uv_build"
@@ -0,0 +1,9 @@
1
+ """Core operations used across the QuantClimate stack."""
2
+
3
+ from qc_core import plotting
4
+ from qc_core.plotting import style_path, use_ledger_style
5
+
6
+ __all__ = ["main", "plotting", "style_path", "use_ledger_style"]
7
+
8
+ def main() -> None:
9
+ print("Hello from qc-core!")
@@ -0,0 +1,135 @@
1
+ ## QuantClimate "Calibrated Ledger" matplotlib style -- light mode.
2
+ ## Aligned with landing-page/DESIGN.md: warm off-white paper, near-black umber
3
+ ## ink, hairline rules, and a single oxblood accent leading a short, muted
4
+ ## categorical cycle. Serif states the claim (titles, via
5
+ ## case_studies.plotting.serif_title); sans carries the evidence (labels,
6
+ ## ticks, legends).
7
+ ##
8
+ ## The color cycle passes the six categorical-palette checks (OKLab validator:
9
+ ## lightness band, chroma floor, CVD dE >= 8 adjacent, normal-vision dE >= 15,
10
+ ## contrast >= 3:1 on #faf9f7). Slot 1 is oxblood lifted one lightness step
11
+ ## (#8a332e vs brand #7a2e2a) to sit inside the series lightness band; the
12
+ ## brand oxblood itself remains the accent for single-series emphasis.
13
+ ##
14
+ ## ---- Fonts --------------------------------------------------------------
15
+ ## matplotlib can only use LOCALLY installed fonts (no web fonts). Install
16
+ ## Spectral and Public Sans (fonts.google.com) for full fidelity; otherwise
17
+ ## this falls back to Georgia / Helvetica / DejaVu.
18
+ font.family: sans-serif
19
+ font.sans-serif: Public Sans, Helvetica Neue, Arial, DejaVu Sans, sans-serif
20
+ font.serif: Spectral, Georgia, Palatino, DejaVu Serif, serif
21
+ font.monospace: Menlo, Consolas, DejaVu Sans Mono, monospace
22
+ font.size: 9
23
+
24
+
25
+ ## ---- Figure -------------------------------------------------------------
26
+ figure.facecolor: faf9f7
27
+ figure.edgecolor: faf9f7
28
+ figure.dpi: 250
29
+ figure.figsize: 5.5, 3
30
+ figure.constrained_layout.use: True
31
+ figure.autolayout: False
32
+
33
+
34
+ ## ---- Saved figures ------------------------------------------------------
35
+ ## Transparent by default so figures sit directly on the site's paper ground.
36
+ savefig.facecolor: faf9f7
37
+ savefig.edgecolor: faf9f7
38
+ savefig.dpi: 250
39
+ savefig.bbox: tight
40
+ savefig.pad_inches: 0.08
41
+ savefig.transparent: True
42
+
43
+
44
+ ## ---- Axes ---------------------------------------------------------------
45
+ ## Hairline frame, bottom + left only: rules stay recessive so the data wears
46
+ ## the ink. Titles are ink, left-aligned ("the claim").
47
+ axes.facecolor: faf9f7
48
+ axes.edgecolor: e6dfd6
49
+ axes.linewidth: 1.0
50
+ axes.spines.top: False
51
+ axes.spines.right: False
52
+ axes.spines.left: True
53
+ axes.spines.bottom: True
54
+ axes.labelcolor: 423b36
55
+ axes.labelsize: 9
56
+ axes.labelpad: 3.0
57
+ axes.titlecolor: 2a2320
58
+ axes.titlesize: 11.5
59
+ axes.titleweight: semibold
60
+ axes.titlelocation: left
61
+ axes.titlepad: 10.0
62
+ axes.axisbelow: True
63
+ ## NB: hex without '#' -- an in-value '#' starts an rc-file comment and
64
+ ## silently truncates this line, dropping the whole cycle.
65
+ axes.prop_cycle: cycler('color', ['8a332e', '31649e', '9a7020', '8f4a85', '5a7a2e'])
66
+
67
+
68
+ ## ---- Text ---------------------------------------------------------------
69
+ ## NOTE: ink (2a2320), body (423b36), muted (6b6058), hairline (e6dfd6) and
70
+ ## paper (faf9f7) mirror case_studies.plotting.COLORS -- keep them in sync.
71
+ text.color: 2a2320
72
+
73
+
74
+ ## ---- Ticks --------------------------------------------------------------
75
+ ## Small outward calibration marks in muted; labels muted too (metadata).
76
+ xtick.color: 6b6058
77
+ ytick.color: 6b6058
78
+ xtick.labelcolor: 6b6058
79
+ ytick.labelcolor: 6b6058
80
+ xtick.labelsize: 8.5
81
+ ytick.labelsize: 8.5
82
+ xtick.direction: out
83
+ ytick.direction: out
84
+ xtick.major.size: 3.5
85
+ ytick.major.size: 3.5
86
+ xtick.major.width: 0.8
87
+ ytick.major.width: 0.8
88
+ xtick.minor.size: 2.0
89
+ ytick.minor.size: 2.0
90
+ xtick.minor.width: 0.6
91
+ ytick.minor.width: 0.6
92
+
93
+
94
+ ## ---- Grid ---------------------------------------------------------------
95
+ ## Horizontal hairline rules only -- the ledger's lines.
96
+ axes.grid: True
97
+ axes.grid.axis: y
98
+ grid.color: e6dfd6
99
+ grid.linewidth: 0.6
100
+ grid.linestyle: -
101
+ grid.alpha: 0.8
102
+
103
+
104
+ ## ---- Lines & markers ----------------------------------------------------
105
+ lines.linewidth: 1.8
106
+ lines.markersize: 5.5
107
+ lines.solid_capstyle: round
108
+ lines.dash_capstyle: round
109
+
110
+
111
+ ## ---- Patches (bars, histograms, ...) ------------------------------------
112
+ ## Paper-colored edges give adjacent bars / stacked segments a surface gap.
113
+ patch.facecolor: 8a332e
114
+ patch.edgecolor: faf9f7
115
+ patch.linewidth: 0.8
116
+ patch.force_edgecolor: True
117
+ patch.antialiased: True
118
+
119
+
120
+ ## ---- Scatter ------------------------------------------------------------
121
+ scatter.edgecolors: faf9f7
122
+
123
+
124
+ ## ---- Legend -------------------------------------------------------------
125
+ ## Frameless: identity comes from the mark swatch, text stays in body ink.
126
+ legend.frameon: False
127
+ legend.fontsize: 8.5
128
+ legend.title_fontsize: 9
129
+ legend.labelcolor: 423b36
130
+ legend.loc: best
131
+ legend.numpoints: 1
132
+
133
+ ## Keep text editable in vector editors.
134
+ pdf.fonttype: 42
135
+ ps.fonttype: 42
@@ -0,0 +1,583 @@
1
+ """"Calibrated Ledger" matplotlib styling for QuantClimate case studies.
2
+
3
+ Aligned with the landing-page design system (``landing-page/DESIGN.md``):
4
+ warm off-white paper, near-black umber ink, hairline rules, and a single
5
+ oxblood accent leading a short, muted categorical cycle. Spectral (serif)
6
+ states the claim in titles via :func:`serif_title`; Public Sans (sans)
7
+ carries the evidence in labels, ticks, and legends.
8
+
9
+ This module supersedes ``case-studies/src/case_studies/style.py`` (the Tufte
10
+ theme). The API mirrors it -- ``use_ledger_style`` replaces
11
+ ``use_tufte_style``; ``COLORS``, ``add_halo``,
12
+ ``legend_right``/``legend_below`` keep their names -- so migration is
13
+ mechanical. Two rc files ship alongside this module: ``ledger.mplstyle``
14
+ (light, the default) and ``ledger_dark.mplstyle``.
15
+
16
+ Both color cycles pass the six categorical-palette checks (OKLab: lightness
17
+ band, chroma floor, CVD separation dE >= 8 adjacent, normal-vision floor
18
+ dE >= 15, contrast >= 3:1 on their surface). The first three slots also pass
19
+ the stricter all-pairs test, so scatter plots should carry at most three
20
+ series. Beyond five series, fold extras into an "Other" drawn in ``muted``
21
+ or facet -- never invent a sixth hue.
22
+
23
+ Note on fonts: matplotlib can only use locally *installed* fonts (no web
24
+ fonts). Install Spectral and Public Sans (fonts.google.com) for full
25
+ fidelity; otherwise the style falls back to Georgia / Helvetica.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from contextlib import contextmanager
31
+ from dataclasses import asdict, dataclass, fields
32
+ from pathlib import Path
33
+ from typing import TYPE_CHECKING, Iterator, Sequence
34
+
35
+ if TYPE_CHECKING:
36
+ from matplotlib.artist import Artist
37
+ from matplotlib.axes import Axes
38
+ from matplotlib.legend import Legend
39
+ from matplotlib.typing import ColorType
40
+
41
+ # The rc files ship next to this module as package resources.
42
+ LIGHT_STYLE_PATH: Path = Path(__file__).with_name("ledger.mplstyle")
43
+ DARK_STYLE_PATH: Path = Path(__file__).with_name("ledger_dark.mplstyle")
44
+
45
+ #: Spectral-first serif stack for claims (titles); see :func:`serif_title`.
46
+ SERIF_STACK: list[str] = ["Spectral", "Georgia", "Palatino", "DejaVu Serif", "serif"]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Palette:
51
+ """Named ledger colors, mirroring ``landing-page/src/config/theme.json``.
52
+
53
+ ``ink``..``paper`` and the ``oxblood*`` accents match the design tokens;
54
+ ``cycle`` is the validated categorical cycle whose values also live in the
55
+ rc files (keep them in sync). Reference colors by attribute
56
+ (autocompletes) or by name::
57
+
58
+ COLORS.oxblood # "#7a2e2a"
59
+ COLORS["ink"] # "#2a2320"
60
+
61
+ After :func:`use_ledger_style` (or a manual :func:`register_colors`) the
62
+ same names also work directly in any matplotlib call, e.g.
63
+ ``color="oxblood"``.
64
+ """
65
+
66
+ ink: str = "#2a2320" # near-black warm umber; titles, emphasis
67
+ body: str = "#423b36" # running text; axis labels
68
+ muted: str = "#6b6058" # metadata; ticks, captions, "Other" series
69
+ hairline: str = "#e6dfd6" # 1px rules; spines, grid
70
+ surface: str = "#f3efe9" # insets, one warm step up from paper
71
+ paper: str = "#faf9f7" # the page ground -- white, not cream
72
+ oxblood: str = "#7a2e2a" # THE accent; the number that matters
73
+ oxblood_deep: str = "#5f231f" # hover/pressed shade
74
+ oxblood_wash: str = "#f4e7e4" # pale tint; distribution fills
75
+ #: Validated categorical cycle. Slot 1 is oxblood lifted one lightness
76
+ #: step so the series band holds; the brand oxblood stays the accent for
77
+ #: single-series emphasis.
78
+ cycle: tuple[str, ...] = ("#8a332e", "#31649e", "#9a7020", "#8f4a85", "#5a7a2e")
79
+
80
+ def __getitem__(self, name: str) -> str:
81
+ """Look a color up by name, e.g. ``COLORS["oxblood"]``."""
82
+ try:
83
+ value = getattr(self, name)
84
+ except AttributeError:
85
+ raise KeyError(name) from None
86
+ if not isinstance(value, str):
87
+ raise KeyError(name)
88
+ return value
89
+
90
+ def __iter__(self) -> Iterator[str]:
91
+ """Iterate the *color* names (the keys), like a mapping."""
92
+ return (name for name, _ in self._named_colors())
93
+
94
+ def _named_colors(self) -> list[tuple[str, str]]:
95
+ """The ``(name, hex)`` pairs, excluding the cycle tuple."""
96
+ return [(k, v) for k, v in asdict(self).items() if isinstance(v, str)]
97
+
98
+ def as_dict(self) -> dict[str, str]:
99
+ """Return the named colors as an ordered ``{name: hex}`` mapping."""
100
+ return dict(self._named_colors())
101
+
102
+
103
+ #: Light mode -- the default, matching the site's light theme.
104
+ COLORS = Palette()
105
+
106
+ #: Dark mode -- the same roles re-lit on warm near-black. ``oxblood_deep`` is
107
+ #: derived (the design system defines no dark pressed shade); ``oxblood_wash``
108
+ #: matches the site's dark EstimateFigure wash.
109
+ COLORS_DARK = Palette(
110
+ ink="#f2ece6",
111
+ body="#d8cfc7",
112
+ muted="#9a8d84",
113
+ hairline="#3a322e",
114
+ surface="#2a2320",
115
+ paper="#201b19",
116
+ oxblood="#cf6f60",
117
+ oxblood_deep="#a35043",
118
+ oxblood_wash="#2f211e",
119
+ cycle=("#cf6f60", "#5b8fd6", "#b3883b", "#b877ab", "#78933f"),
120
+ )
121
+
122
+
123
+ def register_colors(palette: Palette = COLORS) -> None:
124
+ """Register the palette names in matplotlib's global color table.
125
+
126
+ Makes every name usable as a literal color anywhere matplotlib accepts
127
+ one, e.g. ``ax.plot(x, y, color="oxblood")`` or
128
+ ``ax.axhline(0, color="hairline")`` -- exactly like a built-in name.
129
+
130
+ Idempotent. Note: ``ink`` and ``paper`` are also registered (with
131
+ different hexes) by the superseded ``case_studies.style``; whichever
132
+ module registers last wins, so avoid mixing the two themes in one
133
+ session. Called automatically by :func:`use_ledger_style`.
134
+ """
135
+ import matplotlib.colors as mcolors
136
+
137
+ mcolors.get_named_colors_mapping().update(palette.as_dict())
138
+
139
+
140
+ def style_path(dark: bool = False) -> Path:
141
+ """Return the absolute path to the packaged ledger style file."""
142
+ return (DARK_STYLE_PATH if dark else LIGHT_STYLE_PATH).resolve()
143
+
144
+
145
+ def use_ledger_style(dark: bool = False) -> None:
146
+ """Apply the Calibrated Ledger style globally by loading the rc file.
147
+
148
+ Equivalent to ``matplotlib.rc_file(style_path(dark))`` but independent of
149
+ the current working directory. Also registers the named palette colors
150
+ (see :func:`register_colors`) so ``color="oxblood"`` works immediately.
151
+
152
+ Args:
153
+ dark: Load the dark-mode variant (near-black paper, lifted accent).
154
+ """
155
+ import matplotlib as mpl
156
+
157
+ mpl.rc_file(style_path(dark))
158
+ register_colors(COLORS_DARK if dark else COLORS)
159
+
160
+
161
+ @contextmanager
162
+ def _ledger_context(dark: bool = False) -> Iterator[None]:
163
+ """Apply the ledger style only within the ``with`` block.
164
+
165
+ Note: rcParams revert on exit, but registered color names (harmless and
166
+ idempotent) persist in matplotlib's global table.
167
+ """
168
+ import matplotlib as mpl
169
+
170
+ register_colors(COLORS_DARK if dark else COLORS)
171
+ with mpl.rc_context(fname=style_path(dark)):
172
+ yield
173
+
174
+
175
+ # Expose the context manager as ``use_ledger_style.context()`` for a single,
176
+ # discoverable entry point while keeping ``use_ledger_style()`` callable.
177
+ use_ledger_style.context = _ledger_context # type: ignore[attr-defined]
178
+
179
+
180
+ def palette(n: int | None = None) -> list[str]:
181
+ """Return the active color cycle as a list, so colors can be indexed.
182
+
183
+ Reads the currently-loaded cycle from ``rcParams['axes.prop_cycle']``
184
+ (i.e. the cycle set by :func:`use_ledger_style` or whatever style is
185
+ active), so you can grab a specific theme color::
186
+
187
+ cols = palette()
188
+ ax.plot(x, y, color=cols[1])
189
+
190
+ Tip: matplotlib's built-in shorthand ``"C0"``, ``"C1"``, ... refers to
191
+ the 1st, 2nd, ... colors of the active cycle with no helper at all.
192
+
193
+ Args:
194
+ n: If given, return exactly *n* colors, repeating the cycle as
195
+ needed. When ``None`` (default), return the cycle once at its
196
+ natural length. Prefer folding a 6th+ series into ``muted``
197
+ instead of repeating.
198
+
199
+ Returns:
200
+ List of color specs (hex strings) from the active cycle.
201
+ """
202
+ import matplotlib as mpl
203
+
204
+ colors = list(mpl.rcParams["axes.prop_cycle"].by_key().get("color", []))
205
+ if n is None or not colors:
206
+ return colors
207
+ return [colors[i % len(colors)] for i in range(n)]
208
+
209
+
210
+ def serif_title(
211
+ label: str,
212
+ ax: Axes | None = None,
213
+ **kwargs,
214
+ ) -> Artist:
215
+ """Set a left-aligned Spectral (serif) axes title -- the claim.
216
+
217
+ The design rule is "serif states, sans measures": Spectral makes the
218
+ claim, Public Sans carries the evidence. The rc files set sans globally
219
+ (labels, ticks, legends); this helper opts the title into the serif
220
+ stack, since matplotlib has no per-element font-family rcParam::
221
+
222
+ serif_title("Low water halves Rhine tonnage", ax)
223
+
224
+ Args:
225
+ label: Title text.
226
+ ax: Target axes. Defaults to the current axes (``plt.gca()``).
227
+ **kwargs: Forwarded to ``Axes.set_title`` (e.g. ``fontsize``, ``pad``).
228
+
229
+ Returns:
230
+ The title ``Text`` artist.
231
+ """
232
+ ax = _resolve_ax(ax)
233
+ kwargs.setdefault("fontfamily", SERIF_STACK)
234
+ kwargs.setdefault("fontweight", 600)
235
+ return ax.set_title(label, **kwargs)
236
+
237
+
238
+ def estimate_plot(
239
+ x: Sequence[float],
240
+ density: Sequence[float],
241
+ *,
242
+ interval: tuple[float, float],
243
+ median: float,
244
+ ax: Axes | None = None,
245
+ colors: Palette = COLORS,
246
+ ) -> Axes:
247
+ """Draw the brand's signature Estimate Figure: a posterior with its interval.
248
+
249
+ Mirrors the landing page's ``EstimateFigure`` component: the full
250
+ posterior in a pale oxblood wash, the credible interval in translucent
251
+ oxblood, the outline stroked in oxblood, the median as an ink rule, and
252
+ a hairline baseline. The density axis is meaningless to the reader, so
253
+ the left spine and y-ticks are removed::
254
+
255
+ estimate_plot(grid, pdf, interval=(6, 34), median=18)
256
+
257
+ Args:
258
+ x: Support of the distribution (e.g. a grid of % changes).
259
+ density: Density values over *x* (any positive scale).
260
+ interval: ``(lo, hi)`` credible-interval bounds, in *x* units.
261
+ median: Point estimate, in *x* units.
262
+ ax: Target axes. Defaults to the current axes (``plt.gca()``).
263
+ colors: Palette to draw with (pass ``COLORS_DARK`` for dark mode).
264
+
265
+ Returns:
266
+ The axes drawn on.
267
+ """
268
+ import numpy as np
269
+
270
+ ax = _resolve_ax(ax)
271
+ xs = np.asarray(x, dtype=float)
272
+ ys = np.asarray(density, dtype=float)
273
+ lo, hi = interval
274
+
275
+ ax.fill_between(xs, ys, 0.0, color=colors.oxblood_wash, lw=0, zorder=1)
276
+ band = (xs >= lo) & (xs <= hi)
277
+ ax.fill_between(
278
+ xs[band], ys[band], 0.0, color=colors.oxblood, alpha=0.3, lw=0, zorder=2
279
+ )
280
+ ax.plot(xs, ys, color=colors.oxblood, lw=1.8, solid_capstyle="round", zorder=4)
281
+ ax.axhline(0.0, color=colors.hairline, lw=1.0, zorder=3)
282
+ ax.vlines(
283
+ median,
284
+ 0.0,
285
+ float(np.interp(median, xs, ys)),
286
+ color=colors.ink,
287
+ lw=1.4,
288
+ zorder=5,
289
+ )
290
+
291
+ ax.set_ylim(bottom=0.0)
292
+ ax.spines["left"].set_visible(False)
293
+ ax.set_yticks([])
294
+ ax.grid(False)
295
+ return ax
296
+
297
+
298
+ def _blend(color: ColorType, target: tuple[float, float, float], amount: float) -> str:
299
+ """Blend *color* a fraction *amount* (0..1) toward *target*; preserve alpha."""
300
+ import matplotlib.colors as mcolors
301
+
302
+ amount = min(1.0, max(0.0, float(amount)))
303
+ r, g, b, a = mcolors.to_rgba(color)
304
+ tr, tg, tb = target
305
+ mixed = (
306
+ r + (tr - r) * amount,
307
+ g + (tg - g) * amount,
308
+ b + (tb - b) * amount,
309
+ a,
310
+ )
311
+ return mcolors.to_hex(mixed, keep_alpha=a < 1.0)
312
+
313
+
314
+ def darken(color: ColorType, amount: float = 0.3) -> str:
315
+ """Return a darker shade of *color* as a hex string.
316
+
317
+ Blends *amount* of the way toward black (``0`` = unchanged, ``1`` =
318
+ black). Accepts any matplotlib color spec -- cycle refs (``"C0"``),
319
+ named colors (including the registered theme names like ``"oxblood"``),
320
+ hex, rgb(a) tuples, or grayscale strings -- so::
321
+
322
+ darken("C0") # 30% darker than the 1st cycle color
323
+ darken(COLORS.oxblood, 0.5)
324
+ ax.plot(x, y, color=darken("C1", 0.2))
325
+ """
326
+ return _blend(color, (0.0, 0.0, 0.0), amount)
327
+
328
+
329
+ def lighten(color: ColorType, amount: float = 0.3) -> str:
330
+ """Return a lighter shade of *color* as a hex string.
331
+
332
+ Blends *amount* of the way toward white (``0`` = unchanged, ``1`` =
333
+ white). Accepts the same color specs as :func:`darken`.
334
+ """
335
+ return _blend(color, (1.0, 1.0, 1.0), amount)
336
+
337
+
338
+ def change_contrast(color: ColorType, value: float) -> str:
339
+ """Scale a color's saturation by *value*, returning a hex string.
340
+
341
+ Adjusts saturation in HLS space (lightness preserved), so the color
342
+ (de)saturates without getting darker or lighter:
343
+
344
+ * ``value == 1`` -> unchanged
345
+ * ``0 <= value < 1`` -> desaturated, toward grey (``0`` = fully grey)
346
+ * ``value > 1`` -> more saturated / vivid (capped at full saturation)
347
+
348
+ Accepts the same color specs as :func:`darken`; alpha is preserved.
349
+ """
350
+ import colorsys
351
+
352
+ import matplotlib.colors as mcolors
353
+
354
+ r, g, b, a = mcolors.to_rgba(color)
355
+ h, lightness, s = colorsys.rgb_to_hls(r, g, b)
356
+ s = min(1.0, max(0.0, s * float(value)))
357
+ r2, g2, b2 = colorsys.hls_to_rgb(h, lightness, s)
358
+ return mcolors.to_hex((r2, g2, b2, a), keep_alpha=a < 1.0)
359
+
360
+
361
+ def add_halo(
362
+ artist: Artist | Sequence[Artist],
363
+ width: float = 4.0,
364
+ color: ColorType = COLORS.paper,
365
+ ) -> Artist | Sequence[Artist]:
366
+ """Give a line/text artist a contrasting outline ("casing") so it pops.
367
+
368
+ Strokes a wider line of *color* (default: theme ``paper``) beneath the
369
+ artist using matplotlib path effects -- ideal for a median/reference line
370
+ or a label drawn over a busy histogram.
371
+
372
+ Args:
373
+ artist: A single artist, or a sequence of them (e.g. the list
374
+ returned by ``Axes.plot``).
375
+ width: Casing line width in points. Make it a couple of points wider
376
+ than the artist's own line width so the outline shows on both
377
+ sides.
378
+ color: Casing color (default: theme paper ``#faf9f7``; pass
379
+ ``COLORS_DARK.paper`` on the dark style).
380
+
381
+ Returns:
382
+ The *artist* argument, for chaining::
383
+
384
+ add_halo(ax.axvline(median, color="ink", lw=1.8, zorder=5))
385
+ add_halo(ax.text(median, ymax, "median", ...))
386
+ """
387
+ import matplotlib.artist as martist
388
+ import matplotlib.patheffects as pe
389
+
390
+ effects: list[pe.AbstractPathEffect] = [
391
+ pe.withStroke(linewidth=width, foreground=color)
392
+ ]
393
+ artists: list[Artist] = (
394
+ [artist] if isinstance(artist, martist.Artist) else list(artist)
395
+ )
396
+ for a in artists:
397
+ a.set_path_effects(effects)
398
+ return artist
399
+
400
+
401
+ def _resolve_ax(ax: Axes | None) -> Axes:
402
+ """Return *ax*, or the current axes when *ax* is ``None``."""
403
+ if ax is None:
404
+ import matplotlib.pyplot as plt
405
+
406
+ ax = plt.gca()
407
+ return ax
408
+
409
+
410
+ def _resolve_handles_labels(
411
+ ax: Axes,
412
+ handles: Sequence[Artist] | None,
413
+ labels: Sequence[str] | None,
414
+ ) -> tuple[Sequence[Artist], Sequence[str]]:
415
+ """Fill in missing *handles*/*labels* from the artists drawn on *ax*."""
416
+ if handles is None or labels is None:
417
+ auto_handles, auto_labels = ax.get_legend_handles_labels()
418
+ handles = auto_handles if handles is None else handles
419
+ labels = auto_labels if labels is None else labels
420
+ return handles, labels
421
+
422
+
423
+ def _expand_figure_for_legend(ax: Axes, legend: Legend, margin: float = 0.1) -> None:
424
+ """Grow the figure canvas so *legend* fits outside the axes WITHOUT shrinking it.
425
+
426
+ The active layout engine (e.g. ``constrained_layout``) would otherwise
427
+ resize the axes to fit an outside legend within the fixed figure size.
428
+ Instead, this turns the layout engine off, pins every axes to its current
429
+ size in inches, and enlarges the figure on whichever sides the legend
430
+ overflows.
431
+
432
+ Args:
433
+ ax: An axes belonging to the figure to expand.
434
+ legend: The (already-placed) external legend to make room for.
435
+ margin: Gap, in inches, left between the legend and the new canvas
436
+ edge.
437
+ """
438
+ from matplotlib.figure import Figure
439
+
440
+ fig = ax.get_figure()
441
+ if not isinstance(fig, Figure):
442
+ return # SubFigures (and None) cannot be resized independently.
443
+
444
+ # constrained/tight layout re-shrinks the axes on every draw, so turn the
445
+ # layout engine off and manage the geometry ourselves.
446
+ fig.set_layout_engine("none")
447
+
448
+ # Draw once so the legend (and its text) have a concrete position and size.
449
+ fig.canvas.draw()
450
+
451
+ size = fig.get_size_inches()
452
+ w_in, h_in = float(size[0]), float(size[1])
453
+ dpi = float(fig.get_dpi())
454
+
455
+ # Pin every axes to its current geometry, in inches.
456
+ axes_inches = []
457
+ for a in fig.axes:
458
+ p = a.get_position()
459
+ axes_inches.append(
460
+ (a, p.x0 * w_in, p.y0 * h_in, p.width * w_in, p.height * h_in)
461
+ )
462
+
463
+ # Legend extent, in inches, relative to the figure's bottom-left origin.
464
+ bb = legend.get_window_extent()
465
+ lx0, ly0, lx1, ly1 = bb.x0 / dpi, bb.y0 / dpi, bb.x1 / dpi, bb.y1 / dpi
466
+
467
+ # How far the legend spills past each figure edge (plus a margin on that side).
468
+ add_left = (-lx0 + margin) if lx0 < 0 else 0.0
469
+ add_right = (lx1 - w_in + margin) if lx1 > w_in else 0.0
470
+ add_bottom = (-ly0 + margin) if ly0 < 0 else 0.0
471
+ add_top = (ly1 - h_in + margin) if ly1 > h_in else 0.0
472
+
473
+ if not (add_left or add_right or add_bottom or add_top):
474
+ return # already inside the canvas; nothing to do.
475
+
476
+ new_w = w_in + add_left + add_right
477
+ new_h = h_in + add_bottom + add_top
478
+
479
+ # Re-pin axes at identical inch sizes; existing content shifts right by
480
+ # ``add_left`` and up by ``add_bottom`` to open up the new margins. The
481
+ # legend is anchored in axes coordinates, so it moves with the axes.
482
+ for a, ix0, iy0, iw, ih in axes_inches:
483
+ a.set_position(
484
+ (
485
+ (ix0 + add_left) / new_w,
486
+ (iy0 + add_bottom) / new_h,
487
+ iw / new_w,
488
+ ih / new_h,
489
+ )
490
+ )
491
+
492
+ fig.set_size_inches(new_w, new_h)
493
+ fig.canvas.draw()
494
+
495
+
496
+ def legend_right(
497
+ ax: Axes | None = None,
498
+ *,
499
+ handles: Sequence[Artist] | None = None,
500
+ labels: Sequence[str] | None = None,
501
+ pad: float = 0.02,
502
+ expand: bool = True,
503
+ **kwargs,
504
+ ) -> Legend:
505
+ """Move the legend just outside the right edge of the plot, one entry per row.
506
+
507
+ Entries are stacked in a single column, so they read top-to-bottom row by
508
+ row -- handy for keeping a tall, multi-series key clear of the data area.
509
+
510
+ Args:
511
+ ax: Target axes. Defaults to the current axes (``plt.gca()``).
512
+ handles: Explicit legend handles. Auto-detected from *ax* when
513
+ omitted.
514
+ labels: Explicit legend labels. Auto-detected from *ax* when omitted.
515
+ pad: Horizontal gap between the axes and the legend, as a fraction of
516
+ axes width (the legend's left edge is anchored at ``1 + pad``).
517
+ expand: When ``True`` (default), keep the axes at its current size
518
+ and grow the figure canvas to the right to fit the legend,
519
+ instead of letting the layout engine shrink the axes. This
520
+ disables the figure's layout engine (e.g. ``constrained_layout``).
521
+ **kwargs: Forwarded to ``Axes.legend`` (e.g. ``title``, ``fontsize``).
522
+ ``ncol`` defaults to 1 to preserve the row-by-row layout.
523
+
524
+ Returns:
525
+ The created ``matplotlib.legend.Legend``.
526
+ """
527
+ ax = _resolve_ax(ax)
528
+ handles, labels = _resolve_handles_labels(ax, handles, labels)
529
+ kwargs.setdefault("loc", "center left")
530
+ kwargs.setdefault("bbox_to_anchor", (1.0 + pad, 0.5))
531
+ kwargs.setdefault("borderaxespad", 0.0)
532
+ kwargs.setdefault("ncol", 1)
533
+ legend = ax.legend(handles, labels, **kwargs)
534
+ if expand:
535
+ _expand_figure_for_legend(ax, legend)
536
+ return legend
537
+
538
+
539
+ def legend_below(
540
+ ax: Axes | None = None,
541
+ *,
542
+ per_row: int | None = None,
543
+ handles: Sequence[Artist] | None = None,
544
+ labels: Sequence[str] | None = None,
545
+ pad: float = 0.12,
546
+ expand: bool = True,
547
+ **kwargs,
548
+ ) -> Legend:
549
+ """Move the legend below the plot, with *per_row* entries on each row.
550
+
551
+ Args:
552
+ ax: Target axes. Defaults to the current axes (``plt.gca()``).
553
+ per_row: Number of legend entries per row. Defaults to ``None``,
554
+ which lays every entry out on a single row.
555
+ handles: Explicit legend handles. Auto-detected from *ax* when
556
+ omitted.
557
+ labels: Explicit legend labels. Auto-detected from *ax* when omitted.
558
+ pad: Vertical gap between the axes and the legend, as a fraction of
559
+ axes height (the legend's top edge is anchored at ``-pad``).
560
+ expand: When ``True`` (default), keep the axes at its current size
561
+ and grow the figure canvas downward to fit the legend, instead of
562
+ letting the layout engine shrink the axes. This disables the
563
+ figure's layout engine (e.g. ``constrained_layout``).
564
+ **kwargs: Forwarded to ``Axes.legend`` (e.g. ``title``, ``fontsize``).
565
+
566
+ Returns:
567
+ The created ``matplotlib.legend.Legend``.
568
+ """
569
+ ax = _resolve_ax(ax)
570
+ handles, labels = _resolve_handles_labels(ax, handles, labels)
571
+ if per_row is None:
572
+ # Honor an explicit ncol if given; otherwise spread all entries on one row.
573
+ per_row = kwargs.pop("ncol", None) or len(labels) or 1
574
+ else:
575
+ kwargs.pop("ncol", None)
576
+ kwargs.setdefault("loc", "upper center")
577
+ kwargs.setdefault("bbox_to_anchor", (0.5, -pad))
578
+ kwargs.setdefault("borderaxespad", 0.0)
579
+ kwargs["ncol"] = max(1, int(per_row))
580
+ legend = ax.legend(handles, labels, **kwargs)
581
+ if expand:
582
+ _expand_figure_for_legend(ax, legend)
583
+ return legend