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:
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user