Recent experiments with svg have motivated me to revisit an old idea: how to arrange plots (or other graphical objects) in a more intuitive, hands-on manner?
Part of my motivation is to complement rather than bypass the myriad existing tools. Grid itself has provided a relatively flexible framework, and higher-level interfaces have sprouted over the years: gridExtra::grid.arrange() (originally arrange() around 10 years ago), align.plots() in a defunct package ggExtra, gtable, egg::ggarrange() more recently, as well as cowplot and patchwork, etc.
It’s relatively easy to place plots, aligned or not, in a grid layout, possibly nested. But this supposes a few things, implicitly:
the user has a mental picture of the plot arrangement
the final figure’s dimensions can be sized to accommodate the content
plot panels should be free to expand and fill the available space
(I’m ignoring the special case of plots with fixed aspect ratios at this stage)
In some use-cases though, we might want to arrange graphical objects in a defined space, at predefined locations dictated by aesthetic judgment, interaction with the surrounding graphical context (e.g. a presentation slide, a flyer, etc.) More generally, could we conceive a hybrid system mixing repeatable automated layout, with a more immediate-feedback drawing application? I started this experiment with this goal in mind.
Let’s start with a hand-drawn layout with three rectangles,
We assign id='plot1..3' to each <rect> element, with a svg file that looks like this,
Note that I specified a document width and height in mm; I’ll use mm throughout.
Importing the svg into R
minisvg can parse an svg and store it in its OO model (tree of R6 objects), from which I extract the various rect attributes into a tibble and convert dimensions to numeric.
The positions and sizes can be directly mapped into grid viewports (again interpreting all dimensions in mm),
library(grid)make_viewport <-function(x, y, width, height, id, ...){viewport(x =unit(x,'mm'),y =unit(1,'npc') -unit(height,'mm') -unit(y,'mm'),width=unit(width,'mm'),height=unit(height,'mm'), just =c(0,0),clip ='off', name = id)}v$vp <-pmap(v, make_viewport)v
# A tibble: 3 × 6
x y width height id vp
<dbl> <dbl> <dbl> <dbl> <chr> <list>
1 19.1 18.6 156. 77.4 plot1 <viewport>
2 69.1 119. 106. 73.8 plot2 <viewport>
3 19.1 119. 29.9 73.8 plot3 <viewport>
Drawing into viewports
We could simply print ggplots into these viewports with print(p, vp=) but this would place the entire plot in the defined regions. Instead, I’m interested in placing the plot panel (panels, for facetted plots) in the pre-specified areas. This is where egg::gtable_frame() comes in handy: it converts a ggplot into a standardised 3x3 gtable, where the core cell (2,2) contains the plot panel(s), and the surrounding cells wrap other elements such as axes, legends, titles, etc. This is the low-level function used by egg::ggarrange() to align multiple plots. Here we call egg::gtable_frame() on a list of plots,
library(palmerpenguins)library(ggplot2)p1 <-ggplot(data = penguins, aes(x = flipper_length_mm,y = body_mass_g)) +geom_point(aes(color = species, shape = species),size =3,alpha =0.8) +scale_color_manual(values =c("darkorange","purple","cyan4")) +labs(title ="Penguin size, Palmer Station LTER",subtitle ="Flipper length and body mass",x ="Flipper length (mm)",y ="Body mass (g)",color ="Penguin species",shape ="Penguin species") +theme(legend.position =c(0.2, 0.7),plot.caption =element_text(hjust =0, face="italic"),plot.caption.position ="plot")p2 <-ggplot(data = penguins, aes(x = flipper_length_mm)) +geom_histogram(aes(fill = species), alpha =0.5, position ="identity") +scale_fill_manual(values =c("darkorange","purple","cyan4")) +labs(x ="Flipper length (mm)",y ="Frequency",title ="Penguin flipper lengths")p3 <-ggplot(data = penguins, aes(x = species, y = flipper_length_mm)) +geom_boxplot(aes(color = species), width =0.3, show.legend =FALSE) +geom_jitter(aes(color = species), alpha =0.5, show.legend =FALSE, position =position_jitter(width =0.2, seed =0)) +scale_color_manual(values =c("darkorange","purple","cyan4")) +labs(x ="Species",y ="Flipper length (mm)")p4 <-ggplot(penguins, aes(x = flipper_length_mm,y = body_mass_g)) +geom_point(aes(color = sex)) +scale_color_manual(values =c("darkorange","cyan4"), na.translate =FALSE) +labs(title ="Penguin flipper and body mass",subtitle ="Dimensions for male and female Adelie, Chinstrap and Gentoo Penguins at Palmer Station LTER",x ="Flipper length (mm)",y ="Body mass (g)",color ="Penguin sex") +theme(legend.position ="bottom",plot.title.position ="plot",plot.caption =element_text(hjust =0, face="italic"),plot.caption.position ="plot") +facet_wrap(~species)pl <-list(p1,p2,p3)gl <-lapply(pl, ggplotGrob) # convert to a gtablelibrary(egg)gl <-lapply(gl, gtable_frame, debug =TRUE)gridExtra::grid.arrange(grobs=gl, nrow=1)
(the debug=TRUE argument draws fine lines to show the 3x3 cells)
Each frame will be drawn into a viewport sized and offset to accomodate the side annotations and place the panel in the requested area,
The last step is to call the function on each plot and associated viewport,
walk2(gl, v$vp, draw_frame)
Further thoughts
It seems reasonably straight-forward to place ggplot2 not according to a rigid grid but at user-specified locations. Relative alignment of panels is obtained by aligning the edges of the drawn rectangles, which drawing programs make easy to do with “snap-to-grid” and magnetic handles of various kinds. Unlike gtable-based layouts, plots can be placed anywhere; we gain the freedom of general grid viewports, and keep the possibility of relative constraints.
The next obvious step is to iterate: add the output plot as a fixed background layer in the original svg layout file, and adjust the layout to taste (e.g. avoiding inelegant clashes between labels, etc.). This will require filtering out this background element from the parsed svg file.