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 +41 -0
- qc_core-0.0.1/README.md +31 -0
- qc_core-0.0.1/pyproject.toml +18 -0
- qc_core-0.0.1/pyproject.toml.orig +19 -0
- qc_core-0.0.1/src/qc_core/__init__.py +9 -0
- qc_core-0.0.1/src/qc_core/ledger.mplstyle +135 -0
- qc_core-0.0.1/src/qc_core/plotting.py +583 -0
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.
|
qc_core-0.0.1/README.md
ADDED
|
@@ -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
|