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:
2026-07-22 14:19:30 +02:00
parent 8cae6533d5
commit 36ae18e880
11 changed files with 3164 additions and 5 deletions
+30
View File
@@ -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",
]
+97
View File
@@ -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,
)
+80
View File
@@ -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
+102
View File
@@ -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"]])
+127
View File
@@ -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
+64
View File
@@ -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()