Skip to content

Commit 34f94ea

Browse files
authored
fix: apply element text angle to surfaces (#59)
2 parents 4c014a5 + 3cc1893 commit 34f94ea

7 files changed

Lines changed: 69 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- feat: `labs()` fields default to `auto`; pass `none` to suppress an axis or legend title and reclaim the space it reserved. (#12)
1313
- feat: `element-blank()` on a text surface (axis, plot, or legend title) collapses the space the text would reserve. (#12)
1414
- feat: `width`/`height` accept `auto` to fill the available space of a bounded container. (#10)
15+
- fix: `element-text(angle:)`/`element-typst(angle:)` rotate axis tick labels (seeding the `guide-axis(angle:)` default) and the plot title, subtitle, and caption, instead of being ignored. (#59)
1516
- fix: `labs(tag:)` draws the figure tag above the title on a standalone plot, styled by the `plot-tag` theme element, instead of being ignored. (#58)
1617
- fix: `labs(alt:)` fills in the figure's accessibility alt text when `plot(alt:)` is unset, instead of being stored and ignored. (#57)
1718
- fix: `geom-qq()`/`geom-qq-line()` honour the `distribution` argument (`uniform`/`exponential`) instead of always plotting against the normal reference. (#56)

src/render.typ

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -863,22 +863,32 @@
863863
// Normalise a single `guide-axis*` spec to the flat shape consumers expect.
864864
// Always carries a `stack` flag so callers can branch on the same field
865865
// regardless of whether the guide originated from a stack or stand-alone.
866-
#let _normalise-axis-guide(g) = (
867-
angle: g.at("angle", default: 0),
868-
n-dodge: calc.max(1, g.at("n-dodge", default: 1)),
869-
logticks: g.at("logticks", default: false),
870-
stack: false,
871-
)
866+
// `default-angle` (degrees, from the axis-text theme element) applies when the
867+
// guide leaves `angle` at its `0` default, so `guide-axis(angle:)` overrides
868+
// the theme but the theme still drives rotation on its own.
869+
#let _normalise-axis-guide(g, default-angle) = {
870+
let angle = g.at("angle", default: 0)
871+
(
872+
angle: if angle != 0 { angle } else { default-angle },
873+
n-dodge: calc.max(1, g.at("n-dodge", default: 1)),
874+
logticks: g.at("logticks", default: false),
875+
stack: false,
876+
)
877+
}
872878

873879
// Read a `guide-axis(...)` or `guide-axis-stack(...)` configuration off the
874880
// plot spec. Single guides flatten to `(angle, n-dodge, logticks, stack)`;
875881
// stacks add `(guides, spacing)` plus aggregate fields so flat (non-stack)
876882
// callers (label-anchor, log-minors) still see a sensible single-row view.
877-
#let _read-axis-guide(spec, aes) = {
883+
#let _read-axis-guide(spec, aes, default-angle: 0) = {
878884
let g = spec.at("guides", default: (:)).at(aes, default: none)
879-
if g == none { return (angle: 0, n-dodge: 1, logticks: false, stack: false) }
880-
if not g.at("stack", default: false) { return _normalise-axis-guide(g) }
881-
let subs = g.guides.map(_normalise-axis-guide)
885+
if g == none {
886+
return (angle: default-angle, n-dodge: 1, logticks: false, stack: false)
887+
}
888+
if not g.at("stack", default: false) {
889+
return _normalise-axis-guide(g, default-angle)
890+
}
891+
let subs = g.guides.map(s => _normalise-axis-guide(s, default-angle))
882892
if subs.len() == 0 {
883893
panic(
884894
"guide-axis-stack requires at least one sub-guide; got an empty list.",
@@ -894,6 +904,14 @@
894904
)
895905
}
896906

907+
// Tick-label rotation in degrees read off the `axis-text` theme element for
908+
// the given aesthetic, used as the `guide-axis(angle:)` default so a theme can
909+
// rotate tick labels on its own.
910+
#let _axis-text-angle(theme, aes) = {
911+
let a = _text-style(theme, "axis-text-" + aes).angle
912+
if a == none { 0 } else { a.deg() }
913+
}
914+
897915
// Cap trim is a small wedge at the named axis-arc end, capped at 2° so it
898916
// stays a visible-but-modest "fade" no matter how wide the theta sweep is.
899917
#let _THETA-CAP-FRAC = 0.02
@@ -1234,8 +1252,14 @@
12341252
let _len-side = (p, s, a) => _scalar-cascade(theme, p, s, a) / 1cm
12351253
let _tick-len = _per-side(_len-side, "tick-length")
12361254

1237-
let x-guide = _read-axis-guide(spec, "x")
1238-
let y-guide = _read-axis-guide(spec, "y")
1255+
let x-guide = _read-axis-guide(spec, "x", default-angle: _axis-text-angle(
1256+
theme,
1257+
"x",
1258+
))
1259+
let y-guide = _read-axis-guide(spec, "y", default-angle: _axis-text-angle(
1260+
theme,
1261+
"y",
1262+
))
12391263
let _x-label-anchor(angle) = {
12401264
if angle == 0 { "north" } else if angle > 0 { "north-east" } else {
12411265
"north-west"
@@ -3086,13 +3110,9 @@
30863110
let a = if style.align != none { style.align } else { default-align }
30873111
let args = _text-args(style)
30883112
for (k, v) in text-args.named() { args.insert(k, v) }
3089-
box(
3090-
width: inner-w,
3091-
align(
3092-
a,
3093-
text(..args)[#resolve-prose(value, eval-strings: style.typst)],
3094-
),
3095-
)
3113+
let body = text(..args)[#resolve-prose(value, eval-strings: style.typst)]
3114+
if style.angle != none { body = rotate(style.angle, reflow: true, body) }
3115+
box(width: inner-w, align(a, body))
30963116
} else { none }
30973117
let tag-block = if labs != none {
30983118
_chrome-block(labs.tag, tag, left, weight: tag.weight)
@@ -3453,8 +3473,16 @@
34533473
"y",
34543474
)
34553475

3456-
let x-guide = _read-axis-guide(spec, "x")
3457-
let y-guide = _read-axis-guide(spec, "y")
3476+
let x-guide = _read-axis-guide(
3477+
spec,
3478+
"x",
3479+
default-angle: _axis-text-angle(theme, "x"),
3480+
)
3481+
let y-guide = _read-axis-guide(
3482+
spec,
3483+
"y",
3484+
default-angle: _axis-text-angle(theme, "y"),
3485+
)
34583486
// Themes that disable tick labels (`theme-void`) reserve no perpendicular
34593487
// depth for them; otherwise the chrome margin reserves space for ink that
34603488
// never draws, inverting the panel rect on small plot sizes.

src/theme/elements.typ

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@
1919
///
2020
/// \@param colour Text colour, or `none` to inherit.
2121
///
22-
/// \@param angle Rotation angle (a Typst angle), or `none` to inherit.
22+
/// \@param angle Rotation angle (a Typst angle), or `none` to inherit. Honoured
23+
/// on axis tick labels (`axis-text`, seeding the \@guide-axis `angle`, which
24+
/// overrides it) and on the plot title, subtitle, and caption. Legend and
25+
/// strip text ignore this field.
2326
///
2427
/// \@param font Font family (e.g., `"sans"`, `"serif"`), or `none` to inherit.
2528
///
@@ -132,7 +135,10 @@
132135
///
133136
/// \@param colour Text colour, or `none` to inherit.
134137
///
135-
/// \@param angle Rotation angle (a Typst angle), or `none` to inherit.
138+
/// \@param angle Rotation angle (a Typst angle), or `none` to inherit. Honoured
139+
/// on axis tick labels (`axis-text`, seeding the \@guide-axis `angle`, which
140+
/// overrides it) and on the plot title, subtitle, and caption. Legend and
141+
/// strip text ignore this field.
136142
///
137143
/// \@param font Font family, or `none` to inherit.
138144
///

src/theme/theme.typ

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,9 @@
303303
margin: _normalise-margin(el.at("margin", default: none)),
304304
// `none` when unset; each draw site applies its per-surface default.
305305
align: el.at("align", default: none),
306+
// `none` when unset; text surfaces rotate their drawn content by this
307+
// angle, and axis-text feeds it as the tick-label angle default.
308+
angle: el.at("angle", default: none),
306309
)
307310
}
308311

tests/unit/test-theme.typ

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,12 @@
121121
// A normal text element keeps its declared size.
122122
#assert.eq(_text-style(merge-theme(theme()), "axis-title").size, 9pt)
123123

124+
// `angle` surfaces through `_text-style` and cascades to per-side surfaces,
125+
// so axis-text rotation reaches the tick-label angle default.
126+
#let angled = merge-theme(theme(axis-text: element-text(angle: 30deg)))
127+
#assert.eq(_text-style(angled, "axis-text").angle, 30deg)
128+
#assert.eq(_text-style(angled, "axis-text-x-bottom").angle, 30deg)
129+
// Unset stays `none` so existing themes keep upright text.
130+
#assert.eq(_text-style(merge-theme(theme()), "axis-text").angle, none)
131+
124132
Theme tests passed.
-5 Bytes
Loading
1.85 KB
Loading

0 commit comments

Comments
 (0)