ggfoundry 0.3.1

R
package
Leafier and leaner
Author

Carl Goodwin

Published

July 7, 2024

A pair of balancing scales hold the lighter ggfoundry hex on the left scale and the heavier pair of leaves on the right scale.

ggfoundry offers arbitrary colourable and fillable shapes for ggplot2. A showcase of examples, how it compares to other approaches and a list of available shapes, are all covered on the package website.

The theme of ggfoundry 0.3.1:

Leafier

ggfoundry’s display_palette() presents the loaded palette in paint pots.

pal_name <- "Custom Palette"

pal <- c("#11424B", "#F8715F", "#EC974C", "#A2C26A", "#49786A")

display_palette(pal, pal_name)

Fillable leaves can enhance the visual impact of a dendrogram and help draw attention to particular clusters.

geom_casting() plots a shape and fill for each cluster with the appropriate coordinates and angles. scale_shape_manual() specifies the chosen shapes.

data <- hclust(dist(USArrests), "ave") |>
  dendro_data(type = "rectangle")

cluster <- hclust(dist(USArrests), "ave") |>
  cutree(k = 2) |>
  as_tibble(rownames = "label") |>
  mutate(cluster = factor(value), .keep = "unused")

num_leaves <- nrow(data$labels)
offset <- 15 # Degrees by which the first branch is rotated
from <- 90 - offset # First text label
by <- -(360 - (2 * offset)) / (num_leaves - 1) # Degrees between labels

leaves <- data$labels |>
  mutate(
    angle = seq(from = from, by = by, length.out = num_leaves),
    shape_angle = seq(from + 90, by = by, length.out = num_leaves),
  ) |>
  left_join(cluster, join_by(label))

ggplot() +
  geom_segment(
    aes(x = x, y = y, xend = xend, yend = yend),
    data = segment(data)
  ) +
  geom_text(
    aes(x = x, y = y, label = label, angle = angle),
    size = 3, hjust = 0, nudge_y = 20, data = leaves
  ) +
  geom_casting(
    aes(x, y, angle = shape_angle, group = x, fill = cluster, shape = cluster),
    vjust = 0.7, size = 0.08, data = leaves
  ) +
  scale_y_reverse() +
  scale_fill_manual(values = c(pal[2], pal[4])) +
  scale_shape_manual(values = c("oak", "hibiscus")) +
  labs(title = "Leafy Dendrogram") +
  coord_radial() +
  theme_dendro() + 
  theme(
    plot.title = element_text(hjust = 0.5),
    legend.key.size = unit(2, "line"),
    legend.position = "top"
    )

Two leaf types are currently supported: Hibiscus and oak. More could be added if needed.

Leaner

A reduction of >2MB in the shipped package size was achieved by:

  • Moving supplementary documentation (with plots) from vignettes to articles;
  • Switching snapshot tests from png files to ggplot layer data;
  • Running the svg-to-Picture conversion at build time.

Converting vignettes to articles is an easy win by moving them to a vignettes/articles sub-folder and ensuring .Rbuildignore contains ^vignettes/articles$ (it will already do so if usethis::use_article has been run). There’s also the potential to remove Suggests from the DESCRIPTION file if packages are now only used in articles.

Snapshot tests are a wonderful addition to testthat. The test takes a snapshot of something complex. The next time the test is run, if the output has changed, there’s an option to accept the change (if intentional) or fix it (if unintentional). Switching to snapshots of ggplot2::layer_data is lighter and png-free.

The R Packages book has a section understand when code is executed and advises carefully reviewing any code outside of a function. On-build does feel like the best time to make the svg-to-Picture conversion. Doing it outside of the package increases the shipped size. Doing it inside the plotting function affects performance. Doing it .onLoad() introduces a small (couple of seconds) delay in package load time.

package <- "ggfoundry"

build_ignore <- read_lines(str_c("~/", package, "/.Rbuildignore")) |>
  str_remove("\\$") |>
  str_c(collapse = "|")

df <-
  list.files(str_c("~/", package), recursive = TRUE, full.names = TRUE) |>
  map(file.info) |>
  bind_rows() |>
  as_tibble(rownames = "path") |>
  select(path, size) |>
  mutate(
    path = str_extract(path, str_c("(?<=", package, "/).*")),
    type = if_else(str_detect(path, build_ignore), "ignored", "shipped"),
  ) |>
  separate_wider_delim(
    path,
    delim = "/",
    names = c("lvl1", "lvl2", "lvl3", "lvl4", "lvl5"),
    too_few = "align_start",
    too_many = "drop",
    cols_remove = FALSE
  )

shipped_df <- df |>
  mutate(across(starts_with("lvl"), \(x) case_when(
    lvl3 %in% c("_snaps") ~ str_c(lvl2, "/", lvl3),
    lvl1 %in% c("inst") ~ str_c(lvl1, "/", lvl2),
    .default = x
  ))) |>
  summarise(size = sum(size), .by = c(lvl1, type)) |>
  arrange(type, desc(size)) |>
  filter(type == "shipped")

ship_size <- shipped_df |>
  summarise(size = sum(size)) |>
  pull()

theme_spiral <-
  theme_void() +
  theme(
    plot.title = element_text(hjust = 0.5),
    plot.subtitle = element_text(hjust = 0.5, margin = margin(0, 0, 30, 0)),
    plot.margin = unit(c(1, 0, 0, 0), "cm"),
    legend.position = "none"
  )

p1 <- shipped_df |>
  ggplot(aes(fct_reorder(lvl1, size), size)) +
  geom_col(aes(fill = if_else(lvl1 == "inst/extdata", pal[4], pal[3]))) +
  geom_textpath(
    aes(label = lvl1), size = 2, 
    offset = unit(-10, "pt"), angle = -40, hjust = 1
  ) +
  geom_textpath(
    aes(label = glue("{round(size / 1e3, 1)}")),
    vjust = 0, size = 3,
  ) +
  annotate("text", 0.5, 0.5, label = "KB", vjust = 2.2, colour = pal[5]) +
  scale_y_log10() +
  scale_fill_identity() +
  labs(
    title = glue("A leaner {package}"),
    subtitle = glue("{round(ship_size / 1e6, 2)}MB shipped")
  ) +
  coord_radial(inner.radius = 0.1) +
  theme_spiral

p2 <- df |>
  filter(lvl2 == "extdata") |>
  mutate(shape = str_extract(lvl3, "(?<=-).*(?=_)")) |>
  summarise(size = sum(size), .by = shape) |>
  ggplot(aes(fct_reorder(shape, size), size)) +
  geom_col(fill = pal[4]) +
  geom_textpath(
    aes(label = shape), size = 2, 
    offset = unit(-10, "pt"), angle = -70, hjust = 1
  ) +
  geom_textpath(
    aes(label = glue("{round(size / 1e3, 0)}")),
    vjust = 0, size = 2,
  ) +
  annotate("text", 0.5, 0.5, label = "KB", vjust = 2.2, colour = pal[5]) +
  labs(
    title = glue("{package} shapes"), 
    subtitle = "inst/extdata"
    ) +
  coord_radial(inner.radius = 0.1) +
  theme_spiral

p1 + p2

Overall, the 0.97MB shipped is comfortably within the <10MB CRAN policy (5 data + 5 documentation) and leaves plenty of scope to grow.

The shapes are the biggest contributor: inst/extdata contains the 28 pairs of svg files (outline and fill). But each pair is relatively small, ranging from 5 to 66KB determined by shape complexity: The baby dendrogram being the most complex.

The objects of class Picture (created at build time) are not exported. But use of the triple colon operator confirms these internal objects do exist.

names(ggfoundry:::picture_lst)
 [1] "circle-circleF_col-cairo"     "circle-circleF_fill-cairo"   
 [3] "circle-circleL_col-cairo"     "circle-circleL_fill-cairo"   
 [5] "circle-circleR_col-cairo"     "circle-circleR_fill-cairo"   
 [7] "container-jar_col-cairo"      "container-jar_fill-cairo"    
 [9] "container-tube_col-cairo"     "container-tube_fill-cairo"   
[11] "cross-cross1_col-cairo"       "cross-cross1_fill-cairo"     
[13] "cross-cross2_col-cairo"       "cross-cross2_fill-cairo"     
[15] "flower-sunflower1_col-cairo"  "flower-sunflower1_fill-cairo"
[17] "flower-sunflower2_col-cairo"  "flower-sunflower2_fill-cairo"
[19] "flower-sunflower3_col-cairo"  "flower-sunflower3_fill-cairo"
[21] "flower-sunflower4_col-cairo"  "flower-sunflower4_fill-cairo"
[23] "flower-sunflower5_col-cairo"  "flower-sunflower5_fill-cairo"
[25] "flower-sunflower6_col-cairo"  "flower-sunflower6_fill-cairo"
[27] "flower-sunflower7_col-cairo"  "flower-sunflower7_fill-cairo"
[29] "flower-sunflower8_col-cairo"  "flower-sunflower8_fill-cairo"
[31] "geom-box_col-cairo"           "geom-box_fill-cairo"         
[33] "geom-dendro_col-cairo"        "geom-dendro_fill-cairo"      
[35] "geom-ribbon_col-cairo"        "geom-ribbon_fill-cairo"      
[37] "geom-violin_col-cairo"        "geom-violin_fill-cairo"      
[39] "leaf-hibiscus_col-cairo"      "leaf-hibiscus_fill-cairo"    
[41] "leaf-oak_col-cairo"           "leaf-oak_fill-cairo"         
[43] "penguin-adelie_col-cairo"     "penguin-adelie_fill-cairo"   
[45] "penguin-chinstrap_col-cairo"  "penguin-chinstrap_fill-cairo"
[47] "penguin-gentoo_col-cairo"     "penguin-gentoo_fill-cairo"   
[49] "polygon-heptagon_col-cairo"   "polygon-heptagon_fill-cairo" 
[51] "polygon-hexagon_col-cairo"    "polygon-hexagon_fill-cairo"  
[53] "polygon-octagon_col-cairo"    "polygon-octagon_fill-cairo"  
[55] "polygon-pentagon_col-cairo"   "polygon-pentagon_fill-cairo"