Add plotstyle: reusable KIT-branded matplotlib styling toolkit
Introduces a new plotstyle package providing consistent, presentation/thesis-ready matplotlib figures: a KIT corporate-design color palette (categorical, sequential, diverging, status), a theme applied via use() (KIT-black bottom spine only, left/bottom ticks, horizontal gridlines, left-aligned titles, LaTeX text in Latin Modern Sans), new_figure() with size presets and figure-level title/params subtitles, a same-size colorbar() helper, style_legend() and panel_label() building blocks, and savefig(). Ships as an optional "plotting" extra so core gallery installs stay lightweight, with a full test suite and a runnable example notebook. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
.vscode
|
.vscode
|
||||||
*.sif
|
*.sif
|
||||||
*.ipynb
|
*.ipynb
|
||||||
|
!examples/*.ipynb
|
||||||
backups
|
backups
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
.venv
|
.venv
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,30 @@
|
|||||||
|
"""Reusable matplotlib styling and building blocks for consistent, modern
|
||||||
|
scientific plots across presentations and thesis figures.
|
||||||
|
|
||||||
|
import plotstyle as ps
|
||||||
|
|
||||||
|
ps.use()
|
||||||
|
fig, ax = ps.new_figure("thesis-single")
|
||||||
|
ax.plot(x, y, label="A")
|
||||||
|
ps.style_legend(ax)
|
||||||
|
ps.savefig(fig, "plots/my_plot", formats=("pdf", "png"))
|
||||||
|
"""
|
||||||
|
|
||||||
|
from . import colors
|
||||||
|
from .annotations import panel_label, style_legend
|
||||||
|
from .colors import get_color
|
||||||
|
from .figures import FIGSIZES, colorbar, new_figure, savefig
|
||||||
|
from .style import reset, use
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"colors",
|
||||||
|
"get_color",
|
||||||
|
"use",
|
||||||
|
"reset",
|
||||||
|
"new_figure",
|
||||||
|
"colorbar",
|
||||||
|
"savefig",
|
||||||
|
"FIGSIZES",
|
||||||
|
"style_legend",
|
||||||
|
"panel_label",
|
||||||
|
]
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Legend and panel-label helpers for consistent multi-panel figures."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
from matplotlib.axes import Axes
|
||||||
|
from matplotlib.colors import to_rgba
|
||||||
|
|
||||||
|
from .colors import INK
|
||||||
|
|
||||||
|
_PANEL_LOCS = {
|
||||||
|
"upper left": (0.02, 0.98, "left", "top"),
|
||||||
|
"upper right": (0.98, 0.98, "right", "top"),
|
||||||
|
"lower left": (0.02, 0.08, "left", "bottom"),
|
||||||
|
"lower right": (0.98, 0.08, "right", "bottom"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def style_legend(
|
||||||
|
ax: Axes,
|
||||||
|
loc: str = "outside right upper",
|
||||||
|
frameon: bool = False,
|
||||||
|
title: str = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Add a figure-level legend, placed outside the axes by default.
|
||||||
|
|
||||||
|
Builds on `ax`'s handles/labels (or explicit `handles=`/`labels=` kwargs)
|
||||||
|
but attaches the legend to `ax`'s figure via `fig.legend(...)`, so it sits
|
||||||
|
outside the plot area rather than overlapping the data.
|
||||||
|
|
||||||
|
A `title` is strongly encouraged: an untitled legend floating outside the
|
||||||
|
axes loses its visual link to what it's describing, e.g.
|
||||||
|
`style_legend(ax, title="Series")`. Without one, this emits a warning
|
||||||
|
rather than failing — the legend still renders.
|
||||||
|
"""
|
||||||
|
if title is None:
|
||||||
|
warnings.warn(
|
||||||
|
"style_legend() called without a title — an outside legend reads better with one, "
|
||||||
|
"e.g. style_legend(ax, title='Series').",
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
fig = ax.get_figure()
|
||||||
|
handles = kwargs.pop("handles", None)
|
||||||
|
labels = kwargs.pop("labels", None)
|
||||||
|
if handles is None or labels is None:
|
||||||
|
handles, labels = ax.get_legend_handles_labels()
|
||||||
|
|
||||||
|
legend = fig.legend(handles, labels, loc=loc, frameon=frameon, title=title, **kwargs)
|
||||||
|
if legend.get_title() is not None:
|
||||||
|
legend.get_title().set_fontweight("bold")
|
||||||
|
return legend
|
||||||
|
|
||||||
|
|
||||||
|
def panel_label(
|
||||||
|
ax: Axes,
|
||||||
|
label: str,
|
||||||
|
loc: str = "lower right",
|
||||||
|
fontweight: str = "bold",
|
||||||
|
box: bool = True,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Add a panel label like "(a)" for multi-panel thesis/paper figures.
|
||||||
|
|
||||||
|
Defaults to the bottom-right corner, colored to match the axes' xlabel,
|
||||||
|
on a light-grey semi-transparent rounded box with a slim solid border.
|
||||||
|
Pass `box=False` for bare text with no box.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
x, y, ha, va = _PANEL_LOCS[loc]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"Unknown panel_label loc {loc!r}. Choose from {sorted(_PANEL_LOCS)}.") from exc
|
||||||
|
|
||||||
|
kwargs.setdefault("color", ax.xaxis.label.get_color())
|
||||||
|
if box:
|
||||||
|
kwargs.setdefault(
|
||||||
|
"bbox",
|
||||||
|
dict(
|
||||||
|
boxstyle="round,pad=0.3",
|
||||||
|
facecolor=to_rgba(INK["gridline"], alpha=0.8),
|
||||||
|
edgecolor=INK["baseline"],
|
||||||
|
linewidth=0.8,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ax.text(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
f"({label})",
|
||||||
|
transform=ax.transAxes,
|
||||||
|
ha=ha,
|
||||||
|
va=va,
|
||||||
|
fontweight=fontweight,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Base rcParams for plotstyle. Loaded via plt.style.use() from style.use().
|
||||||
|
# Colors here mirror plotstyle.colors.INK (KIT black-70% gray family on white) — kept in sync by hand.
|
||||||
|
|
||||||
|
figure.facecolor: ffffff
|
||||||
|
figure.edgecolor: ffffff
|
||||||
|
figure.constrained_layout.use: True
|
||||||
|
|
||||||
|
axes.facecolor: ffffff
|
||||||
|
# axes.edgecolor/linewidth style the one visible spine (bottom — the rest are
|
||||||
|
# off below), so this is really "the bottom spine is black and heavier", not
|
||||||
|
# a general axes outline color.
|
||||||
|
axes.edgecolor: 000000
|
||||||
|
axes.linewidth: 1.25
|
||||||
|
axes.labelcolor: 6a6a6a
|
||||||
|
axes.titlecolor: 404040
|
||||||
|
axes.titleweight: bold
|
||||||
|
axes.titlelocation: left
|
||||||
|
axes.grid: True
|
||||||
|
axes.grid.axis: y
|
||||||
|
axes.axisbelow: True
|
||||||
|
axes.spines.top: False
|
||||||
|
axes.spines.right: False
|
||||||
|
axes.spines.left: False
|
||||||
|
axes.spines.bottom: True
|
||||||
|
|
||||||
|
grid.color: ececec
|
||||||
|
grid.linewidth: 0.8
|
||||||
|
grid.alpha: 1.0
|
||||||
|
|
||||||
|
xtick.color: 969696
|
||||||
|
ytick.color: 969696
|
||||||
|
# Major tick labels read as the primary ("black") ink; minor tick labels are
|
||||||
|
# muted grey. rcParams only expose one labelcolor per axis (no major/minor
|
||||||
|
# split) — the per-major/minor distinction is applied in code by
|
||||||
|
# plotstyle.figures.new_figure() via ax.tick_params(which=...). These values
|
||||||
|
# are just the fallback/default for axes that bypass new_figure().
|
||||||
|
xtick.labelcolor: 404040
|
||||||
|
ytick.labelcolor: 404040
|
||||||
|
xtick.direction: out
|
||||||
|
ytick.direction: out
|
||||||
|
xtick.bottom: True
|
||||||
|
xtick.top: False
|
||||||
|
ytick.left: True
|
||||||
|
ytick.right: False
|
||||||
|
# Minor ticks: x-axis only. The y-axis already has horizontal gridlines at
|
||||||
|
# major ticks, so y minor ticks would just add unlabeled clutter.
|
||||||
|
xtick.minor.visible: True
|
||||||
|
ytick.minor.visible: False
|
||||||
|
xtick.major.size: 6.0
|
||||||
|
xtick.minor.size: 3.0
|
||||||
|
xtick.major.width: 0.8
|
||||||
|
xtick.minor.width: 0.6
|
||||||
|
ytick.major.size: 6.0
|
||||||
|
ytick.minor.size: 3.0
|
||||||
|
ytick.major.width: 0.8
|
||||||
|
ytick.minor.width: 0.6
|
||||||
|
|
||||||
|
lines.linewidth: 2.0
|
||||||
|
lines.markersize: 6.0
|
||||||
|
lines.solid_capstyle: round
|
||||||
|
|
||||||
|
font.family: sans-serif
|
||||||
|
font.sans-serif: DejaVu Sans, Arial, Helvetica, sans-serif
|
||||||
|
font.size: 11
|
||||||
|
axes.titlesize: 13
|
||||||
|
axes.labelsize: 11
|
||||||
|
xtick.labelsize: 10
|
||||||
|
ytick.labelsize: 10
|
||||||
|
legend.fontsize: 10
|
||||||
|
|
||||||
|
legend.frameon: False
|
||||||
|
legend.handlelength: 1.6
|
||||||
|
legend.labelspacing: 0.4
|
||||||
|
legend.title_fontsize: 10
|
||||||
|
|
||||||
|
savefig.facecolor: ffffff
|
||||||
|
savefig.edgecolor: ffffff
|
||||||
|
savefig.dpi: 300
|
||||||
|
savefig.bbox: tight
|
||||||
|
savefig.pad_inches: 0.05
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""KIT (Karlsruhe Institute of Technology) corporate design color palette.
|
||||||
|
|
||||||
|
Hex values for KIT green, KIT blue, black 70%, and the corporate accent
|
||||||
|
colors are taken verbatim from the KIT corporate design guide
|
||||||
|
(https://kit-cd.km.kit.edu/english/341.php) — do not hand-edit them without
|
||||||
|
checking that page.
|
||||||
|
|
||||||
|
The categorical *order* below is not arbitrary: it was chosen by running
|
||||||
|
every hue through the CVD-safety/contrast checks described in the `dataviz`
|
||||||
|
skill (fixed hue order, OKLab CVD separation under simulated color-vision
|
||||||
|
deficiency, a normal-vision separation floor, contrast vs. a white surface)
|
||||||
|
and keeping the ordering that clears the adjacent-pair checks. KIT yellow
|
||||||
|
(#FCE500) is the one hue that cannot pass on its own (too light on a white
|
||||||
|
surface, ~1.3:1 contrast) — that is a property of the hex value itself, not
|
||||||
|
the ordering, so it is placed last and should always be paired with a
|
||||||
|
visible direct label rather than relied on as a fill alone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from matplotlib.colors import LinearSegmentedColormap
|
||||||
|
|
||||||
|
# Fixed-order categorical hues (KIT primary + accent colors). Order is the
|
||||||
|
# CVD-safety mechanism: never reorder, and never cycle past the last slot
|
||||||
|
# (fold extra series into "Other").
|
||||||
|
CATEGORICAL = [
|
||||||
|
"#009682", # 0 KIT green (primary)
|
||||||
|
"#DF9B1B", # 1 orange
|
||||||
|
"#4664AA", # 2 KIT blue (primary)
|
||||||
|
"#A78230", # 3 brown
|
||||||
|
"#23A1E0", # 4 cyan
|
||||||
|
"#A3107C", # 5 purple
|
||||||
|
"#8CB63C", # 6 pea green
|
||||||
|
"#A22223", # 7 red
|
||||||
|
"#FCE500", # 8 yellow — low contrast on white; always pair with a direct label
|
||||||
|
]
|
||||||
|
|
||||||
|
# Single-hue (KIT blue) sequential ramp, steps 100..700. Step 700 is the exact
|
||||||
|
# brand hex (the high/saturated end); lighter steps are tints blended toward
|
||||||
|
# white in sRGB — there is no darker-than-brand shade.
|
||||||
|
SEQUENTIAL_STEPS = [
|
||||||
|
"#e3e8f2", # 100
|
||||||
|
"#c9d2e6", # 200
|
||||||
|
"#afbcda", # 300
|
||||||
|
"#95a6ce", # 400
|
||||||
|
"#7a90c2", # 500
|
||||||
|
"#607ab6", # 600
|
||||||
|
"#4664AA", # 700 (KIT blue)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Diverging KIT blue <-> KIT red, neutral gray midpoint.
|
||||||
|
DIVERGING = {
|
||||||
|
"low": "#4664AA",
|
||||||
|
"mid": "#f7f7f7",
|
||||||
|
"high": "#A22223",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fixed, reserved status scale — deliberately NOT re-themed to KIT colors:
|
||||||
|
# status is a small fixed scale with reserved meaning that must stay visually
|
||||||
|
# distinct from the categorical slots so it never impersonates a series.
|
||||||
|
# Never put these in the categorical cycle; always pair with an icon/label.
|
||||||
|
STATUS = {
|
||||||
|
"good": "#0ca30c",
|
||||||
|
"warning": "#fab219",
|
||||||
|
"serious": "#ec835a",
|
||||||
|
"critical": "#d03b3b",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Chrome / ink roles, derived from KIT's specified "black 70%" (#404040,
|
||||||
|
# used by KIT for headings and continuous text) on a white surface.
|
||||||
|
INK = {
|
||||||
|
"surface": "#ffffff",
|
||||||
|
"primary": "#404040", # KIT black 70% — headings, titles, continuous text
|
||||||
|
"secondary": "#6a6a6a",
|
||||||
|
"muted": "#969696",
|
||||||
|
"gridline": "#ececec",
|
||||||
|
"baseline": "#c2c2c2",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_color(index: int) -> str:
|
||||||
|
"""Return the categorical color for series `index` (0-based).
|
||||||
|
|
||||||
|
Raises ValueError past the validated slots instead of silently wrapping
|
||||||
|
back to slot 0, which would collide two series on the same hue.
|
||||||
|
"""
|
||||||
|
if not 0 <= index < len(CATEGORICAL):
|
||||||
|
raise ValueError(
|
||||||
|
f"get_color({index}) out of range: only {len(CATEGORICAL)} validated categorical "
|
||||||
|
"colors exist. Fold extra series into an 'Other' bucket or facet instead of cycling."
|
||||||
|
)
|
||||||
|
return CATEGORICAL[index]
|
||||||
|
|
||||||
|
|
||||||
|
def sequential_cmap(name: str = "ps_sequential") -> LinearSegmentedColormap:
|
||||||
|
"""Continuous KIT-blue sequential colormap for magnitude encoding."""
|
||||||
|
return LinearSegmentedColormap.from_list(name, SEQUENTIAL_STEPS)
|
||||||
|
|
||||||
|
|
||||||
|
def diverging_cmap(name: str = "ps_diverging") -> LinearSegmentedColormap:
|
||||||
|
"""Continuous KIT blue-gray-red diverging colormap for polarity encoding."""
|
||||||
|
return LinearSegmentedColormap.from_list(name, [DIVERGING["low"], DIVERGING["mid"], DIVERGING["high"]])
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Figure creation, colorbar, and saving helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Mapping, Sequence, Union
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from matplotlib.axes import Axes
|
||||||
|
from matplotlib.colorbar import Colorbar
|
||||||
|
from matplotlib.figure import Figure
|
||||||
|
from mpl_toolkits.axes_grid1 import make_axes_locatable
|
||||||
|
|
||||||
|
from .colors import INK
|
||||||
|
|
||||||
|
FIGSIZES = {
|
||||||
|
"thesis-single": (6.0, 4.0),
|
||||||
|
"thesis-wide": (8.0, 4.5),
|
||||||
|
"slide-16x9": (10.0, 5.625),
|
||||||
|
"square": (5.0, 5.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _style_ticks(ax: Axes) -> None:
|
||||||
|
"""Major tick labels read as primary ("black") ink; minor as muted grey.
|
||||||
|
|
||||||
|
rcParams only expose a single labelcolor per axis (no major/minor split),
|
||||||
|
so this distinction has to be applied per-Axes in code.
|
||||||
|
"""
|
||||||
|
ax.tick_params(axis="both", which="major", labelcolor=INK["primary"])
|
||||||
|
ax.tick_params(axis="both", which="minor", labelcolor=INK["muted"])
|
||||||
|
|
||||||
|
|
||||||
|
def _format_params(params: Mapping) -> str:
|
||||||
|
return " | ".join(f"{key}: {value}" for key, value in params.items())
|
||||||
|
|
||||||
|
|
||||||
|
def _set_figure_title(fig: Figure, title: Union[str, None], params: Union[Mapping, None]) -> None:
|
||||||
|
# A single Text artist gets one color under matplotlib's usetex rendering
|
||||||
|
# (dvipng rasterizes it as one greyscale mask, tinted uniformly — any
|
||||||
|
# in-source \color/\textcolor is ignored), so the subtitle is set apart
|
||||||
|
# from the title by size (\small) only, not color.
|
||||||
|
lines = []
|
||||||
|
if title is not None:
|
||||||
|
lines.append(r"\textbf{" + title + "}")
|
||||||
|
if params:
|
||||||
|
lines.append(r"{\small " + _format_params(params) + "}")
|
||||||
|
if lines:
|
||||||
|
fig.suptitle("\n".join(lines), x=0.0, ha="left")
|
||||||
|
|
||||||
|
|
||||||
|
def new_figure(
|
||||||
|
preset: Union[str, tuple] = "thesis-single",
|
||||||
|
*,
|
||||||
|
title: str = None,
|
||||||
|
params: Mapping = None,
|
||||||
|
**subplots_kwargs,
|
||||||
|
):
|
||||||
|
"""Create a figure/axes pair sized for a named preset or an explicit (w, h) tuple.
|
||||||
|
|
||||||
|
Presets (inches): thesis-single, thesis-wide, slide-16x9, square.
|
||||||
|
|
||||||
|
`title` sets a left-aligned, bold figure-level title (`fig.suptitle`) —
|
||||||
|
this is preferred over an axes title even when there's a single axes, so
|
||||||
|
it stays consistent for single- and multi-panel figures alike. `params`
|
||||||
|
is an optional dict rendered as a smaller, muted subtitle line below the
|
||||||
|
title, formatted as "key1: value1 | key2: value2 | ...".
|
||||||
|
"""
|
||||||
|
if isinstance(preset, str):
|
||||||
|
try:
|
||||||
|
figsize = FIGSIZES[preset]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown figure preset {preset!r}. Choose from {sorted(FIGSIZES)} or pass an (w, h) tuple."
|
||||||
|
) from exc
|
||||||
|
else:
|
||||||
|
figsize = preset
|
||||||
|
|
||||||
|
subplots_kwargs.setdefault("figsize", figsize)
|
||||||
|
fig, axes = plt.subplots(**subplots_kwargs)
|
||||||
|
|
||||||
|
for ax in [axes] if isinstance(axes, Axes) else np.ravel(axes):
|
||||||
|
_style_ticks(ax)
|
||||||
|
|
||||||
|
_set_figure_title(fig, title, params)
|
||||||
|
|
||||||
|
return fig, axes
|
||||||
|
|
||||||
|
|
||||||
|
def colorbar(mappable, ax: Axes, size: str = "5%", pad: float = 0.05, **kwargs) -> Colorbar:
|
||||||
|
"""Add a colorbar matched to `ax`'s actual on-screen size.
|
||||||
|
|
||||||
|
`fig.colorbar(mappable, ax=ax)` sizes the colorbar to the axes' nominal
|
||||||
|
bounding box, which is taller than the axes once things like
|
||||||
|
`ax.set_aspect("equal")` have visually shrunk it (e.g. a non-square
|
||||||
|
`imshow`). This appends a same-size axes via `make_axes_locatable`
|
||||||
|
instead, so the colorbar always matches what's actually drawn.
|
||||||
|
"""
|
||||||
|
divider = make_axes_locatable(ax)
|
||||||
|
cax = divider.append_axes("right", size=size, pad=pad)
|
||||||
|
cb = ax.get_figure().colorbar(mappable, cax=cax, **kwargs)
|
||||||
|
# Colorbar draws its own border (a dedicated "outline" spine) that isn't
|
||||||
|
# covered by axes.spines.{left,right,top} — without this it'd pick up
|
||||||
|
# the bold black bottom-spine color/width from the main theme as a box
|
||||||
|
# around the whole colorbar, which reads as a stray, heavier-than-intended
|
||||||
|
# edge rather than the plain baseline it's styled to be elsewhere.
|
||||||
|
cb.outline.set_visible(False)
|
||||||
|
return cb
|
||||||
|
|
||||||
|
|
||||||
|
def savefig(fig: Figure, path: Union[str, Path], formats: Sequence[str] = ("pdf",), dpi: int = 300) -> list[Path]:
|
||||||
|
"""Save `fig` to `path` once per format, creating parent directories as needed.
|
||||||
|
|
||||||
|
`path` should have no extension — it's appended per format, e.g.
|
||||||
|
savefig(fig, "plots/my_plot", formats=("pdf", "png")) writes
|
||||||
|
plots/my_plot.pdf and plots/my_plot.png.
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
written = []
|
||||||
|
for fmt in formats:
|
||||||
|
out_path = path.with_suffix(f".{fmt}")
|
||||||
|
fig.savefig(out_path, format=fmt, dpi=dpi)
|
||||||
|
written.append(out_path)
|
||||||
|
return written
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Apply the plotstyle rcParams theme.
|
||||||
|
|
||||||
|
Text (titles, axis labels, legends, tick labels, annotations — the entirety
|
||||||
|
of every string) is always rendered through a real LaTeX toolchain, using
|
||||||
|
Latin Modern Sans as a modern sans-serif LaTeX font (including math mode, via
|
||||||
|
`sfmath` — otherwise tick numbers fall back to a serif math font even with
|
||||||
|
`\\familydefault` set to sans). `fontenc`'s T1 encoding is loaded too — without
|
||||||
|
it, some plain ASCII characters (e.g. "|") render as the wrong glyph under
|
||||||
|
OT1, LaTeX's default. This requires a working local `latex`/`dvipng` install;
|
||||||
|
there is no mathtext fallback.
|
||||||
|
|
||||||
|
Note: matplotlib's usetex rendering rasterizes each Text artist as a single
|
||||||
|
greyscale glyph mask via dvipng and then tints the *whole* thing with that
|
||||||
|
artist's one `color` — any in-source `\\color`/`\\textcolor` command is
|
||||||
|
ignored. So two differently-colored spans (e.g. a black title next to a grey
|
||||||
|
subtitle) can't live in one Text object; `figures.new_figure()`'s title/params
|
||||||
|
handling only varies font size (`\\small`) between lines, not color, for
|
||||||
|
exactly this reason.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from cycler import cycler
|
||||||
|
from importlib import resources
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
from .colors import CATEGORICAL
|
||||||
|
|
||||||
|
_LINESTYLES = ["-", "--", "-.", ":"]
|
||||||
|
|
||||||
|
_LATEX_PREAMBLE = (
|
||||||
|
r"\usepackage[T1]{fontenc}\usepackage{amsmath}\usepackage{lmodern}\usepackage{sfmath}"
|
||||||
|
r"\renewcommand{\familydefault}{\sfdefault}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def use(cycle_linestyles: bool = False) -> None:
|
||||||
|
"""Apply the plotstyle theme to matplotlib's global rcParams.
|
||||||
|
|
||||||
|
Call once at the top of a plotting script, before creating any figures.
|
||||||
|
|
||||||
|
By default `axes.prop_cycle` only cycles color (all lines solid) — pass
|
||||||
|
`cycle_linestyles=True` to also cycle through a repeating linestyle
|
||||||
|
sequence, so series stay distinguishable even if color is lost
|
||||||
|
(grayscale printing, projector glare, color-vision deficiency).
|
||||||
|
"""
|
||||||
|
style_path = resources.files("plotstyle").joinpath("assets", "plotstyle.mplstyle")
|
||||||
|
plt.style.use(str(style_path))
|
||||||
|
|
||||||
|
if cycle_linestyles:
|
||||||
|
n = len(CATEGORICAL)
|
||||||
|
linestyles = (_LINESTYLES * (n // len(_LINESTYLES) + 1))[:n]
|
||||||
|
plt.rcParams["axes.prop_cycle"] = cycler(color=CATEGORICAL) + cycler(linestyle=linestyles)
|
||||||
|
else:
|
||||||
|
plt.rcParams["axes.prop_cycle"] = cycler(color=CATEGORICAL)
|
||||||
|
|
||||||
|
plt.rcParams["text.usetex"] = True
|
||||||
|
plt.rcParams["text.latex.preamble"] = _LATEX_PREAMBLE
|
||||||
|
|
||||||
|
|
||||||
|
def reset() -> None:
|
||||||
|
"""Restore matplotlib defaults (useful between tests/notebook cells)."""
|
||||||
|
plt.rcdefaults()
|
||||||
+5
-2
@@ -50,13 +50,16 @@ dev = [
|
|||||||
"pylint>=2.0",
|
"pylint>=2.0",
|
||||||
"mypy>=0.900",
|
"mypy>=0.900",
|
||||||
]
|
]
|
||||||
|
plotting = [
|
||||||
|
"matplotlib>=3.7",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
gallery = "gallery.cli:main"
|
gallery = "gallery.cli:main"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
packages = ["gallery", "gallery.utils", "gallery.config"]
|
packages = ["gallery", "gallery.utils", "gallery.config", "plotstyle"]
|
||||||
package-data = {gallery = ["templates/*", "assets/css/*", "assets/js/*", "config/*"]}
|
package-data = {gallery = ["templates/*", "assets/css/*", "assets/js/*", "config/*"], plotstyle = ["assets/*.mplstyle"]}
|
||||||
include-package-data = true
|
include-package-data = true
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import pytest
|
||||||
|
from matplotlib.colors import Colormap
|
||||||
|
|
||||||
|
import plotstyle as ps
|
||||||
|
from plotstyle import colors
|
||||||
|
|
||||||
|
|
||||||
|
def test_categorical_palette_matches_kit_hex():
|
||||||
|
assert colors.CATEGORICAL == [
|
||||||
|
"#009682",
|
||||||
|
"#DF9B1B",
|
||||||
|
"#4664AA",
|
||||||
|
"#A78230",
|
||||||
|
"#23A1E0",
|
||||||
|
"#A3107C",
|
||||||
|
"#8CB63C",
|
||||||
|
"#A22223",
|
||||||
|
"#FCE500",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_diverging_palette_matches_kit_hex():
|
||||||
|
assert colors.DIVERGING == {"low": "#4664AA", "mid": "#f7f7f7", "high": "#A22223"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_palette_matches_validated_hex():
|
||||||
|
assert colors.STATUS == {
|
||||||
|
"good": "#0ca30c",
|
||||||
|
"warning": "#fab219",
|
||||||
|
"serious": "#ec835a",
|
||||||
|
"critical": "#d03b3b",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_color_returns_categorical_slot():
|
||||||
|
assert colors.get_color(0) == colors.CATEGORICAL[0]
|
||||||
|
assert colors.get_color(len(colors.CATEGORICAL) - 1) == colors.CATEGORICAL[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_color_out_of_range_raises():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
colors.get_color(len(colors.CATEGORICAL))
|
||||||
|
|
||||||
|
|
||||||
|
def test_sequential_steps_are_kit_blue_variations():
|
||||||
|
assert colors.SEQUENTIAL_STEPS[-1].lower() == "#4664aa"
|
||||||
|
# Lightest step should be a lighter (higher-lightness) tint than the brand color.
|
||||||
|
import colorsys
|
||||||
|
|
||||||
|
def lightness(hex_color):
|
||||||
|
r, g, b = (int(hex_color.lstrip("#")[i : i + 2], 16) / 255 for i in (0, 2, 4))
|
||||||
|
return colorsys.rgb_to_hls(r, g, b)[1]
|
||||||
|
|
||||||
|
assert lightness(colors.SEQUENTIAL_STEPS[0]) > lightness(colors.SEQUENTIAL_STEPS[-1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_sequential_cmap_is_usable_colormap():
|
||||||
|
cmap = colors.sequential_cmap()
|
||||||
|
assert isinstance(cmap, Colormap)
|
||||||
|
assert cmap(0.0) != cmap(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_diverging_cmap_is_usable_colormap():
|
||||||
|
cmap = colors.diverging_cmap()
|
||||||
|
assert isinstance(cmap, Colormap)
|
||||||
|
assert cmap(0.0) != cmap(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_use_sets_expected_rcparams():
|
||||||
|
ps.use()
|
||||||
|
try:
|
||||||
|
assert plt.rcParams["axes.facecolor"] == "#ffffff"
|
||||||
|
assert plt.rcParams["text.usetex"] is True
|
||||||
|
assert "lmodern" in plt.rcParams["text.latex.preamble"]
|
||||||
|
assert "sfmath" in plt.rcParams["text.latex.preamble"]
|
||||||
|
assert "fontenc" in plt.rcParams["text.latex.preamble"]
|
||||||
|
assert r"\sfdefault" in plt.rcParams["text.latex.preamble"]
|
||||||
|
cycle_colors = [entry["color"] for entry in plt.rcParams["axes.prop_cycle"]]
|
||||||
|
assert cycle_colors == colors.CATEGORICAL
|
||||||
|
|
||||||
|
assert plt.rcParams["axes.spines.top"] is False
|
||||||
|
assert plt.rcParams["axes.spines.right"] is False
|
||||||
|
assert plt.rcParams["axes.spines.left"] is False
|
||||||
|
assert plt.rcParams["axes.spines.bottom"] is True
|
||||||
|
assert plt.rcParams["xtick.bottom"] is True
|
||||||
|
assert plt.rcParams["ytick.left"] is True
|
||||||
|
assert plt.rcParams["axes.edgecolor"] == "#000000"
|
||||||
|
assert plt.rcParams["axes.linewidth"] > 0.8
|
||||||
|
|
||||||
|
assert plt.rcParams["axes.grid"] is True
|
||||||
|
assert plt.rcParams["axes.grid.axis"] == "y"
|
||||||
|
assert plt.rcParams["axes.titlelocation"] == "left"
|
||||||
|
|
||||||
|
assert plt.rcParams["xtick.minor.visible"] is True
|
||||||
|
assert plt.rcParams["ytick.minor.visible"] is False
|
||||||
|
assert plt.rcParams["xtick.major.size"] > plt.rcParams["xtick.minor.size"]
|
||||||
|
finally:
|
||||||
|
ps.reset()
|
||||||
|
|
||||||
|
|
||||||
|
def test_use_default_does_not_cycle_linestyles():
|
||||||
|
ps.use()
|
||||||
|
try:
|
||||||
|
cycle_keys = plt.rcParams["axes.prop_cycle"].keys
|
||||||
|
assert cycle_keys == {"color"}
|
||||||
|
finally:
|
||||||
|
ps.reset()
|
||||||
|
|
||||||
|
|
||||||
|
def test_use_cycle_linestyles_opt_in():
|
||||||
|
ps.use(cycle_linestyles=True)
|
||||||
|
try:
|
||||||
|
cycle_keys = plt.rcParams["axes.prop_cycle"].keys
|
||||||
|
assert cycle_keys == {"color", "linestyle"}
|
||||||
|
linestyles = {entry["linestyle"] for entry in plt.rcParams["axes.prop_cycle"]}
|
||||||
|
assert len(linestyles) > 1
|
||||||
|
finally:
|
||||||
|
ps.reset()
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_figure_styles_major_and_minor_tick_labels_differently():
|
||||||
|
fig, ax = ps.new_figure("square")
|
||||||
|
try:
|
||||||
|
assert ax.xaxis.get_tick_params(which="major")["labelcolor"] == colors.INK["primary"]
|
||||||
|
assert ax.xaxis.get_tick_params(which="minor")["labelcolor"] == colors.INK["muted"]
|
||||||
|
assert ax.yaxis.get_tick_params(which="major")["labelcolor"] == colors.INK["primary"]
|
||||||
|
assert ax.yaxis.get_tick_params(which="minor")["labelcolor"] == colors.INK["muted"]
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_figure_preset_sizes():
|
||||||
|
fig, ax = ps.new_figure("thesis-single")
|
||||||
|
try:
|
||||||
|
assert tuple(fig.get_size_inches()) == ps.FIGSIZES["thesis-single"]
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_figure_explicit_tuple():
|
||||||
|
fig, ax = ps.new_figure((3.0, 2.0))
|
||||||
|
try:
|
||||||
|
assert tuple(fig.get_size_inches()) == (3.0, 2.0)
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_figure_unknown_preset_raises():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ps.new_figure("not-a-real-preset")
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_figure_title_sets_left_aligned_figure_suptitle():
|
||||||
|
fig, ax = ps.new_figure("square", title="My Title")
|
||||||
|
try:
|
||||||
|
assert fig._suptitle is not None
|
||||||
|
assert "My Title" in fig._suptitle.get_text()
|
||||||
|
assert fig._suptitle.get_ha() == "left"
|
||||||
|
assert ax.get_title() == ""
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_figure_params_adds_subtitle_line():
|
||||||
|
fig, ax = ps.new_figure("square", title="My Title", params={"N": 100, "seed": 42})
|
||||||
|
try:
|
||||||
|
text = fig._suptitle.get_text()
|
||||||
|
assert "My Title" in text
|
||||||
|
assert "N: 100" in text
|
||||||
|
assert "seed: 42" in text
|
||||||
|
assert "|" in text
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_figure_without_title_or_params_has_no_suptitle():
|
||||||
|
fig, ax = ps.new_figure("square")
|
||||||
|
try:
|
||||||
|
assert fig._suptitle is None
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_colorbar_matches_axes_height():
|
||||||
|
fig, ax = ps.new_figure("square")
|
||||||
|
try:
|
||||||
|
im = ax.imshow([[0, 1], [2, 3]])
|
||||||
|
cb = ps.colorbar(im, ax)
|
||||||
|
fig.canvas.draw()
|
||||||
|
ax_bbox = ax.get_position()
|
||||||
|
cax_bbox = cb.ax.get_position()
|
||||||
|
assert ax_bbox.y0 == pytest.approx(cax_bbox.y0, abs=1e-6)
|
||||||
|
assert ax_bbox.y1 == pytest.approx(cax_bbox.y1, abs=1e-6)
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_colorbar_has_no_outline():
|
||||||
|
fig, ax = ps.new_figure("square")
|
||||||
|
try:
|
||||||
|
im = ax.imshow([[0, 1], [2, 3]])
|
||||||
|
cb = ps.colorbar(im, ax)
|
||||||
|
assert cb.outline.get_visible() is False
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_savefig_writes_requested_formats(tmp_path):
|
||||||
|
fig, ax = ps.new_figure("square")
|
||||||
|
ax.plot([0, 1], [0, 1])
|
||||||
|
try:
|
||||||
|
out_dir = tmp_path / "nested" / "plots"
|
||||||
|
written = ps.savefig(fig, out_dir / "my_plot", formats=("pdf", "png"))
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
assert [p.name for p in written] == ["my_plot.pdf", "my_plot.png"]
|
||||||
|
for p in written:
|
||||||
|
assert p.exists()
|
||||||
|
assert p.stat().st_size > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_legend_places_legend_on_figure_outside_axes():
|
||||||
|
fig, ax = ps.new_figure("square")
|
||||||
|
try:
|
||||||
|
ax.plot([0, 1], [0, 1], label="series")
|
||||||
|
legend = ps.style_legend(ax, title="Series")
|
||||||
|
assert legend in fig.legends
|
||||||
|
assert ax.get_legend() is None
|
||||||
|
assert legend.get_title().get_text() == "Series"
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_style_legend_without_title_warns():
|
||||||
|
fig, ax = ps.new_figure("square")
|
||||||
|
try:
|
||||||
|
ax.plot([0, 1], [0, 1], label="series")
|
||||||
|
with pytest.warns(UserWarning):
|
||||||
|
ps.style_legend(ax)
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_label_does_not_raise():
|
||||||
|
fig, ax = ps.new_figure("square")
|
||||||
|
try:
|
||||||
|
ax.plot([0, 1], [0, 1], label="series")
|
||||||
|
ps.panel_label(ax, "a")
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_label_unknown_loc_raises():
|
||||||
|
fig, ax = ps.new_figure("square")
|
||||||
|
try:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ps.panel_label(ax, "a", loc="middle")
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_label_defaults_to_lower_right():
|
||||||
|
fig, ax = ps.new_figure("square")
|
||||||
|
try:
|
||||||
|
text = ps.panel_label(ax, "a")
|
||||||
|
assert text.get_ha() == "right"
|
||||||
|
assert text.get_va() == "bottom"
|
||||||
|
x, y = text.get_position()
|
||||||
|
assert x > 0.5
|
||||||
|
assert y < 0.5
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_label_color_matches_xlabel():
|
||||||
|
fig, ax = ps.new_figure("square")
|
||||||
|
try:
|
||||||
|
ax.set_xlabel("Time (s)")
|
||||||
|
text = ps.panel_label(ax, "a")
|
||||||
|
assert text.get_color() == ax.xaxis.label.get_color()
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_label_has_rounded_semi_transparent_box_by_default():
|
||||||
|
fig, ax = ps.new_figure("square")
|
||||||
|
try:
|
||||||
|
text = ps.panel_label(ax, "a")
|
||||||
|
patch = text.get_bbox_patch()
|
||||||
|
assert patch is not None
|
||||||
|
assert "round" in patch.get_boxstyle().__class__.__name__.lower()
|
||||||
|
assert patch.get_facecolor()[3] < 1.0
|
||||||
|
assert patch.get_edgecolor()[3] == 1.0
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_label_box_can_be_disabled():
|
||||||
|
fig, ax = ps.new_figure("square")
|
||||||
|
try:
|
||||||
|
text = ps.panel_label(ax, "a", box=False)
|
||||||
|
assert text.get_bbox_patch() is None
|
||||||
|
finally:
|
||||||
|
plt.close(fig)
|
||||||
Reference in New Issue
Block a user