Skip to content

Commit b3ef39a

Browse files
authored
Merge pull request #33 from humanpred/claude/vigilant-joliot-c1db27
Auto-render every page for ggforce paginated facets (issue 2)
2 parents 36dc455 + 2a79d5f commit b3ef39a

8 files changed

Lines changed: 269 additions & 0 deletions

File tree

DESCRIPTION

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Roxygen: list(markdown = TRUE)
1414
Depends: R (>= 4.3)
1515
Suggests:
1616
ggbreak,
17+
ggforce,
1718
quarto,
1819
rmarkdown,
1920
spelling,

NAMESPACE

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ S3method("%+%",default)
44
S3method("%+%",gglist)
55
S3method("%+%",ggtibble)
66
S3method(Ops,ggtibble)
7+
S3method(as_gglist,"NULL")
8+
S3method(as_gglist,default)
9+
S3method(as_gglist,gg)
10+
S3method(as_gglist,gglist)
11+
S3method(as_gglist,labels)
12+
S3method(as_gglist,list)
713
S3method(chooseOpsMethod,gglist)
814
S3method(chooseOpsMethod,ggtibble)
915
S3method(format,gglist)
@@ -25,6 +31,7 @@ S3method(vec_arith.gglist,labels)
2531
S3method(vec_arith.gglist,list)
2632
S3method(vec_arith.gglist,uneval)
2733
export("%+%")
34+
export(as_gglist)
2835
export(gglist)
2936
export(ggplot)
3037
export(ggsave)

NEWS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# ggtibble 1.0.2.9000
22

3+
* New exported S3 generic `as_gglist()` (methods for `gg`, `list`, `gglist`,
4+
`labels`, and `NULL`) that promotes an input to a `gglist`. When the input
5+
uses `ggforce::facet_wrap_paginate()` or `ggforce::facet_grid_paginate()`,
6+
`as_gglist()` expands the paginated plot into one element per rendered
7+
page so it can be passed to `print()`, `knit_print()`, or `ggsave()` and
8+
every page will render. Page expansion is opt-in — render methods are
9+
unchanged and do not call `as_gglist()` implicitly. `ggforce` is added to
10+
Suggests (issue 2).
311
* New `ggtibble` knitr chunk option that simplifies rendering a `ggtibble` in
412
R Markdown and Quarto reports. Setting `ggtibble = "my_obj"` (or
513
`ggtibble = my_obj`) on a chunk auto-sets the chunk label, `fig.cap`, and

R/as_gglist.R

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#' Convert an object to a `gglist`
2+
#'
3+
#' Promotes an input value to a `gglist`. When the input includes a `gg`
4+
#' object that uses `ggforce::facet_wrap_paginate()` or
5+
#' `ggforce::facet_grid_paginate()`, the paginated plot is expanded into one
6+
#' `gglist` element per rendered page so subsequent calls to `print()`,
7+
#' `knit_print()`, or `ggsave()` render every page. Inputs that already
8+
#' use a non-paginated facet (or no facet at all) pass through unchanged.
9+
#'
10+
#' For an input that is already a `gglist`, the value is returned unchanged
11+
#' so the method is a no-op when nothing needs to be coerced. Call this
12+
#' yourself before rendering when you want paginated facets expanded — the
13+
#' render methods do not call it implicitly because page expansion is not
14+
#' always desired.
15+
#'
16+
#' @param x A `gg`, `gglist`, list of `gg` objects, `NULL`, or `labels`
17+
#' object to convert.
18+
#' @param ... Not used.
19+
#' @return A `gglist` object.
20+
#' @examples
21+
#' p <- ggplot2::ggplot(mtcars, ggplot2::aes(mpg, wt)) + ggplot2::geom_point()
22+
#' as_gglist(p)
23+
#' @export
24+
as_gglist <- function(x, ...) {
25+
UseMethod("as_gglist")
26+
}
27+
28+
#' @export
29+
as_gglist.default <- function(x, ...) {
30+
rlang::abort(
31+
paste0("No `as_gglist()` method for class <", class(x)[1], ">")
32+
)
33+
}
34+
35+
#' @export
36+
as_gglist.gg <- function(x, ...) {
37+
new_gglist(gg_to_pages(x))
38+
}
39+
40+
#' @export
41+
as_gglist.list <- function(x, ...) {
42+
expanded <- unlist(
43+
lapply(x, function(el) {
44+
if (inherits(el, "gg")) gg_to_pages(el) else list(el)
45+
}),
46+
recursive = FALSE
47+
)
48+
new_gglist(expanded)
49+
}
50+
51+
#' @export
52+
as_gglist.gglist <- function(x, ...) {
53+
x
54+
}
55+
56+
#' @export
57+
as_gglist.labels <- function(x, ...) {
58+
new_gglist(list(x))
59+
}
60+
61+
#' @method as_gglist NULL
62+
#' @export
63+
`as_gglist.NULL` <- function(x, ...) {
64+
new_gglist(list(NULL))
65+
}

R/pagination.R

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Internal helpers used by `as_gglist()` to expand ggforce paginated facets.
2+
3+
is_paginated <- function(plot) {
4+
inherits(plot, "gg") &&
5+
inherits(plot$facet, c("FacetWrapPaginate", "FacetGridPaginate"))
6+
}
7+
8+
n_pages_for_plot <- function(plot) {
9+
if (!is_paginated(plot)) {
10+
return(1L)
11+
}
12+
rlang::check_installed("ggforce", reason = "to render paginated facets")
13+
np <- ggforce::n_pages(plot)
14+
if (is.null(np)) 1L else as.integer(np)
15+
}
16+
17+
gg_to_pages <- function(plot) {
18+
if (!is_paginated(plot)) {
19+
return(list(plot))
20+
}
21+
np <- n_pages_for_plot(plot)
22+
# `plot$facet` is a ggproto object (reference semantics); modifying its
23+
# `params$page` field mutates the shared facet for every reference to it.
24+
# Deep-clone the plot per page so the returned list elements render
25+
# independently.
26+
lapply(seq_len(np), function(i) {
27+
p <- unserialize(serialize(plot, NULL))
28+
p$facet$params$page <- i
29+
p
30+
})
31+
}

inst/WORDLIST

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ labelled
1616
pictex
1717
png
1818
ps
19+
renderable
1920
sprintf
2021
svg
2122
tex

man/as_gglist.Rd

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/testthat/test-pagination.R

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Tests for as_gglist() and the ggforce paginated-facet expansion it performs.
2+
3+
make_paginated_plot <- function() {
4+
# 3 cyl levels, ncol=1, nrow=1 -> 3 pages
5+
ggplot2::ggplot(mtcars, ggplot2::aes(mpg, wt)) +
6+
ggplot2::geom_point() +
7+
ggforce::facet_wrap_paginate(~ cyl, ncol = 1, nrow = 1)
8+
}
9+
10+
make_plain_plot <- function() {
11+
ggplot2::ggplot(mtcars, ggplot2::aes(mpg, wt)) +
12+
ggplot2::geom_point()
13+
}
14+
15+
# is_paginated ####
16+
17+
test_that("is_paginated identifies paginated and non-paginated plots", {
18+
expect_false(is_paginated(make_plain_plot()))
19+
expect_false(is_paginated("not a plot"))
20+
expect_false(is_paginated(NULL))
21+
skip_if_not_installed("ggforce")
22+
expect_true(is_paginated(make_paginated_plot()))
23+
})
24+
25+
# n_pages_for_plot ####
26+
27+
test_that("n_pages_for_plot returns 1 for non-paginated plots", {
28+
expect_identical(n_pages_for_plot(make_plain_plot()), 1L)
29+
})
30+
31+
test_that("n_pages_for_plot returns the actual page count for paginated plots", {
32+
skip_if_not_installed("ggforce")
33+
expect_identical(n_pages_for_plot(make_paginated_plot()), 3L)
34+
})
35+
36+
# gg_to_pages ####
37+
38+
test_that("gg_to_pages returns list(plot) for non-paginated plots", {
39+
p <- make_plain_plot()
40+
result <- gg_to_pages(p)
41+
expect_length(result, 1)
42+
expect_identical(result[[1]], p)
43+
})
44+
45+
test_that("gg_to_pages expands paginated plot to one element per page", {
46+
skip_if_not_installed("ggforce")
47+
p <- make_paginated_plot()
48+
result <- gg_to_pages(p)
49+
expect_length(result, 3)
50+
pages_used <- vapply(result, function(pl) pl$facet$params$page, integer(1))
51+
expect_identical(pages_used, 1:3)
52+
})
53+
54+
# as_gglist ####
55+
56+
test_that("as_gglist.default errors", {
57+
expect_error(as_gglist(1L), regexp = "No `as_gglist\\(\\)` method for class")
58+
})
59+
60+
test_that("as_gglist.NULL wraps NULL in a length-1 gglist", {
61+
result <- as_gglist(NULL)
62+
expect_s3_class(result, "gglist")
63+
expect_length(result, 1)
64+
expect_null(result[[1]])
65+
})
66+
67+
test_that("as_gglist.labels wraps a labels object", {
68+
result <- as_gglist(ggplot2::labs(x = "foo"))
69+
expect_s3_class(result, "gglist")
70+
expect_length(result, 1)
71+
})
72+
73+
test_that("as_gglist.gg wraps a non-paginated plot as a length-1 gglist", {
74+
result <- as_gglist(make_plain_plot())
75+
expect_s3_class(result, "gglist")
76+
expect_length(result, 1)
77+
})
78+
79+
test_that("as_gglist.gg expands a paginated plot to one element per page", {
80+
skip_if_not_installed("ggforce")
81+
result <- as_gglist(make_paginated_plot())
82+
expect_s3_class(result, "gglist")
83+
expect_length(result, 3)
84+
})
85+
86+
test_that("as_gglist.list works with a list of plain plots", {
87+
result <- as_gglist(list(make_plain_plot(), make_plain_plot()))
88+
expect_s3_class(result, "gglist")
89+
expect_length(result, 2)
90+
})
91+
92+
test_that("as_gglist.list expands paginated elements inline", {
93+
skip_if_not_installed("ggforce")
94+
result <- as_gglist(list(make_plain_plot(), make_paginated_plot()))
95+
expect_s3_class(result, "gglist")
96+
expect_length(result, 4)
97+
})
98+
99+
test_that("as_gglist.gglist is identity (idempotent for non-paginated)", {
100+
g <- new_gglist(list(make_plain_plot(), make_plain_plot()))
101+
expect_identical(as_gglist(g), g)
102+
})
103+
104+
test_that("as_gglist.gglist is identity even when elements are paginated", {
105+
skip_if_not_installed("ggforce")
106+
g <- new_gglist(list(make_plain_plot(), make_paginated_plot()))
107+
expect_identical(as_gglist(g), g)
108+
expect_identical(as_gglist(as_gglist(g)), g)
109+
})
110+
111+
# Render integration through as_gglist ####
112+
113+
test_that("ggsave() saves every page when the gglist is first passed through as_gglist", {
114+
skip_if_not_installed("ggforce")
115+
withr::with_tempdir({
116+
flat <- as_gglist(make_paginated_plot())
117+
ggsave(filename = c("p1.png", "p2.png", "p3.png"), plot = flat, width = 4, height = 3)
118+
expect_setequal(list.files(pattern = "\\.png$"), c("p1.png", "p2.png", "p3.png"))
119+
})
120+
})

0 commit comments

Comments
 (0)