Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# vellumplot (development version)

* **Rotated and wrapping axis tick labels.** `axis.text.x` / `axis.text.y` now
honour an `angle` — `theme(axis.text.x = element_text(angle = 45))` slants the
labels and the gutter reserves the rotated height (an explicit `hjust`/`vjust`
sets the anchor; otherwise the end of the run is pinned at the tick). When the
labels are left horizontal, a long discrete label that would overrun its tick
spacing wraps onto multiple lines instead of colliding with its neighbour, and
the label row grows to fit — the per-tick companion of the title/subtitle/
caption wrapping already shipped. Labels that already fit are unchanged.

* **Multi-page PDFs and parallel batch export.** `pdf_pages()` writes several
plots into one PDF (a report or slide deck), one plot per page — pages may
differ in size and each keeps its accessibility tags — or splits a single
Expand Down
9 changes: 8 additions & 1 deletion R/compile-composition.R
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,14 @@ NULL
scene <- vellum::pop(scene)
# axes + axis titles
scene <- .draw_y_axis(scene, gm$panel_row, gm$ylabels_col, vsc, rt)
scene <- .draw_x_axis(scene, gm$xlabels_row, gm$panel_col, hsc, rt)
scene <- .draw_x_axis(
scene,
gm$xlabels_row,
gm$panel_col,
hsc,
rt,
gm$xwrap_mm
)
scene <- .draw_y_title(scene, gm$panel_row, gm$ytitle_col, vsc$name, rt)
scene <- .draw_x_title(scene, gm$xtitle_row, gm$panel_col, hsc$name, rt)
# per-sub-plot legend (only when not collecting)
Expand Down
112 changes: 103 additions & 9 deletions R/compile-guides.R
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,92 @@ NULL
# byte-identical but for the small geometry table below (viewport name + scale,
# the axis-line segment endpoints and its sketch seed, and the label anchor +
# justification), so they are now thin wrappers over this.
.draw_axis <- function(scene, row, col, sc, rt, axis, sec = FALSE) {
# hjust/vjust (0..1) bucketed to a justification word, matching `.band_anchor`.
.just_h <- function(h) {
if (h < 0.25) {
"left"
} else if (h > 0.75) {
"right"
} else {
"centre"
}
}
.just_v <- function(v) {
if (v < 0.25) {
"bottom"
} else if (v > 0.75) {
"top"
} else {
"centre"
}
}

# The justification a tick label uses. Unrotated labels keep the track's fixed
# anchor (`geom$just`). A rotated label honours the element's own `hjust`/`vjust`
# when set; otherwise it anchors the *end* of the run at the tick so the slanted
# text hangs clear of the panel (down-outward along the bottom, up-outward along
# the top).
.axis_text_just <- function(geom, el, rot) {
if (rot == 0) {
return(geom$just)
}
hj <- el@hjust
vj <- el@vjust
if (!is.null(hj) || !is.null(vj)) {
return(c(
if (!is.null(hj)) .just_h(hj) else geom$just[1],
if (!is.null(vj)) .just_v(vj) else geom$just[2]
))
}
if (identical(geom$just[2], "top")) {
c(if (rot > 0) "right" else "left", "top")
} else if (identical(geom$just[2], "bottom")) {
c(if (rot > 0) "left" else "right", "bottom")
} else {
geom$just
}
}

# The `text_grob` for one tick label. When `wrap_mm` (the mm width a single tick
# gets) is finite and the label is unrotated, the text wraps to that width and
# its lines centre under the tick; otherwise it is an ordinary single line drawn
# exactly where the old code placed it. Shared by the draw and the row/gutter
# height measurement (`.track_h_axis`) so the reserved track always matches.
.axis_text_grob <- function(
label,
el,
just,
x = vellum::vl_unit(0.5, "npc"),
y = vellum::vl_unit(0.5, "npc"),
rot = 0,
wrap_mm = NA_real_
) {
wrap <- length(wrap_mm) == 1L && is.finite(wrap_mm) && wrap_mm > 0 && rot == 0
# Tabular figures on tick labels: digits keep a constant advance width, so a
# column of numbers does not jitter left/right from tick to tick. Harmless on
# non-numeric labels (only digit glyphs are affected).
vellum::text_grob(
label,
x = x,
y = y,
just = just,
rot = rot,
width = if (wrap) vellum::vl_unit(wrap_mm, "mm") else NULL,
align = if (wrap) "centre" else "left",
gp = .el_gpar_text(el, features = .TNUM)
)
}

.draw_axis <- function(
scene,
row,
col,
sc,
rt,
axis,
sec = FALSE,
wrap_mm = NA_real_
) {
is_y <- identical(axis, "y")
el <- rt[[if (is_y) "axis.text.y" else "axis.text.x"]]
aline <- rt[[if (is_y) "axis.line.y" else "axis.line.x"]]
Expand Down Expand Up @@ -352,10 +437,8 @@ NULL
)
}
if (!.is_blank(el)) {
# Tabular figures on tick labels: digits keep a constant advance width, so a
# column of numbers does not jitter left/right from tick to tick. Harmless on
# non-numeric labels (only digit glyphs are affected).
gp <- .el_gpar_text(el, features = .TNUM)
rot <- .el_rot(el)
just <- .axis_text_just(geom, el, rot)
for (i in seq_along(sc$breaks)) {
x <- if (is_y) {
vellum::vl_unit(geom$lx, "npc")
Expand All @@ -369,7 +452,15 @@ NULL
}
scene <- vellum::draw(
scene,
vellum::text_grob(sc$labels[i], x = x, y = y, just = geom$just, gp = gp)
.axis_text_grob(
sc$labels[i],
el,
just,
x = x,
y = y,
rot = rot,
wrap_mm = wrap_mm
)
)
}
}
Expand All @@ -381,9 +472,12 @@ NULL
.draw_axis(scene, row, col, y_sc, rt, "y")
}

# x-axis labels for `x_sc`, centred under each gridline.
.draw_x_axis <- function(scene, row, col, x_sc, rt) {
.draw_axis(scene, row, col, x_sc, rt, "x")
# x-axis labels for `x_sc`, centred under each gridline. `wrap_mm` is the mm
# width one tick gets, so a long label wraps to its column instead of colliding
# with its neighbour; `NA` (the default, and every unknown-width path) keeps
# single-line labels.
.draw_x_axis <- function(scene, row, col, x_sc, rt, wrap_mm = NA_real_) {
.draw_axis(scene, row, col, x_sc, rt, "x", wrap_mm = wrap_mm)
}

# A facet strip: an optional filled background plus a centred label. `rot = 90`
Expand Down
115 changes: 111 additions & 4 deletions R/compile-layout.R
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,23 @@ NULL
vellum::vl_unit(pad_mm, "mm")
}

# Height (mm) of the x tick-label row. Measured through the *same* grob the axis
# drawer builds (`.axis_text_grob`), so a rotated or wrapped label reserves the
# height it actually draws at. `wrap_mm` is the per-tick mm budget (NA = no wrap).
.track_h_axis <- function(el, label, pad_mm, rot = 0, wrap_mm = NA_real_) {
if (.is_blank(el)) {
return(vellum::vl_unit(0, "mm"))
}
g <- .axis_text_grob(
label,
el,
c("centre", "top"),
rot = rot,
wrap_mm = wrap_mm
)
vellum::grobheight(g) + vellum::vl_unit(pad_mm, "mm")
}

# Build the panel + gutter layout. In the simplest single-panel case the columns
# are [ y-title | y-labels | panel(null) ] and the rows [ panel(null) | x-labels
# | x-title ]; faceting, strips, a legend track, and the title/subtitle/tag/
Expand Down Expand Up @@ -288,9 +305,19 @@ NULL
function(p) (if (flip) p$y_sc else p$x_sc)$labels
)))
tick <- rt[["axis.ticks.length"]]
# Tick labels honour the element's rotation (`axis.text.x/y` angle), so the
# gutter/row is sized from the *rotated* extent. The x tick-label row height is
# deferred to below (it also depends on the wrap budget, which needs the legend
# placement resolved first).
xrot <- .el_rot(rt[["axis.text.x"]])
yrot <- .el_rot(rt[["axis.text.y"]])
yt <- .track_w(rt[["axis.title.y"]], vsc$name, .PAD_MM, rot = 90)
yl <- .track_w(rt[["axis.text.y"]], .longest(v_labs), tick + .PAD_MM)
xl <- .track_h(rt[["axis.text.x"]], .longest(h_labs), tick + .PAD_MM)
yl <- .track_w(
rt[["axis.text.y"]],
.longest(v_labs),
tick + .PAD_MM,
rot = yrot
)
xt <- .track_h(rt[["axis.title.x"]], hsc$name, .PAD_MM)
# Secondary axes (opposite edge): a right-column pair for the vertical scale's
# sec axis, a top-row pair for the horizontal scale's. Only sized when present,
Expand All @@ -300,13 +327,23 @@ NULL
has_y_sec <- !is.null(y_sec)
has_x_sec <- !is.null(x_sec)
y2l <- if (has_y_sec) {
.track_w(rt[["axis.text.y"]], .longest(y_sec$labels), tick + .PAD_MM)
.track_w(
rt[["axis.text.y"]],
.longest(y_sec$labels),
tick + .PAD_MM,
rot = yrot
)
}
y2t <- if (has_y_sec) {
.track_w(rt[["axis.title.y"]], y_sec$name, .PAD_MM, rot = 90)
}
x2l <- if (has_x_sec) {
.track_h(rt[["axis.text.x"]], .longest(x_sec$labels), tick + .PAD_MM)
.track_h(
rt[["axis.text.x"]],
.longest(x_sec$labels),
tick + .PAD_MM,
rot = xrot
)
}
x2t <- if (has_x_sec) .track_h(rt[["axis.title.x"]], x_sec$name, .PAD_MM)
# Polar panels carry their axis labels/titles inside the square panel, so the
Expand All @@ -331,6 +368,75 @@ NULL
legend_vert <- show_legend && pos %in% c("left", "right")
legend_horiz <- show_legend && pos %in% c("top", "bottom")

# Per-x-tick wrap budget (mm): the panel's mm width shared among its x ticks, so
# a long discrete label wraps to its own column instead of running into its
# neighbour. Only knowable when the page width is set (`band_w`), the labels are
# unrotated, the x scale is shared, and the panel is a plain (unweighted) null
# track -- aspect/fixed/sf/polar/marginal give the panel a weighted null width
# that does not resolve to mm here, so those fall back to single-line labels.
# Panel mm = content box less the fixed flanking columns (y title/labels, any
# secondary y pair, a row strip, a vertical legend) and the inter-panel gaps,
# split across the C panel columns.
xwrap_mm <- NA_real_
can_wrap_x <- is.finite(band_w) &&
xrot == 0 &&
!respect &&
!polar &&
is.null(marginal) &&
!free_x &&
length(hsc$breaks) > 0L &&
!.is_blank(rt[["axis.text.x"]])
if (can_wrap_x) {
leg_w <- if (legend_vert) {
.legend_width(guides, rt, legend_avail_h)
} else {
vellum::vl_unit(0, "mm")
}
side <- c(
list(yt, yl, leg_w),
if (has_row_strip) list(strip),
if (has_y_sec) list(y2l, y2t)
)
side_mm <- sum(vapply(
side,
function(u) vellum::vl_convert(u, "mm"),
numeric(1)
))
gaps_mm <- vellum::vl_convert(gap, "mm") * (C - 1L)
per_panel_mm <- (band_w - side_mm - gaps_mm) / C
# Any track that does not resolve to a plain mm (a null width slipping
# through) leaves `per_panel_mm` non-finite -- fall back to single-line.
if (isTRUE(per_panel_mm > 0)) {
budget <- per_panel_mm / length(hsc$breaks)
# Only engage wrapping when the widest label actually overruns its budget,
# so every plot whose labels already fit keeps its exact single-line layout
# (measured through the same grob the drawer builds).
el_x <- rt[["axis.text.x"]]
natural <- vellum::vl_convert(
vellum::grobwidth(.axis_text_grob(
.longest(h_labs),
el_x,
c("centre", "top")
)),
"mm"
)
if (isTRUE(natural > budget)) {
xwrap_mm <- budget
}
}
}
# The x tick-label row height, now that the wrap budget is known (deferred from
# the gutter block above). Polar keeps the collapsed zero set earlier.
if (!polar) {
xl <- .track_h_axis(
rt[["axis.text.x"]],
.longest(h_labs),
tick + .PAD_MM,
rot = xrot,
wrap_mm = xwrap_mm
)
}

# --- columns: [ legend(left)? | ytitle | (ylabels panel gap)xC |
# row-strip? | legend(right)? ] ---
W <- .tracks()
Expand Down Expand Up @@ -476,6 +582,7 @@ NULL
ncol_total = length(W$u),
nrow_total = length(H$u),
band_w = band_w,
xwrap_mm = xwrap_mm,
respect = respect
)
}
5 changes: 3 additions & 2 deletions R/elements.R
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,9 @@ element_blank <- S7::new_class(
)
}

# Text rotation for a resolved text element (degrees).
.el_rot <- function(el) el@angle %||% 0
# Text rotation for a resolved text element (degrees). Blank elements draw
# nothing and carry no `angle` property, so they read as unrotated.
.el_rot <- function(el) if (.is_blank(el)) 0 else el@angle %||% 0

# The resolved sketch for a drawn line/rect element: the `vellum_sketch` it (or,
# via the theme tree, a parent element) carries, else NULL (crisp). `NA` (forced
Expand Down
3 changes: 2 additions & 1 deletion R/seam.R
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,8 @@ NULL
lay$xlabels_row[1],
lay$panel_col[cc],
warp_h(hshared),
rt
rt,
lay$xwrap_mm
)
}
}
Expand Down
33 changes: 33 additions & 0 deletions inst/examples/32-axis-labels.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Rotating and wrapping long axis tick labels.

library(vellumplot)
outdir <- "figures"
dir.create(outdir, showWarnings = FALSE)

gdp <- data.frame(
country = c(
"United States", "United Kingdom", "United Arab Emirates",
"Republic of Korea", "Czech Republic"
),
score = c(9, 6, 7, 8, 5)
)

# Rotate the tick labels: the gutter reserves the rotated height automatically.
render_plot(
vplot(gdp) |>
mark_bar(x = country, y = score) |>
theme(axis.text.x = element_text(angle = 45)) |>
labs(title = "Rotated axis labels"),
file.path(outdir, "32-rotated.png")
)

# Left horizontal, a label wider than its tick spacing wraps onto more lines and
# the row grows to fit. Labels that already fit are unchanged.
render_plot(
vplot(gdp) |>
mark_bar(x = country, y = score) |>
labs(title = "Wrapped axis labels"),
file.path(outdir, "32-wrapped.png")
)

message("32-axis-labels: wrote rotated + wrapped bar charts to ", outdir)
Loading
Loading