paperplot-quantum 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.
- paperplot/__init__.py +77 -0
- paperplot/core.py +228 -0
- paperplot/data/fonts/GUST-FONT-LICENSE.txt +29 -0
- paperplot/data/fonts/LICENSE.md +22 -0
- paperplot/data/fonts/texgyreheros-bold.otf +0 -0
- paperplot/data/fonts/texgyreheros-bolditalic.otf +0 -0
- paperplot/data/fonts/texgyreheros-italic.otf +0 -0
- paperplot/data/fonts/texgyreheros-regular.otf +0 -0
- paperplot/data/journals.toml +123 -0
- paperplot/fonts.py +45 -0
- paperplot/journals.py +57 -0
- paperplot/layout.py +133 -0
- paperplot/lint.py +156 -0
- paperplot/mplstyle.py +103 -0
- paperplot/palettes.py +190 -0
- paperplot/plots.py +310 -0
- paperplot/preview.py +250 -0
- paperplot/registry.py +111 -0
- paperplot/save.py +99 -0
- paperplot/style.py +194 -0
- paperplot_quantum-0.1.0.dist-info/METADATA +281 -0
- paperplot_quantum-0.1.0.dist-info/RECORD +27 -0
- paperplot_quantum-0.1.0.dist-info/WHEEL +5 -0
- paperplot_quantum-0.1.0.dist-info/licenses/LICENSE +21 -0
- paperplot_quantum-0.1.0.dist-info/licenses/paperplot/data/fonts/GUST-FONT-LICENSE.txt +29 -0
- paperplot_quantum-0.1.0.dist-info/licenses/paperplot/data/fonts/LICENSE.md +22 -0
- paperplot_quantum-0.1.0.dist-info/top_level.txt +1 -0
paperplot/layout.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Figure geometry: width classes, unit conversions, and figsize math.
|
|
2
|
+
|
|
3
|
+
Pure geometry — no matplotlib styling here. ``units`` live here too (the
|
|
4
|
+
conversions are small and only used for sizing).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import difflib
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING: # avoid a runtime import cycle with journals.py
|
|
14
|
+
from .journals import JournalSpec
|
|
15
|
+
|
|
16
|
+
MM_PER_IN = 25.4
|
|
17
|
+
GOLDEN = (1.0 + 5.0 ** 0.5) / 2.0 # ~1.618
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def mm_to_in(mm: float) -> float:
|
|
21
|
+
return mm / MM_PER_IN
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def in_to_mm(inch: float) -> float:
|
|
25
|
+
return inch * MM_PER_IN
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pt_to_mm(pt: float) -> float:
|
|
29
|
+
return pt * (MM_PER_IN / 72.0) # 1 pt = 1/72 in
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Width(str, Enum):
|
|
33
|
+
"""Standard journal figure widths."""
|
|
34
|
+
|
|
35
|
+
SINGLE = "single"
|
|
36
|
+
ONEHALF = "onehalf"
|
|
37
|
+
DOUBLE = "double"
|
|
38
|
+
FULL_PAGE = "full_page"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_width(width) -> str:
|
|
42
|
+
"""Normalize a Width/str to a canonical key, with a friendly error."""
|
|
43
|
+
if isinstance(width, Width):
|
|
44
|
+
return width.value
|
|
45
|
+
key = str(width).strip().lower().replace("-", "_")
|
|
46
|
+
valid = [w.value for w in Width]
|
|
47
|
+
if key in valid:
|
|
48
|
+
return key
|
|
49
|
+
hint = difflib.get_close_matches(key, valid, n=1)
|
|
50
|
+
suffix = f" Did you mean {hint[0]!r}?" if hint else ""
|
|
51
|
+
raise ValueError(f"Unknown width {width!r}. Valid: {valid}.{suffix}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def width_in(spec: "JournalSpec", width="single") -> float:
|
|
55
|
+
"""Physical figure width in inches for the given width class."""
|
|
56
|
+
key = resolve_width(width)
|
|
57
|
+
if key == Width.FULL_PAGE.value:
|
|
58
|
+
key = Width.DOUBLE.value # full-page spans the double-column block
|
|
59
|
+
try:
|
|
60
|
+
return mm_to_in(spec.widths_mm[key])
|
|
61
|
+
except KeyError:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"Journal {spec.name!r} has no {key!r} width "
|
|
64
|
+
f"(has {sorted(spec.widths_mm)})."
|
|
65
|
+
) from None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def figsize(spec: "JournalSpec", width="single", aspect="golden", height=None):
|
|
69
|
+
"""Return ``(w_in, h_in)``.
|
|
70
|
+
|
|
71
|
+
Width is fixed by the column class. Height comes from ``height`` (inches,
|
|
72
|
+
overrides everything), else ``aspect`` applied to the width, clamped to the
|
|
73
|
+
page's usable height. ``FULL_PAGE`` defaults to page-height minus the caption
|
|
74
|
+
reserve so it actually fills the page.
|
|
75
|
+
"""
|
|
76
|
+
key = resolve_width(width)
|
|
77
|
+
w = width_in(spec, key)
|
|
78
|
+
usable_h = mm_to_in(spec.page_height_mm)
|
|
79
|
+
|
|
80
|
+
if key == Width.FULL_PAGE.value:
|
|
81
|
+
if height is not None:
|
|
82
|
+
h = float(height)
|
|
83
|
+
else:
|
|
84
|
+
h = mm_to_in(spec.page_height_mm - spec.caption_reserve_mm)
|
|
85
|
+
return (w, min(h, usable_h))
|
|
86
|
+
|
|
87
|
+
if height is not None:
|
|
88
|
+
h = float(height)
|
|
89
|
+
elif aspect in (None, "golden"):
|
|
90
|
+
h = w / GOLDEN
|
|
91
|
+
elif aspect == "equal":
|
|
92
|
+
h = w
|
|
93
|
+
else:
|
|
94
|
+
h = w * float(aspect) # aspect = height / width
|
|
95
|
+
|
|
96
|
+
return (w, min(h, usable_h))
|
|
97
|
+
|
|
98
|
+
import numpy as np
|
|
99
|
+
|
|
100
|
+
class Row:
|
|
101
|
+
def __init__(self, name: str, height: float, gap_above: float = 0.0, attached: bool = False):
|
|
102
|
+
self.name = name
|
|
103
|
+
self.height = height
|
|
104
|
+
self.gap_above = gap_above
|
|
105
|
+
self.attached = attached
|
|
106
|
+
|
|
107
|
+
class Grid:
|
|
108
|
+
def __init__(self, *rows: Row, ncols: int = 1, col_width: float = 1.25, wspace: float = 0.3, margins: dict = None):
|
|
109
|
+
self.rows = list(rows)
|
|
110
|
+
self.ncols = ncols
|
|
111
|
+
self.col_width = col_width
|
|
112
|
+
self.wspace = wspace
|
|
113
|
+
self.margins = {"left": 0.54, "right": 0.05, "top": 0.18, "bottom": 0.30}
|
|
114
|
+
if margins:
|
|
115
|
+
self.margins.update(margins)
|
|
116
|
+
|
|
117
|
+
class GridAxesDict(dict):
|
|
118
|
+
"""Hybrid array-dict for accessing axes by name or index."""
|
|
119
|
+
def __init__(self, axes_array, row_names):
|
|
120
|
+
super().__init__()
|
|
121
|
+
self.axes_array = axes_array
|
|
122
|
+
self.row_names = row_names
|
|
123
|
+
for r_idx, name in enumerate(row_names):
|
|
124
|
+
for c_idx in range(axes_array.shape[1]):
|
|
125
|
+
self[(name, c_idx)] = axes_array[r_idx, c_idx]
|
|
126
|
+
|
|
127
|
+
def __getitem__(self, key):
|
|
128
|
+
if isinstance(key, tuple) and len(key) == 2 and isinstance(key[0], int) and isinstance(key[1], int):
|
|
129
|
+
return self.axes_array[key[0], key[1]]
|
|
130
|
+
return super().__getitem__(key)
|
|
131
|
+
|
|
132
|
+
def __iter__(self):
|
|
133
|
+
return iter(self.axes_array)
|
paperplot/lint.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""``preflight()`` — validate a figure against journal rules. Warn, never block.
|
|
2
|
+
|
|
3
|
+
Returns a structured ``Report`` so CI can gate on it while humans read a table.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional, Tuple, Union
|
|
10
|
+
|
|
11
|
+
import matplotlib.colors as mcolors
|
|
12
|
+
|
|
13
|
+
import importlib
|
|
14
|
+
|
|
15
|
+
from .journals import JournalSpec
|
|
16
|
+
|
|
17
|
+
# pp.style() shadows the style submodule on the package; reach it via sys.modules.
|
|
18
|
+
_style = importlib.import_module("paperplot.style")
|
|
19
|
+
|
|
20
|
+
Number = Union[float, str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Finding:
|
|
25
|
+
severity: str # "warn" | "info"
|
|
26
|
+
rule: str # min_font | min_linewidth | gray_luminance
|
|
27
|
+
locator: str # human-readable artist location
|
|
28
|
+
measured: Number
|
|
29
|
+
limit: Number
|
|
30
|
+
message: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Report:
|
|
35
|
+
findings: Tuple[Finding, ...]
|
|
36
|
+
spec_name: str = ""
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def warnings(self) -> Tuple[Finding, ...]:
|
|
40
|
+
return tuple(f for f in self.findings if f.severity == "warn")
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def ok(self) -> bool:
|
|
44
|
+
return not self.warnings
|
|
45
|
+
|
|
46
|
+
def __bool__(self) -> bool:
|
|
47
|
+
return self.ok
|
|
48
|
+
|
|
49
|
+
def by_rule(self, rule: str) -> Tuple[Finding, ...]:
|
|
50
|
+
return tuple(f for f in self.findings if f.rule == rule)
|
|
51
|
+
|
|
52
|
+
def __str__(self) -> str:
|
|
53
|
+
if not self.findings:
|
|
54
|
+
return f"preflight [{self.spec_name}]: OK ✓ (no issues)"
|
|
55
|
+
lines = [f"preflight [{self.spec_name}]: "
|
|
56
|
+
f"{len(self.warnings)} warning(s), "
|
|
57
|
+
f"{len(self.findings) - len(self.warnings)} info"]
|
|
58
|
+
for f in self.findings:
|
|
59
|
+
mark = "⚠" if f.severity == "warn" else "ℹ"
|
|
60
|
+
lines.append(f" {mark} {f.message}")
|
|
61
|
+
return "\n".join(lines)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _luminance(color) -> float:
|
|
65
|
+
r, g, b = mcolors.to_rgb(color)
|
|
66
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def preflight(fig, spec: Optional[JournalSpec] = None) -> Report:
|
|
70
|
+
"""Inspect a drawn figure for sub-spec fonts, lines, and gray ambiguity."""
|
|
71
|
+
spec = spec or _style.active()
|
|
72
|
+
if spec is None:
|
|
73
|
+
raise RuntimeError("No journal to check against; call pp.use('aps') or pass spec=.")
|
|
74
|
+
|
|
75
|
+
fig.canvas.draw() # realize tick labels / autosized artists
|
|
76
|
+
fp = spec.font_pt
|
|
77
|
+
# Cap-height is an info-level nag aimed at text the *user* shrank below the
|
|
78
|
+
# journal's own defaults; don't fire it on the spec's chosen sizes (e.g. APS
|
|
79
|
+
# 7.5pt ticks are ~1.9mm cap-height by design — reporting them is just noise).
|
|
80
|
+
spec_floor_pt = min(fp.base, fp.tick, fp.legend, fp.panel)
|
|
81
|
+
findings: list[Finding] = []
|
|
82
|
+
|
|
83
|
+
for i, ax in enumerate(fig.axes):
|
|
84
|
+
texts = [("title", ax.title), ("x-label", ax.xaxis.label),
|
|
85
|
+
("y-label", ax.yaxis.label)]
|
|
86
|
+
texts += [("x-tick", t) for t in ax.get_xticklabels()]
|
|
87
|
+
texts += [("y-tick", t) for t in ax.get_yticklabels()]
|
|
88
|
+
leg = ax.get_legend()
|
|
89
|
+
if leg is not None:
|
|
90
|
+
texts += [("legend", t) for t in leg.get_texts()]
|
|
91
|
+
|
|
92
|
+
seen_kinds: set = set()
|
|
93
|
+
for kind, t in texts:
|
|
94
|
+
if not t.get_text():
|
|
95
|
+
continue
|
|
96
|
+
pt = t.get_fontsize()
|
|
97
|
+
loc = f"axes[{i}] {kind}"
|
|
98
|
+
if pt < fp.min_warn_pt:
|
|
99
|
+
findings.append(Finding(
|
|
100
|
+
"warn", "min_font", loc, round(pt, 1), fp.min_warn_pt,
|
|
101
|
+
f"{loc}: {pt:.1f}pt < {fp.min_warn_pt}pt minimum"))
|
|
102
|
+
elif (kind not in seen_kinds and pt < spec_floor_pt
|
|
103
|
+
and fp.cap_height_mm(pt) < fp.min_cap_height_mm):
|
|
104
|
+
cap = fp.cap_height_mm(pt)
|
|
105
|
+
findings.append(Finding(
|
|
106
|
+
"info", "min_font", loc, round(cap, 2), fp.min_cap_height_mm,
|
|
107
|
+
f"{loc}: cap-height {cap:.2f}mm < {fp.min_cap_height_mm}mm "
|
|
108
|
+
f"APS guideline (font {pt:.1f}pt)"))
|
|
109
|
+
seen_kinds.add(kind)
|
|
110
|
+
|
|
111
|
+
for j, line in enumerate(ax.get_lines()):
|
|
112
|
+
lw = line.get_linewidth()
|
|
113
|
+
if 0 < lw < spec.min_linewidth_pt:
|
|
114
|
+
findings.append(Finding(
|
|
115
|
+
"warn", "min_linewidth", f"axes[{i}] Line2D #{j}",
|
|
116
|
+
round(lw, 2), spec.min_linewidth_pt,
|
|
117
|
+
f"axes[{i}] line #{j}: {lw:.2f}pt < "
|
|
118
|
+
f"{spec.min_linewidth_pt}pt minimum weight"))
|
|
119
|
+
|
|
120
|
+
# collection-based artists (LineCollection from step/quiver/etc.) carry
|
|
121
|
+
# their own linewidths and would otherwise dodge the Line2D check.
|
|
122
|
+
for j, coll in enumerate(ax.collections):
|
|
123
|
+
try:
|
|
124
|
+
lws = [float(w) for w in coll.get_linewidths()]
|
|
125
|
+
except (TypeError, ValueError):
|
|
126
|
+
continue
|
|
127
|
+
thin = [w for w in lws if 0 < w < spec.min_linewidth_pt]
|
|
128
|
+
if thin:
|
|
129
|
+
w = min(thin)
|
|
130
|
+
findings.append(Finding(
|
|
131
|
+
"warn", "min_linewidth", f"axes[{i}] collection #{j}",
|
|
132
|
+
round(w, 2), spec.min_linewidth_pt,
|
|
133
|
+
f"axes[{i}] collection #{j}: {w:.2f}pt < "
|
|
134
|
+
f"{spec.min_linewidth_pt}pt minimum weight"))
|
|
135
|
+
|
|
136
|
+
# cycle-color grayscale separability (print is grayscale, APS H24)
|
|
137
|
+
try:
|
|
138
|
+
import matplotlib as mpl
|
|
139
|
+
from . import palettes
|
|
140
|
+
colors = mpl.rcParams["axes.prop_cycle"].by_key().get("color", [])
|
|
141
|
+
# Don't nag about the shipped default: Okabe-Ito is curated colorblind-safe,
|
|
142
|
+
# and its grayscale gaps are a known, accepted trade-off.
|
|
143
|
+
cur = [mcolors.to_hex(c).lower() for c in colors]
|
|
144
|
+
default = [mcolors.to_hex(c).lower() for c in palettes.OKABE_ITO]
|
|
145
|
+
is_default = bool(cur) and cur == default[:len(cur)]
|
|
146
|
+
lums = sorted(_luminance(c) for c in colors[:6])
|
|
147
|
+
gaps = [b - a for a, b in zip(lums, lums[1:])]
|
|
148
|
+
if not is_default and gaps and min(gaps) < 0.05:
|
|
149
|
+
findings.append(Finding(
|
|
150
|
+
"info", "gray_luminance", "color cycle", round(min(gaps), 3), 0.05,
|
|
151
|
+
"two cycle colors are near-identical in grayscale; check the "
|
|
152
|
+
"figure stays legible in print (APS H24)"))
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
return Report(tuple(findings), spec_name=spec.name)
|
paperplot/mplstyle.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The zero-buy-in on-ramp: paperplot's look as a plain matplotlib style sheet.
|
|
2
|
+
|
|
3
|
+
``figure()``/``save()`` are the product — they size to the real column width,
|
|
4
|
+
embed fonts, and preflight. But the *look* (type scale, Okabe-Ito cycle, tick
|
|
5
|
+
style, full box) is just rcParams, and rcParams travel as ``.mplstyle`` files.
|
|
6
|
+
|
|
7
|
+
``export_mplstyle("aps")`` writes a style file; ``register_mplstyles()`` makes
|
|
8
|
+
``plt.style.use("paperplot-aps")`` work in-process. Either way a user gets the
|
|
9
|
+
styling with no API commitment, then graduates to ``pp.figure()`` for the part a
|
|
10
|
+
style sheet *cannot* do — true sizing and preflight.
|
|
11
|
+
|
|
12
|
+
We do NOT register on import: paperplot's first import stays silent and mutates
|
|
13
|
+
no global matplotlib state. Registration is an explicit opt-in.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from cycler import Cycler
|
|
21
|
+
|
|
22
|
+
from .registry import available, get_spec
|
|
23
|
+
from .style import rcparams
|
|
24
|
+
|
|
25
|
+
# Journal keys exposed as style sheets. Variants (prl/prx/prb) share APS styling,
|
|
26
|
+
# so they'd produce identical sheets — we skip them here to keep the list clean.
|
|
27
|
+
_STYLE_KEYS = ("aps", "nature", "ieee", "talk")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _format_value(key: str, value) -> str:
|
|
31
|
+
"""Serialize one rcParam value to matplotlib ``.mplstyle`` syntax."""
|
|
32
|
+
if key == "axes.prop_cycle" and isinstance(value, Cycler):
|
|
33
|
+
# '#' starts a comment in .mplstyle files, so hex colors are written
|
|
34
|
+
# bare (no leading '#') — exactly how matplotlib's own matplotlibrc does.
|
|
35
|
+
colors = (c.lstrip("#") for c in value.by_key().get("color", []))
|
|
36
|
+
inner = ", ".join(f"'{c}'" for c in colors)
|
|
37
|
+
return f"cycler('color', [{inner}])"
|
|
38
|
+
if isinstance(value, bool): # before int/float — bool is an int subclass
|
|
39
|
+
return "True" if value else "False"
|
|
40
|
+
if isinstance(value, (list, tuple)):
|
|
41
|
+
return ", ".join(str(v) for v in value)
|
|
42
|
+
return str(value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def to_mplstyle_text(journal, *, serif: bool = False, usetex: bool = False,
|
|
46
|
+
palette=None, math: str = "cm",
|
|
47
|
+
font_scale: float = 1.0) -> str:
|
|
48
|
+
"""Render a journal's rcParams as ``.mplstyle`` file text.
|
|
49
|
+
|
|
50
|
+
Accepts the same styling options as :func:`paperplot.use`. The result is the
|
|
51
|
+
look only — it carries the journal's *default* (single-column) figure size,
|
|
52
|
+
not per-figure sizing, which is what ``pp.figure(width=...)`` is for.
|
|
53
|
+
"""
|
|
54
|
+
spec = get_spec(journal) if not hasattr(journal, "revision") else journal
|
|
55
|
+
rc = rcparams(spec, serif=serif, usetex=usetex, palette=palette, math=math,
|
|
56
|
+
font_scale=font_scale)
|
|
57
|
+
lines = [
|
|
58
|
+
f"# paperplot style for {spec.name} (revision {spec.revision}).",
|
|
59
|
+
"# Generated by paperplot.export_mplstyle — the look only.",
|
|
60
|
+
"# For journal-true sizing, font embedding, and preflight, use pp.figure()/pp.save().",
|
|
61
|
+
"",
|
|
62
|
+
]
|
|
63
|
+
for key in sorted(rc):
|
|
64
|
+
lines.append(f"{key}: {_format_value(key, rc[key])}")
|
|
65
|
+
return "\n".join(lines) + "\n"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def export_mplstyle(journal, path: Optional[str] = None, **opts) -> str:
|
|
69
|
+
"""Write (and return) a ``.mplstyle`` file for ``journal``.
|
|
70
|
+
|
|
71
|
+
With no ``path``, returns the text without writing. Pass styling options
|
|
72
|
+
(``serif``, ``usetex``, ``palette``, ``math``, ``font_scale``) just like
|
|
73
|
+
:func:`paperplot.use`.
|
|
74
|
+
"""
|
|
75
|
+
text = to_mplstyle_text(journal, **opts)
|
|
76
|
+
if path is not None:
|
|
77
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
78
|
+
fh.write(text)
|
|
79
|
+
return text
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def register_mplstyles() -> list[str]:
|
|
83
|
+
"""Register ``paperplot-<journal>`` styles into matplotlib's style library.
|
|
84
|
+
|
|
85
|
+
After calling this, ``plt.style.use("paperplot-aps")`` works (and the names
|
|
86
|
+
appear in ``plt.style.available``). Opt-in — paperplot never does this on
|
|
87
|
+
import. Returns the registered style names.
|
|
88
|
+
"""
|
|
89
|
+
import matplotlib.style as mstyle
|
|
90
|
+
|
|
91
|
+
names = []
|
|
92
|
+
known = set(available())
|
|
93
|
+
for key in _STYLE_KEYS:
|
|
94
|
+
if key not in known:
|
|
95
|
+
continue
|
|
96
|
+
spec = get_spec(key)
|
|
97
|
+
name = f"paperplot-{key}"
|
|
98
|
+
# Library entries are applied via rcParams.update, so the live dict
|
|
99
|
+
# (cycler object intact) works directly — no text round-trip needed.
|
|
100
|
+
mstyle.library[name] = rcparams(spec)
|
|
101
|
+
names.append(name)
|
|
102
|
+
mstyle.available[:] = sorted(mstyle.library)
|
|
103
|
+
return names
|
paperplot/palettes.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Colors: a colorblind-safe qualitative cycle, sequential/diverging colormaps,
|
|
2
|
+
and a small custom-palette registry. matplotlib-only (no seaborn).
|
|
3
|
+
|
|
4
|
+
The core rule: qualitative cycles (for categories) and sequential/diverging
|
|
5
|
+
colormaps (for ordered data) are *different things*. Using a sequential scheme
|
|
6
|
+
as a categorical cycle is an enforced anti-pattern, not just a docstring note.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import warnings
|
|
12
|
+
from typing import Dict, List, Sequence
|
|
13
|
+
|
|
14
|
+
import matplotlib.colors as mcolors
|
|
15
|
+
from matplotlib import colormaps
|
|
16
|
+
|
|
17
|
+
# Okabe-Ito colorblind-safe qualitative palette (default cycle).
|
|
18
|
+
OKABE_ITO: List[str] = [
|
|
19
|
+
"#0072B2", # blue
|
|
20
|
+
"#D55E00", # vermillion
|
|
21
|
+
"#009E73", # green
|
|
22
|
+
"#CC79A7", # reddish purple
|
|
23
|
+
"#E69F00", # orange
|
|
24
|
+
"#56B4E9", # sky blue
|
|
25
|
+
"#F0E442", # yellow
|
|
26
|
+
"#000000", # black
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Seaborn's qualitative palettes, hardcoded so they're available without seaborn.
|
|
30
|
+
# The pairing that matters: MUTED for area/histogram *fills*, BRIGHT for *strokes*
|
|
31
|
+
# (lines, reference markers). Muted fills under bright strokes keeps overlapping
|
|
32
|
+
# filled shapes legible. See pp.fills() / pp.strokes().
|
|
33
|
+
MUTED: List[str] = [
|
|
34
|
+
"#4878D0", "#EE854A", "#6ACC64", "#D65F5F", "#956CB4",
|
|
35
|
+
"#8C613C", "#DC7EC0", "#797979", "#D5BB67", "#82C6E2",
|
|
36
|
+
]
|
|
37
|
+
BRIGHT: List[str] = [
|
|
38
|
+
"#023EFF", "#FF7C00", "#1AC938", "#E8000B", "#8B2BE2",
|
|
39
|
+
"#9F4800", "#F14CC1", "#A3A3A3", "#FFC400", "#00D7FF",
|
|
40
|
+
]
|
|
41
|
+
DEEP: List[str] = [
|
|
42
|
+
"#4C72B0", "#DD8452", "#55A868", "#C44E52", "#8172B3",
|
|
43
|
+
"#937860", "#DA8BC3", "#8C8C8C", "#CCB974", "#64B5CD",
|
|
44
|
+
]
|
|
45
|
+
COLORBLIND: List[str] = [
|
|
46
|
+
"#0173B2", "#DE8F05", "#029E73", "#D55E00", "#CC78BC",
|
|
47
|
+
"#CA9161", "#FBAFE4", "#949494", "#ECE133", "#56B4E9",
|
|
48
|
+
]
|
|
49
|
+
PASTEL: List[str] = [
|
|
50
|
+
"#A1C9F4", "#FFB482", "#8DE5A1", "#FF9F9B", "#D0BBFF",
|
|
51
|
+
"#DEBB9B", "#FAB0E4", "#CFCFCF", "#FFFEA3", "#B9F2F0",
|
|
52
|
+
]
|
|
53
|
+
DARK: List[str] = [
|
|
54
|
+
"#001C7F", "#B1400D", "#12711C", "#8C0800", "#591E71",
|
|
55
|
+
"#592F0D", "#A23582", "#3C3C3C", "#B8850A", "#006374",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# Named categorical palettes resolvable by pp.palette("name").
|
|
59
|
+
_BUILTIN: Dict[str, List[str]] = {
|
|
60
|
+
"muted": MUTED,
|
|
61
|
+
"bright": BRIGHT,
|
|
62
|
+
"deep": DEEP,
|
|
63
|
+
"colorblind": COLORBLIND,
|
|
64
|
+
"pastel": PASTEL,
|
|
65
|
+
"dark": DARK,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Palettes designed to stay distinguishable under common color-vision deficiencies.
|
|
69
|
+
# The seaborn "muted"/"bright"/etc. families are NOT in this set — they pair well
|
|
70
|
+
# as fills/strokes but should not carry meaning across categorical *lines*.
|
|
71
|
+
_CB_SAFE = {"okabe-ito", "colorblind"}
|
|
72
|
+
|
|
73
|
+
# ColorBrewer + perceptual maps that are NOT valid categorical cycles.
|
|
74
|
+
_SEQUENTIAL = {
|
|
75
|
+
"Blues", "BuGn", "BuPu", "GnBu", "Greens", "Greys", "OrRd", "Oranges",
|
|
76
|
+
"PuBu", "PuBuGn", "PuRd", "Purples", "RdPu", "Reds", "YlGn", "YlGnBu",
|
|
77
|
+
"YlOrBr", "YlOrRd", "viridis", "plasma", "inferno", "magma", "cividis",
|
|
78
|
+
}
|
|
79
|
+
_DIVERGING = {
|
|
80
|
+
"BrBG", "PiYG", "PRGn", "PuOr", "RdBu", "RdGy", "RdYlBu", "RdYlGn",
|
|
81
|
+
"Spectral", "coolwarm", "bwr", "seismic",
|
|
82
|
+
}
|
|
83
|
+
# True qualitative matplotlib colormaps — safe to use as categorical cycles.
|
|
84
|
+
# (Continuous maps are stored as 256-entry ListedColormaps in modern matplotlib,
|
|
85
|
+
# so a `hasattr(cmap, "colors")` test wrongly treats them as qualitative.)
|
|
86
|
+
_QUALITATIVE = {
|
|
87
|
+
"Pastel1", "Pastel2", "Paired", "Accent", "Dark2",
|
|
88
|
+
"Set1", "Set2", "Set3", "tab10", "tab20", "tab20b", "tab20c",
|
|
89
|
+
}
|
|
90
|
+
_RESERVED = {"mpl", "okabe-ito", "okabe_ito"}
|
|
91
|
+
|
|
92
|
+
_custom: Dict[str, List[str]] = {}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def register_palette(name: str, colors: Sequence) -> None:
|
|
96
|
+
"""Register a named categorical palette from a list of colors (hex or RGB)."""
|
|
97
|
+
key = name.strip().lower()
|
|
98
|
+
if key in _RESERVED:
|
|
99
|
+
raise ValueError(f"{name!r} is a reserved palette name.")
|
|
100
|
+
try:
|
|
101
|
+
hexed = [mcolors.to_hex(c) for c in colors]
|
|
102
|
+
except (ValueError, TypeError) as e:
|
|
103
|
+
raise ValueError(f"Invalid color in palette {name!r}: {e}") from None
|
|
104
|
+
if not hexed:
|
|
105
|
+
raise ValueError("A palette needs at least one color.")
|
|
106
|
+
_custom[key] = hexed
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cmap(name: str):
|
|
110
|
+
"""Return a matplotlib Colormap for ordered/heatmap data."""
|
|
111
|
+
return colormaps[name] # modern API; cm.get_cmap was removed in mpl 3.9
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def palette(name: str = "okabe-ito", n: int | None = None) -> List[str]:
|
|
115
|
+
"""Return a list of categorical colors.
|
|
116
|
+
|
|
117
|
+
Registered names and qualitative schemes are returned directly; sequential or
|
|
118
|
+
diverging scheme names raise a warning (that's the anti-pattern) and are
|
|
119
|
+
sampled as a fallback.
|
|
120
|
+
"""
|
|
121
|
+
key = name.strip().lower()
|
|
122
|
+
if key in _custom:
|
|
123
|
+
cols = list(_custom[key])
|
|
124
|
+
elif key in ("okabe-ito", "okabe_ito"):
|
|
125
|
+
cols = list(OKABE_ITO)
|
|
126
|
+
elif key == "mpl":
|
|
127
|
+
cols = list(mcolors.TABLEAU_COLORS.values())
|
|
128
|
+
elif key in _BUILTIN:
|
|
129
|
+
cols = list(_BUILTIN[key])
|
|
130
|
+
elif name in _QUALITATIVE:
|
|
131
|
+
# A true qualitative colormap (Set2, Dark2, tab10…): take its discrete swatches.
|
|
132
|
+
c = colormaps[name]
|
|
133
|
+
k = n or getattr(c, "N", 8)
|
|
134
|
+
cols = [mcolors.to_hex(c(i % c.N)) for i in range(k)]
|
|
135
|
+
else:
|
|
136
|
+
# Any continuous map used as a categorical cycle is the anti-pattern —
|
|
137
|
+
# whether it's a known sequential/diverging name or an unlisted one
|
|
138
|
+
# (turbo, jet, hsv…). Warn and sample a *small* even set, never dump c.N.
|
|
139
|
+
try:
|
|
140
|
+
c = colormaps[name]
|
|
141
|
+
except KeyError:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
f"Unknown palette or colormap {name!r}. "
|
|
144
|
+
f"Categorical palettes: {available_palettes()}; "
|
|
145
|
+
f"for ordered data use pp.cmap({name!r})."
|
|
146
|
+
) from None
|
|
147
|
+
warnings.warn(
|
|
148
|
+
f"{name!r} is a continuous colormap; using it as a categorical "
|
|
149
|
+
f"cycle is an anti-pattern (adjacent categories look ordered/"
|
|
150
|
+
f"low-contrast). Use pp.cmap({name!r}) for ordered data, or a "
|
|
151
|
+
f"qualitative palette for categories.",
|
|
152
|
+
stacklevel=2,
|
|
153
|
+
)
|
|
154
|
+
k = n or 6
|
|
155
|
+
cols = [mcolors.to_hex(c(i / max(k - 1, 1))) for i in range(k)]
|
|
156
|
+
|
|
157
|
+
if n is not None:
|
|
158
|
+
cols = [cols[i % len(cols)] for i in range(n)]
|
|
159
|
+
return cols
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def fills(n: int | None = None) -> List[str]:
|
|
163
|
+
"""Muted palette for area / histogram **fills** (pairs with ``strokes()``)."""
|
|
164
|
+
return palette("muted", n)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def strokes(n: int | None = None) -> List[str]:
|
|
168
|
+
"""Bright palette for **strokes** — lines, outlines, reference markers."""
|
|
169
|
+
return palette("bright", n)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def is_colorblind_safe(name: str) -> bool:
|
|
173
|
+
"""Whether a named palette stays distinguishable under color-vision deficiency."""
|
|
174
|
+
return name.strip().lower().replace("_", "-") in _CB_SAFE
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def available_palettes() -> List[str]:
|
|
178
|
+
"""Names of all resolvable categorical palettes (built-in + registered)."""
|
|
179
|
+
names = ["okabe-ito", *_BUILTIN, "mpl"]
|
|
180
|
+
names += [k for k in _custom if k not in names]
|
|
181
|
+
return names
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def resolve_cycle(value) -> List[str]:
|
|
185
|
+
"""Coerce a palette name / color list / None into a list of cycle colors."""
|
|
186
|
+
if value is None:
|
|
187
|
+
return list(OKABE_ITO)
|
|
188
|
+
if isinstance(value, str):
|
|
189
|
+
return palette(value)
|
|
190
|
+
return [mcolors.to_hex(c) for c in value]
|