Arranging hex stickers in R

Neatly aligning hex stickers for presentations or reports is a tedious process. It is repetitive, and very easy to get wrong. Despite the physical hexagons having a standardised shape and size, digital hexagons appear in many forms. With variation in file type, resolution, transparency and layouts, working with hexagon files can prove challenging.

The code in this blog is bundled into the hexwall R script (available at mitchelloharawild/hexwall), making it simpler to arrange your stickers.

My need for this functionality began with the creation of a hex sticker feature wall at useR! 2018. This involved arranging approximately 200 stickers in the shape of Australia, which you can read about in this blog post.

In that project we collected the stickers via email, but for this blog we will use the RStudio hex-stickers from their GitHub repository (rstudio/hex-stickers).

hex_folder <- "hex_files"

download.file(
  url = "https://github.com/rstudio/hex-stickers/archive/master.zip",
  destfile = file.path(hex_folder, "rstudio-hex.zip")
)

unzip(
  zipfile = file.path(hex_folder, "rstudio-hex.zip"),
  exdir = hex_folder
)

path <- file.path(hex_folder, "hex-stickers-master", "PNG")

# Remove the non-image files
unlink(list.files(path, full.names = TRUE)[tools::file_ext(list.files(path)) %in% c("md", "Rmd")])

list.files(path)
#>  [1] "blogdown.png"       "bookdown.png"       "broom.png"         
#>  [4] "covr.png"           "dbplot.png"         "devtools.png"      
#>  [7] "dplyr.png"          "feather.png"        "forcats.png"       
#> [10] "fs.png"             "ggplot2.png"        "glue.png"          
#> [13] "googledrive.png"    "googlesheets.png"   "haven.png"         
#> [16] "hms.png"            "knitr.png"          "lobstr.png"        
#> [19] "lubridate.png"      "modeldb.png"        "modelr.png"        
#> [22] "parsnip.png"        "pipe.png"           "pkgdown.png"       
#> [25] "plumber-female.png" "purrr.png"          "r2d3.png"          
#> [28] "readr.png"          "readxl.png"         "recipes.png"       
#> [31] "reprex.png"         "rlang.png"          "rmarkdown.png"     
#> [34] "roxygen2.png"       "rsample.png"        "RStudio.png"       
#> [37] "rvest.png"          "scales.png"         "shiny.png"         
#> [40] "sparklyr.png"       "stringr.png"        "testthat.png"      
#> [43] "tibble.png"         "tidymodels.png"     "tidyposterior.png" 
#> [46] "tidypredict.png"    "tidyr.png"          "tidyverse.png"     
#> [49] "usethis.png"        "vctrs.png"          "withr.png"         
#> [52] "xaringan.png"       "yardstick.png"

Preparation

Before we start aligning the hexagons, we first need to convert them to a common size and format. In most cases, the conversion can be done automatically with the ROpenSci magick package.

All images can be read with image_read, however better quality for svg and png formats can be obtained using their specific reading functions. As many stickers had white backgrounds (especially pdf format images), I first convert white to transparent, and then use image_trim to automatically crop the images.

library(magick)
library(purrr)

sticker_files <- list.files(path)
stickers <- file.path(path, sticker_files) %>% 
  map(function(path){
    switch(tools::file_ext(path),
           svg = image_read_svg(path),
           pdf = image_read_pdf(path),
           image_read(path))
  }) %>%
  map(image_transparent, "white") %>%
  map(image_trim) %>%
  set_names(sticker_files)

Unfortunately some stickers can not be fixed automatically, as they were either too low resolution or incorrectly shaped. These cases are easily identifiable for manual fixing from the image information provided by image_info().

# Desired sticker resolution in pixels
sticker_width <- 121

# Scale all stickers to the desired pixel width
stickers <- stickers %>%
  map(image_scale, sticker_width)
  
# Identify low resolution stickers
stickers %>%
    map_lgl(~ with(image_info(.x),
                   width < (sticker_width-1)/2 && format != "svg")
    )

# Identify incorrect shapes / proportions (tolerance of +-2 height)
stickers %>%
    map_lgl(~ with(image_info(.x),
                   height < (median(height)-2) | height > (median(height) + 2))
    )

As some stickers may have slightly different proportions (wihin tolerances above), we first force the stickers to have identical dimensions for alignment.

# Extract correct sticker height (this could also be calculated directly from width)
sticker_height <- stickers %>%
  map(image_info) %>%
  map_dbl("height") %>%
  median

# Coerce sticker dimensions
stickers <- stickers %>%
  map(image_resize, paste0(sticker_width, "x", sticker_height, "!"))

stickers[["tidyverse.png"]]

Alignment

Using magick, it is relatively trivial to align hexagons into rows using magick’s image_append() functionality. The total hexagons in each row alternates between odd and even numbers.

sticker_row_size <- 9
# Calculate row sizes
sticker_col_size <- ceiling(length(stickers)/(sticker_row_size-0.5))
row_lens <- rep(c(sticker_row_size,sticker_row_size-1), length.out=sticker_col_size)
row_lens[length(row_lens)] <- row_lens[length(row_lens)]  - (length(stickers) - sum(row_lens))

sticker_rows <- map2(row_lens, cumsum(row_lens),
                     ~ seq(.y-.x+1, by = 1, length.out = .x)) %>%
  map(~ stickers[.x] %>%
        invoke(c, .) %>%
        image_append)

sticker_rows[[1]]

To simplify the placement of these sticker rows, we can first create a white canvas for us to place sticker rows on. The width is simple to calculate, but the height is slightly more complex. The extra height of each row is the height of the left side of the hexagon (approximately sticker_height/1.33526), then to add the angled parts of the hexagon for the top and bottom, we add the height of one whole sticker.

# Add stickers to canvas
canvas <- image_blank(sticker_row_size*sticker_width, 
                      sticker_height + (sticker_col_size-1)*sticker_height/1.33526,
                      "white")

With our canvas and list of sticker_rows, it is time to add them to the final image using image_composite. The offset of the rows is calculated from the current row number (..3), which is used to add the current row (..2) to the canvas (..1).

reduce2(sticker_rows, seq_along(sticker_rows), 
        ~ image_composite(
          ..1, ..2,
          offset = paste0("+", ((..3-1)%%2)*sticker_width/2,
                          "+", round((..3-1)*sticker_height/1.33526))
        ),
        .init = canvas)

hexwall

Now that you know how it works, try it out on your own hexagon stickers. You can use the code above, or the script available on Github (mitchelloharawild/hexwall). To use the hexwall script on this example, you would use the hexwall function like this:

source("hexwall.R")
hexwall("path", sticker_row_size = 9, sticker_width = 121)
comments powered by Disqus