Add ps.no_spines() for imshow/pcolormesh/2D-histogram plots

Image- and bin-indexed plots have no meaningful x baseline, so the
themed bottom spine kept by use() doesn't apply to them. Adds an
explicit opt-in to disable all spines on such Axes, documents it in
plotstyle/CLAUDE.md, and updates the showcase notebook's colormap and
multi-panel examples to use it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 14:43:41 +02:00
parent 36ae18e880
commit eeb483a4fa
5 changed files with 506 additions and 32 deletions
File diff suppressed because one or more lines are too long
+317
View File
@@ -0,0 +1,317 @@
# CLAUDE.md — `plotstyle`
Guidance for Claude Code (or any coding agent) writing or editing scripts that
use `plotstyle`. This package is a **standalone matplotlib styling toolkit**,
decoupled from the `gallery/` package in this repo — `gallery/` never imports
it. The connection between the two is a file on disk: `plotstyle` produces
PDF figures, and `gallery` (elsewhere in this repo) turns a directory of PDFs
into an HTML gallery. See the bottom of this file for that handoff.
## What it is
A KIT (Karlsruhe Institute of Technology) corporate-design matplotlib theme
plus a handful of building-block functions, so every figure produced for a
thesis chapter or a talk slide looks consistent — validated color palette,
consistent spines/ticks/grid, LaTeX text in a modern sans font, figure
titles with a parameters subtitle, and legend/panel-label helpers.
**Read `examples/plotstyle_showcase.ipynb` (repo root) for a fully rendered,
end-to-end tour** — it's the fastest way to see what every function actually
produces. Everything below is the reference; the notebook is the demo.
## Install
```bash
uv sync --extra plotting # this repo's uv workflow
# or
pip install -e ".[plotting]"
```
`plotstyle` is an optional extra (`matplotlib>=3.7`) so core `gallery`
installs stay lightweight — don't add matplotlib to `gallery`'s own
unconditional dependencies to support this package.
**Hard requirement: a working local LaTeX toolchain (`latex` + `dvipng`).**
`plotstyle.use()` sets `text.usetex = True` unconditionally — there is no
mathtext fallback. If a script using `plotstyle` needs to run somewhere LaTeX
isn't installed, that's a real environment gap to flag, not something to
silently work around in `plotstyle` itself (that decision was made
deliberately across several iterations of this package — don't reintroduce a
fallback without being asked).
## Quick start
```python
import numpy as np
import plotstyle as ps
ps.use() # once, before creating any figure
fig, ax = ps.new_figure(
"thesis-single",
title="Measured signal",
params={"N": 512, "sigma": 1.2, "seed": 42},
)
ax.plot(np.linspace(0, 10, 200), np.sin(np.linspace(0, 10, 200)), label="signal")
ax.set_xlabel("Time (s)")
ax.set_ylabel(r"Amplitude $A(t)$")
ps.style_legend(ax, title="Series")
ps.savefig(fig, "plots/measured_signal", formats=("pdf",))
```
## API reference
Everything is re-exported at the top level (`import plotstyle as ps`); the
submodule layout (`style.py`, `colors.py`, `figures.py`, `annotations.py`) is
an implementation detail, not part of the interface agents should reach into.
### `ps.use(cycle_linestyles: bool = False)`
Applies the theme to matplotlib's global `rcParams`. **Call this once, near
the top of the script, before creating any figure.** Sets:
- Only the bottom spine visible, colored black and heavier than default
(`axes.edgecolor`/`axes.linewidth`) — left/top/right spines off.
- Ticks on left + bottom only; x-axis gets shorter minor ticks between the
major ones (y-axis doesn't — its horizontal gridlines already mark
position). Major tick labels read as dark ink, minor tick labels lighter
grey.
- Horizontal-only, light-grey gridlines.
- Left-aligned axes titles (`axes.titlelocation: left`).
- `axes.prop_cycle` = the 9 KIT categorical colors, in fixed order. Color
only by default — pass `cycle_linestyles=True` to also cycle linestyle
(solid/dashed/dash-dot/dotted), which matters if a figure might be printed
in grayscale or viewed by someone with color-vision deficiency.
- `text.usetex = True` with Latin Modern Sans (`lmodern` + `sfmath` so
*math-mode* text is sans too, not just body text; `fontenc` T1 so plain
ASCII like `|` doesn't render as the wrong glyph under LaTeX's OT1
default).
`ps.reset()` restores matplotlib defaults (`plt.rcdefaults()`) — useful
between notebook cells or in tests, not normally needed in a script.
### `ps.new_figure(preset="thesis-single", *, title=None, params=None, **subplots_kwargs)`
Thin wrapper over `plt.subplots()`. Returns `(fig, ax)` or `(fig, axes)`
exactly like `plt.subplots()``**subplots_kwargs` (`nrows`, `ncols`,
`sharex`, ...) pass straight through.
- `preset`: one of `ps.FIGSIZES``"thesis-single"` (6×4"), `"thesis-wide"`
(8×4.5"), `"slide-16x9"` (10×5.625"), `"square"` (5×5"). Or pass an
explicit `(w, h)` tuple in inches to bypass the presets.
- `title`: sets a **left-aligned, bold figure-level title** via
`fig.suptitle`. **Prefer this over `ax.set_title()` for a single-axes
figure** — it's the recommended, consistent way to title a plot in this
codebase. Reserve `ax.set_title()` for multi-axes figures, where each
panel needs its own title and no single figure title could cover all of
them (see the multi-panel example in the notebook).
- `params`: an optional dict rendered as a smaller subtitle line under the
title: `key1: value1 | key2: value2 | ...`. Good for recording the run
parameters that produced a plot (`params={"N": 512, "seed": 42}`). Note it
is *not* colored differently from the title (see "Known limitation"
below) — only smaller.
### `ps.colorbar(mappable, ax, size="5%", pad=0.05, **kwargs)`
Use this **instead of** `fig.colorbar(im, ax=ax)` whenever `ax` has
`set_aspect("equal")` (or anything else that visually shrinks it) — plain
`fig.colorbar` sizes to the axes' nominal bounding box and ends up taller
than what's actually drawn. This appends a matching-size axes via
`mpl_toolkits.axes_grid1.make_axes_locatable` and also turns off the
colorbar's own border (`cb.outline.set_visible(False)`), which otherwise
independently picks up the bold black spine styling as a stray box around
the colorbar.
### `ps.no_spines(ax)`
Hides all four spines on `ax` (or every Axes in an array, e.g. from
`new_figure(nrows=..., ncols=...)`). `use()` keeps only the bottom spine
visible by default, since most plots have a meaningful x baseline — but
pixel/bin-indexed plots (`imshow`, `pcolormesh`, 2D histograms) don't have
one, so the themed bottom spine implies an axis origin that doesn't mean
anything there. Call this on the Axes for that kind of plot instead of
leaving the bottom spine on or hand-rolling
`ax.spines[...].set_visible(False)`:
```python
im = ax.imshow(image_data)
ps.no_spines(ax)
ps.colorbar(im, ax, label="Intensity")
```
### `ps.style_legend(ax, loc="outside right upper", frameon=False, title=None, **kwargs)`
Builds a legend from `ax`'s handles/labels but attaches it to the **figure**
(`fig.legend(...)`), so it always sits outside the axes rather than
overlapping data. Pass `title=` — strongly encouraged; omitting it prints a
`UserWarning` (the legend still renders, so this won't break a script, but
an agent generating new plots should always pass one).
### `ps.panel_label(ax, label, loc="lower right", fontweight="bold", box=True, **kwargs)`
Adds a `(a)`/`(b)`/… label for multi-panel figures. Defaults to the
bottom-right corner (nudged up from the very edge so it clears the x-axis),
colored to match `ax`'s xlabel, on a light-grey semi-transparent rounded box
with a slim solid border. Pass `box=False` for bare text. Don't hand-roll
this with `ax.text(...)` — use the helper so every panel label in a figure
(and across figures) looks the same.
### `ps.savefig(fig, path, formats=("pdf",), dpi=300)`
Writes one file per format (`path` has no extension; each format is
appended). **Default to `formats=("pdf",)`** — see "Combining with
`gallery`" below for why PDF is what you almost always want here. Creates
parent directories automatically.
### `ps.get_color(i)` / `ps.colors`
`ps.get_color(i)` indexes the 9-color categorical palette (0-based) and
raises `ValueError` past the last slot — **never** wrap/cycle back to 0
yourself past index 8; fold extra series into an "Other" bucket or facet
instead. Prefer relying on the default `prop_cycle` (i.e. just call
`ax.plot(...)` repeatedly without specifying `color=`) over calling
`get_color()` explicitly, unless you need a specific slot out of order (e.g.
matching a color used elsewhere in the same figure).
`ps.colors` also exposes, if you need direct access:
- `CATEGORICAL` — the 9 hex strings, in order.
- `sequential_cmap()` — continuous KIT-blue colormap (light tint → brand
blue) for magnitude/heatmap data.
- `diverging_cmap()` — KIT blue ↔ KIT red through a neutral grey midpoint,
for signed data. Always pass symmetric `vmin`/`vmax` around the data's true
zero when using it.
- `STATUS` — fixed `good`/`warning`/`serious`/`critical` colors. **Never**
put these in a categorical series cycle; only use them for actual
good/bad-style status encoding, always paired with a label.
- `INK` — the grey/text roles (`primary`, `secondary`, `muted`, `gridline`,
`baseline`, `surface`) the theme itself is built from.
## Best practices (for agents writing or reviewing plot scripts)
1. **Call `ps.use()` once, before any figure is created.** Don't call it
again mid-script unless deliberately toggling `cycle_linestyles` back and
forth (rare — only useful when a notebook wants to show both modes).
2. **Prefer `new_figure(title=..., params=...)` over `ax.set_title()`** for
any single-axes figure. Use `ax.set_title()` only per-panel in multi-axes
figures.
3. **Never use literal `#` or `%` in any text passed to matplotlib** (titles,
labels, legend entries, annotations) while `plotstyle` is active — usetex
is always on, and those are LaTeX special characters that will break
rendering with a `RuntimeError` from `latex`. Rephrase instead of
escaping where possible (e.g. a hex color used as a *label* should be
spelled without its `#`; a percentage should read "42 percent" or use an
escaped `\%` if you specifically need the glyph).
4. **Don't hand-style spines/ticks/grid/legend/panel-labels manually**
that's what `use()`, `style_legend()`, and `panel_label()` are for. If an
agent finds itself writing `ax.spines[...].set_visible(...)` or similar
in a script that already calls `ps.use()`, that's very likely fighting
the theme rather than working with it — stop and reconsider. The one
sanctioned exception is `ps.no_spines(ax)` on `imshow`/`pcolormesh`/2D
histogram Axes, where the themed bottom spine implies a baseline that
doesn't exist for pixel/bin data.
5. **Default `savefig(..., formats=("pdf",))`.** Only add `"png"`/`"svg"` if
there's a concrete reason (e.g. a quick raster preview outside the
gallery pipeline) — seeing `formats=("pdf", "png")` in a new script is a
signal to ask why, since the gallery already produces its own PNG
thumbnails from the PDF.
6. **This package has no test/CI dependency on a real LaTeX install being
absent** — the test suite (`tests/test_plotstyle.py`) assumes LaTeX *is*
present (this repo's dev machine has it), and exercises real rendering
rather than mocking it out. Don't add a mathtext-fallback code path to
make tests pass in a hypothetical no-LaTeX CI without being asked; that
would silently reintroduce the fallback behavior that was deliberately
removed.
7. **Known limitation, don't try to route around it:** a figure title and
its `params` subtitle can't have different colors (matplotlib's usetex
rendering tints an entire Text artist with one color; any in-source
`\color`/`\textcolor` is ignored). They're differentiated by size
(`\small`) only. If asked to make the subtitle a different color, the
real fix requires a second, independently-positioned Text artist with its
own color — flag the added complexity rather than quietly reaching for
`\textcolor` again.
## Combining with `gallery`: producing plots the gallery will display
`plotstyle` and `gallery` never share code or imports — the only connection
is that `gallery` recursively scans **source directories** (configured in
`gallery`'s `config.yaml`, see repo-root `CLAUDE.md`) for PDF/HTML files plus
`metadata.yaml`/`.yml`/`.json` files, and turns them into a static site.
`gallery` does its own PDF→PNG conversion at a configured DPI — so a
`plotstyle` script only needs to produce the PDF; **don't** also generate a
PNG "for the gallery" (that's `gallery`'s job, and a hand-made PNG would just
be redundant/inconsistent with the thumbnail `gallery` generates itself).
### End-to-end workflow
1. **Pick or create a source directory** for the project's plots — this can
be anywhere on disk, it does not need to live inside this repo (e.g.
`~/experiments/run42/plots/`). Subdirectories inside it become the
gallery's folder hierarchy.
2. **Write the plotting script using `plotstyle`**, saving into that
directory:
```python
import plotstyle as ps
ps.use()
fig, ax = ps.new_figure("thesis-single", title="Beam profile", params={"run": 42})
# ... plot ...
ps.savefig(fig, "/home/user/experiments/run42/plots/beam_profile/x_projection", formats=("pdf",))
```
3. **Add a `metadata.yaml`** in any folder of that source tree to annotate
every plot within it (and its subfolders — metadata inherits downward,
child keys override parent keys). Fields are freeform YAML — there's no
fixed schema — but a few keys get special, prominent placement in the
per-plot popup UI: `title`, `description`, `plot_type`, `experiment`.
Everything else still displays, just under "Additional Information".
```yaml
# metadata.yaml
title: "Run 42 — Beam Profile Measurements"
description: "Transverse beam profiles at IP1, measured with the wire scanner."
experiment: "Run 42"
plot_type: "beam-profile"
parameters:
beam_energy: "6.5 TeV"
bunch_intensity: "1.1e11"
tags:
- "beam-diagnostics"
- "ip1"
```
- Also accepts `.yml`/`.json` instead of `.yaml`.
- **Per-plot override**: create `<plotname>.yaml` next to
`<plotname>.pdf` (matching the PDF's stem) with just the keys to
override for that one plot — it's merged on top of the inherited
folder metadata.
- Long text values (>100 chars), lists, and nested mappings all render
sensibly in the UI automatically (truncated-with-"show more", tags,
nested key/value blocks respectively) — no special formatting needed
on the Python/YAML side.
- Metadata text values support inline LaTeX, rendered client-side via
MathJax, e.g. `formula: "$$E = mc^2$$"` or
`luminosity: "35.9 fb^{-1}"`.
4. **Register the source** in `gallery`'s `config.yaml` if it isn't already
there:
```yaml
sources:
- name: "run42"
path: "/home/user/experiments/run42/plots"
```
5. **Generate (or update) the gallery**:
```bash
gallery generate --source /home/user/experiments/run42/plots --verbose
```
Incremental: `gallery` only reconverts a PDF to PNG if the PDF is newer
than the cached PNG (with a 30s buffer — see repo-root `CLAUDE.md`), so
re-running a `plotstyle` script that overwrites the same PDF path is
exactly the intended update flow.
+2 -1
View File
@@ -13,7 +13,7 @@ scientific plots across presentations and thesis figures.
from . import colors
from .annotations import panel_label, style_legend
from .colors import get_color
from .figures import FIGSIZES, colorbar, new_figure, savefig
from .figures import FIGSIZES, colorbar, new_figure, no_spines, savefig
from .style import reset, use
__all__ = [
@@ -23,6 +23,7 @@ __all__ = [
"reset",
"new_figure",
"colorbar",
"no_spines",
"savefig",
"FIGSIZES",
"style_legend",
+14
View File
@@ -88,6 +88,20 @@ def new_figure(
return fig, axes
def no_spines(ax: Union[Axes, np.ndarray]) -> None:
"""Hide every spine on `ax` — pass a single Axes or an array of them.
`use()` keeps only the bottom spine visible, since most plots have a
meaningful x baseline. Pixel/bin-indexed plots (`imshow`, `pcolormesh`,
2D histograms) don't — there's no "zero" the bottom spine anchors to —
so call this on their Axes instead of leaving the themed bottom spine on
or hand-rolling `ax.spines[...].set_visible(False)`.
"""
for single_ax in [ax] if isinstance(ax, Axes) else np.ravel(ax):
for spine in single_ax.spines.values():
spine.set_visible(False)
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.
+20
View File
@@ -210,6 +210,26 @@ def test_colorbar_has_no_outline():
plt.close(fig)
def test_no_spines_hides_all_spines_on_single_axes():
fig, ax = ps.new_figure("square")
try:
ax.imshow([[0, 1], [2, 3]])
ps.no_spines(ax)
assert all(not spine.get_visible() for spine in ax.spines.values())
finally:
plt.close(fig)
def test_no_spines_hides_all_spines_on_axes_array():
fig, axes = ps.new_figure("square", nrows=1, ncols=2)
try:
ps.no_spines(axes)
for ax in axes:
assert all(not spine.get_visible() for spine in ax.spines.values())
finally:
plt.close(fig)
def test_savefig_writes_requested_formats(tmp_path):
fig, ax = ps.new_figure("square")
ax.plot([0, 1], [0, 1])