Skip to content

Commit bf1903f

Browse files
committed
feat: add automatic nice axis
1 parent ce47fc2 commit bf1903f

3 files changed

Lines changed: 405 additions & 82 deletions

File tree

README.md

Lines changed: 227 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,22 @@
55
[![Docs](https://img.shields.io/badge/docs-gh--pages-blue)](https://lamalab-org.github.io/lama-aesthetics/)
66
[![License](https://img.shields.io/github/license/lamalab-org/lama-aesthetics)](https://img.shields.io/github/license/lamalab-org/lama-aesthetics)
77

8-
Plotting styles and helpers by LamaLab
8+
Publication-quality plotting styles and helpers for matplotlib by [LamaLab](https://lamalab.org).
99

10-
- **Github repository**: <https://github.com/lamalab-org/lama-aesthetics/>
11-
- **Documentation** <https://lamalab-org.github.io/lama-aesthetics/>
10+
- **GitHub repository**: <https://github.com/lamalab-org/lama-aesthetics/>
11+
- **Documentation**: <https://lamalab-org.github.io/lama-aesthetics/>
12+
13+
## Features at a glance
14+
15+
| Category | What you get |
16+
|---|---|
17+
| **Styles** | `main` (publication), `presentation` (talks), `dark` (dark-themed) |
18+
| **Bundled font** | CMU Sans Serif — auto-registered, no system install needed |
19+
| **Range frame** | Tufte-style axis frame trimmed to the data range, with automatic nice-number bounds for numeric axes and full support for categorical axes |
20+
| **Label helpers** | `ylabel_top` — horizontal y-label placed above the top tick |
21+
| **Reference lines** | `add_identity` — dynamic 1:1 diagonal that follows axis changes |
22+
| **Figure decomposition** | `decompose_figure` — split a multi-series figure into one figure per labeled artist |
23+
| **Dimension constants** | `ONE_COL_WIDTH`, `TWO_COL_WIDTH`, `ONE_COL_HEIGHT`, `TWO_COL_HEIGHT` (golden ratio) |
1224

1325
## Installation
1426

@@ -24,22 +36,48 @@ uv pip install -e .
2436
make install
2537
```
2638

27-
## Usage
39+
## Quick start
40+
41+
```python
42+
import matplotlib.pyplot as plt
43+
import numpy as np
44+
import lama_aesthetics
2845

29-
### Styles
46+
lama_aesthetics.get_style("main")
47+
48+
fig, ax = plt.subplots()
49+
x = np.linspace(0.5, 9.3, 40)
50+
y = np.sin(x) * 10 + 15
51+
52+
ax.plot(x, y)
53+
lama_aesthetics.range_frame(ax, x, y) # nice-number axis bounds by default
54+
lama_aesthetics.ylabel_top("y", ax=ax)
55+
56+
plt.show()
57+
```
3058

31-
The library provides three main plotting styles:
59+
---
3260

33-
- **main**: Optimized for publications, reports, and other documents.
34-
- **presentation**: Features larger fonts and thicker lines for better visibility in presentations.
35-
- **dark**: Same as main but with a black background and white text/lines, ideal for dark-themed presentations or interfaces.
61+
## Styles
62+
63+
The library ships three matplotlib style sheets, all using the bundled **CMU Sans Serif** font:
64+
65+
| Style | Apply with | Description |
66+
|---|---|---|
67+
| **main** | `get_style("main")` | Optimized for single- and two-column journal figures. Compact figure size (3.3 × 2.5 in), inward ticks, thin lines. |
68+
| **presentation** | `get_style("presentation")` | Same layout but with larger fonts (13 / 12 pt) for slides and posters. |
69+
| **dark** | `get_style("dark")` | Black background, white text and lines — ideal for dark-themed slides or dashboards. |
70+
71+
All styles disable the right and top spines, use inward-facing ticks, and remove the legend frame for a clean Tufte-inspired look.
3672

3773
```python
38-
import matplotlib.pyplot as plt
39-
import numpy as np
4074
import lama_aesthetics
4175

42-
lama_aesthetics.get_style("main") # or lama_aesthetics.get_style("presentation") or lama_aesthetics.get_style("dark")
76+
lama_aesthetics.get_style("main")
77+
# or
78+
lama_aesthetics.get_style("presentation")
79+
# or
80+
lama_aesthetics.get_style("dark")
4381
```
4482

4583
<div align="center">
@@ -50,110 +88,228 @@ lama_aesthetics.get_style("main") # or lama_aesthetics.get_style("presentation"
5088
<p><em>Left: Main style; Right: Presentation style</em></p>
5189
</div>
5290

53-
### Helpers
54-
55-
The package includes several plotting utilities to enhance your visualizations:
91+
### Bundled font
5692

57-
- **range_frame**: Draws a frame around the data range.
58-
- **ylabel_top**: Places the y-label at the top of the y-axis.
59-
- **add_identity**: Adds a diagonal reference line.
60-
- **decompose_figure**: Splits a figure into individual figures, one per labeled artist.
61-
62-
### Figure Dimensions
63-
64-
The package provides predefined figure dimension constants based on common journal column widths and the golden ratio:
93+
The **CMU Sans Serif** font (`cmunss.otf`) is bundled with the package and
94+
registered automatically the first time you apply a style. You can also
95+
register it manually:
6596

6697
```python
67-
from lama_aesthetics import (
68-
ONE_COL_WIDTH,
69-
TWO_COL_WIDTH,
70-
ONE_COL_HEIGHT,
71-
TWO_COL_HEIGHT,
72-
)
98+
lama_aesthetics.register_fonts()
99+
font_name = lama_aesthetics.get_font_name() # → "CMU Sans Serif"
100+
```
73101

74-
# Create a single-column figure with golden ratio proportions
75-
fig, ax = plt.subplots(figsize=(ONE_COL_WIDTH, ONE_COL_HEIGHT))
102+
---
76103

77-
# Create a two-column figure with golden ratio proportions
78-
fig, ax = plt.subplots(figsize=(TWO_COL_WIDTH, TWO_COL_HEIGHT))
79-
```
104+
## Plotting utilities
105+
106+
### `range_frame` — Tufte-style range frame with nice-number bounds
80107

81-
Available constants:
108+
`range_frame` trims the axis spines to the data range and offsets them outward
109+
for a clean, separated look. It automatically detects whether each axis is
110+
**numeric** or **categorical** and handles them differently:
82111

83-
- `ONE_COL_WIDTH`: 3 inches (typical single-column width)
84-
- `TWO_COL_WIDTH`: 7.25 inches (typical two-column width)
85-
- `ONE_COL_HEIGHT`: Single-column height based on golden ratio
86-
- `TWO_COL_HEIGHT`: Two-column height based on golden ratio
112+
**Numeric axes (default: `nice=True`):**
113+
The spine bounds are snapped to "nice" tick positions (multiples of 1, 2, 2.5,
114+
5, or 10 × 10^n) that bracket the data. This means the axis line always
115+
starts and ends exactly on a tick mark — no more floating spine endpoints.
116+
Tick positions are computed via matplotlib's `MaxNLocator` and explicitly set
117+
on the axes so there is zero drift between ticks and spine bounds.
87118

88-
### Plotting Utilities Examples
119+
**Categorical axes:**
120+
When `x` or `y` contains strings (e.g. `["a", "b", "c"]`), the spine spans
121+
the integer range `0 .. len(values) - 1`. No rounding is applied.
89122

90123
```python
91124
import matplotlib.pyplot as plt
92125
import numpy as np
93-
from lama_aesthetics.plotutils import range_frame, ylabel_top, add_identity
126+
from lama_aesthetics.plotutils import range_frame
94127

95-
# Create sample data
96-
x = np.linspace(0, 10, 100)
97-
y = np.sin(x)
98-
99-
# Create a figure
100-
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
128+
fig, axes = plt.subplots(1, 3, figsize=(12, 3))
101129

102-
# Example 1: Range frame - only shows axes within the data range
130+
# 1) Basic numeric — bounds snap to nice ticks
131+
x = np.linspace(3.2, 47.8, 20)
132+
y = x * 1.3 - 1.5
103133
axes[0].plot(x, y)
104-
range_frame(axes[0], x, y)
105-
axes[0].set_title("Range Frame")
134+
range_frame(axes[0], x, y) # nice=True by default
135+
axes[0].set_title("Numeric (nice bounds)")
106136

107-
# Example 2: Top Y-label - places ylabel at top of y-axis
108-
axes[1].plot(x, y)
109-
ylabel_top("sin(x)", axes[1])
110-
axes[1].set_title("Top Y-Label")
137+
# 2) Categorical x-axis
138+
categories = ["Model A", "Model B", "Model C", "Model D"]
139+
values = np.array([0.82, 0.91, 0.87, 0.95])
140+
axes[1].plot(categories, values)
141+
range_frame(axes[1], categories, values) # categorical x, numeric y
142+
axes[1].set_title("Categorical x-axis")
111143

112-
# Example 3: Identity line - adds a diagonal reference line
113-
x_scatter = np.linspace(0, 1, 20)
114-
y_scatter = x_scatter + 0.1*np.random.randn(20)
115-
axes[2].scatter(x_scatter, y_scatter)
116-
add_identity(axes[2], linestyle="--", color="gray")
117-
axes[2].set_title("Identity Line")
144+
# 3) Disable nice bounds — raw padding only
145+
axes[2].plot(x, y)
146+
range_frame(axes[2], x, y, nice=False, pad=0.05)
147+
axes[2].set_title("nice=False (raw padding)")
118148

119149
plt.tight_layout()
120150
plt.show()
121151
```
122152

123-
<div align="center"> <img src="docs/static/plotting_functions.png" alt="Helper function examples" width="100%"/> <p><em>Left: Range Frame; Center: Top Y-Label; Right: Identity Line</em></p> </div>
153+
#### Parameters
154+
155+
| Parameter | Default | Description |
156+
|---|---|---|
157+
| `ax` || Matplotlib `Axes` object |
158+
| `x`, `y` || Data arrays (numeric or string/categorical) |
159+
| `pad` | `0.1` | Padding factor applied to both axes (only used when `nice=False`) |
160+
| `pad_x` | `None` | Per-axis padding near the x-axis (vertical). Overrides `pad`. |
161+
| `pad_y` | `None` | Per-axis padding near the y-axis (horizontal). Overrides `pad`. |
162+
| `nice` | `True` | Snap numeric spine bounds to nice tick positions that bracket the data. When `True`, padding parameters are ignored for numeric axes. |
124163

125-
### Decomposing a Figure by Legend Entries
164+
### `ylabel_top` — horizontal y-label above the axis
126165

127-
`decompose_figure` takes a figure (or axes) that contains multiple labeled series and returns a list of `(label, figure)` tuples — one separate figure per legend entry. This is useful when you want to highlight individual series from a combined plot, e.g. to include them separately in a paper or presentation.
166+
Places the y-axis label horizontally above the top tick, making it easier to
167+
read without head-tilting:
168+
169+
```python
170+
from lama_aesthetics.plotutils import ylabel_top
171+
172+
fig, ax = plt.subplots()
173+
ax.plot([0, 1, 2], [0, 1, 4])
174+
ylabel_top("Energy (eV)", ax=ax)
175+
```
176+
177+
| Parameter | Default | Description |
178+
|---|---|---|
179+
| `string` || Label text |
180+
| `ax` | `None` | Axes (defaults to `plt.gca()`) |
181+
| `x_pad` | `0.01` | Horizontal offset in axes coordinates |
182+
| `y_pad` | `0.02` | Vertical offset above the top tick |
183+
184+
### `add_identity` — dynamic 1:1 reference line
185+
186+
Adds a diagonal identity line that automatically adjusts when the axis limits
187+
change (e.g. during zoom or pan):
188+
189+
```python
190+
from lama_aesthetics.plotutils import add_identity
191+
192+
fig, ax = plt.subplots()
193+
predicted = np.array([1.1, 2.3, 2.9, 4.2])
194+
observed = np.array([1.0, 2.0, 3.0, 4.0])
195+
ax.scatter(observed, predicted)
196+
add_identity(ax, linestyle="--", color="gray", alpha=0.6)
197+
```
198+
199+
### `decompose_figure` — split a figure by legend entry
200+
201+
Takes a figure (or a single `Axes`) containing multiple labeled series and
202+
returns a list of `(label, figure)` tuples — one separate figure per legend
203+
entry. This is useful when you want to highlight individual series, e.g. to
204+
include them separately in a paper or presentation.
128205

129206
```python
130-
import matplotlib.pyplot as plt
131-
import numpy as np
132207
from lama_aesthetics import decompose_figure, get_style
133208

134209
get_style("main")
135210

136-
# Build a figure with several series
137211
fig, ax = plt.subplots()
138212
x = np.linspace(0, 2 * np.pi, 50)
139213
ax.plot(x, np.sin(x), label="sin(x)")
140214
ax.plot(x, np.cos(x), label="cos(x)")
141-
ax.plot(x, np.sin(x) + np.cos(x), label="sin(x)+cos(x)")
215+
ax.plot(x, np.sin(x) + np.cos(x), label="sin+cos")
142216
ax.set_xlabel("x")
143217
ax.set_ylabel("f(x)")
144218
ax.set_title("Trigonometric Functions")
145219
ax.legend()
146220

147-
# Split into individual figures — one per labeled series
148-
parts = decompose_figure(fig) # also accepts an Axes directly
221+
parts = decompose_figure(fig) # also accepts an Axes directly
149222

150223
for label, part_fig in parts:
151224
part_fig.savefig(f"{label}.png")
152225
plt.close(part_fig)
153226
```
154227

155-
Each decomposed figure inherits the axis labels, title, limits, and scale of the original. Pass `show_legend=False` to omit the legend from the individual figures.
228+
Each decomposed figure inherits axis labels, title, limits, scale, tick
229+
positions, spine visibility, and grid state from the original. Pass
230+
`show_legend=False` to omit the legend from the individual figures.
231+
232+
**Supported artist types:** line plots (`plot`), scatter plots (`scatter`),
233+
bar charts (`bar`), and filled regions (`fill_between`).
234+
235+
---
236+
237+
## Figure dimension constants
238+
239+
Predefined sizes based on common journal column widths and the golden ratio:
240+
241+
```python
242+
from lama_aesthetics import (
243+
ONE_COL_WIDTH, # 3 inches
244+
TWO_COL_WIDTH, # 7.25 inches
245+
ONE_COL_HEIGHT, # ONE_COL_WIDTH / φ ≈ 1.854 inches
246+
TWO_COL_HEIGHT, # TWO_COL_WIDTH / φ ≈ 4.481 inches
247+
)
248+
249+
fig, ax = plt.subplots(figsize=(ONE_COL_WIDTH, ONE_COL_HEIGHT))
250+
```
251+
252+
---
253+
254+
## Full example
255+
256+
```python
257+
import matplotlib.pyplot as plt
258+
import numpy as np
259+
import lama_aesthetics
260+
from lama_aesthetics.plotutils import range_frame, ylabel_top, add_identity
261+
262+
lama_aesthetics.get_style("main")
263+
264+
x = np.linspace(0, 10, 100)
265+
y = np.sin(x)
266+
267+
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
268+
269+
# Range frame with nice bounds
270+
axes[0].plot(x, y)
271+
range_frame(axes[0], x, y)
272+
axes[0].set_title("Range Frame")
273+
274+
# Top y-label
275+
axes[1].plot(x, y)
276+
ylabel_top("sin(x)", axes[1])
277+
axes[1].set_title("Top Y-Label")
278+
279+
# Identity line
280+
x_sc = np.linspace(0, 1, 20)
281+
y_sc = x_sc + 0.1 * np.random.randn(20)
282+
axes[2].scatter(x_sc, y_sc)
283+
add_identity(axes[2], linestyle="--", color="gray")
284+
axes[2].set_title("Identity Line")
285+
286+
plt.tight_layout()
287+
plt.show()
288+
```
289+
290+
<div align="center">
291+
<img src="docs/static/plotting_functions.png" alt="Helper function examples" width="100%"/>
292+
<p><em>Left: Range Frame; Center: Top Y-Label; Right: Identity Line</em></p>
293+
</div>
156294

157-
Supported artist types: line plots, scatter plots, bar charts, and `fill_between` regions.
295+
---
296+
297+
## API reference
298+
299+
| Function / Constant | Module | Description |
300+
|---|---|---|
301+
| `get_style(name)` | `aesthetics` | Apply a bundled style (`"main"`, `"presentation"`, `"dark"`) |
302+
| `register_fonts()` | `aesthetics` | Register bundled CMU Sans Serif with matplotlib |
303+
| `get_font_name()` | `aesthetics` | Return the registered font family name |
304+
| `range_frame(ax, x, y, ...)` | `plotutils` | Tufte-style range frame with nice-number bounds |
305+
| `ylabel_top(string, ax, ...)` | `plotutils` | Place y-label horizontally above the top tick |
306+
| `add_identity(ax, ...)` | `plotutils` | Add a dynamic 1:1 diagonal reference line |
307+
| `decompose_figure(fig, ...)` | `plotutils` | Split a figure into one figure per labeled artist |
308+
| `ONE_COL_WIDTH` | `aesthetics` | 3 inches |
309+
| `TWO_COL_WIDTH` | `aesthetics` | 7.25 inches |
310+
| `ONE_COL_HEIGHT` | `aesthetics` | `ONE_COL_WIDTH / golden_ratio` |
311+
| `TWO_COL_HEIGHT` | `aesthetics` | `TWO_COL_WIDTH / golden_ratio` |
312+
313+
---
158314

159315
Repository initiated with [lamalab-org/cookiecutter-uv](https://github.com/lamalab-org/cookiecutter-uv).

0 commit comments

Comments
 (0)