Search code examples
rggplot2gridextrar-grid

How to get dimensions of a plot including out of bounds objects


I can calculate the height of a plot like this:

library(ggplot2)
library(egg)
library(gridExtra)

g <- ggplot(iris, aes(x = Species, y = Petal.Length)) +
  stat_summary(geom = 'bar', fun.y = mean) +
  geom_point() +
  scale_y_continuous(limits = c(0,8), expand = c(0,0), oob = function(x, ...) x)

gt <- egg::set_panel_size(g)
gt$layout$clip[gt$layout$name=="panel"] <- "off"
gridExtra::grid.arrange(gt)

sum(as.numeric(grid::convertUnit(gt$heights, "mm")))

But if I have some geom object that is out of bounds, it returns the same height:

g <- ggplot(iris, aes(x = Species, y = Petal.Length)) +
  stat_summary(geom = 'bar', fun.y = mean) +
  geom_point() +
  scale_y_continuous(limits = c(0,8), expand = c(0,0), oob = function(x, ...) x) +
  geom_text(label = 'obText', aes(x = 2, y = 8.5))

gt <- egg::set_panel_size(g)
gt$layout$clip[gt$layout$name=="panel"] <- "off"
gridExtra::grid.arrange(gt)

sum(as.numeric(grid::convertUnit(gt$heights, "mm")))

even though there is now text that is positioned higher than the 53.35411mm.

Is there a way to get the height of the plot including this out of bounds text?


Solution

  • I'm not sure what's your use case, but yes, it's possible.

    The crux of the matter is that the height of the text (or any other geom layer) is not captured in gt$heights, but in the height parameters of the respective grobs (expressed as .$x or $height) further down the nested hierarchy.

    # same code as yours, except that I positioned the label ever further up, to increase the contrast
    g <- ggplot(iris, aes(x = Species, y = Petal.Length)) +
      stat_summary(geom = 'bar', fun.y = mean) +
      geom_point() +
      scale_y_continuous(limits = c(0,8), expand = c(0,0), oob = function(x, ...) x) +
      geom_text(label = 'obText', aes(x = 2, y = 18.5))    
    
    gt <- egg::set_panel_size(g)
    

    Take a look at gt$heights. We can verify that all the height values remain unchanged, regardless whether the panel's clipping has been turned off:

    > gt$heights
     [1] 5.5pt                    0cm                      0cm                     
     [4] 0cm                      0cm                      0cm                     
     [7] 4cm                      sum(2.75pt, 1grobheight) 1grobheight             
    [10] 0cm                      0pt                      5.5pt    
    
    gt$layout$clip[gt$layout$name=="panel"] <- "off"
    
    > gt$heights
     [1] 5.5pt                    0cm                      0cm                     
     [4] 0cm                      0cm                      0cm                     
     [7] 4cm                      sum(2.75pt, 1grobheight) 1grobheight             
    [10] 0cm                      0pt                      5.5pt   
    

    Among all the values above, the one you should care about is [7] 4cm, because that's the height of the panel. We know that because based on gt's layout, the panel is in the 7th row & 5th column, which can be verified by examining the console printout of gt itself, or through gtable_show_layout() from the gtable package:

    > gt
    TableGrob (12 x 9) "layout": 18 grobs
        z         cells       name                                           grob
    1   0 ( 1-12, 1- 9) background               rect[plot.background..rect.3020]
    2   5 ( 6- 6, 4- 4)     spacer                                 zeroGrob[NULL]
    3   7 ( 7- 7, 4- 4)     axis-l           absoluteGrob[GRID.absoluteGrob.3008]
    4   3 ( 8- 8, 4- 4)     spacer                                 zeroGrob[NULL]
    5   6 ( 6- 6, 5- 5)     axis-t                                 zeroGrob[NULL]
    6   1 ( 7- 7, 5- 5)      panel                      gTree[panel-1.gTree.2994]
    7   9 ( 8- 8, 5- 5)     axis-b           absoluteGrob[GRID.absoluteGrob.3001]
    8   4 ( 6- 6, 6- 6)     spacer                                 zeroGrob[NULL]
    9   8 ( 7- 7, 6- 6)     axis-r                                 zeroGrob[NULL]
    10  2 ( 8- 8, 6- 6)     spacer                                 zeroGrob[NULL]
    11 10 ( 5- 5, 5- 5)     xlab-t                                 zeroGrob[NULL]
    12 11 ( 9- 9, 5- 5)     xlab-b titleGrob[axis.title.x.bottom..titleGrob.3011]
    13 12 ( 7- 7, 3- 3)     ylab-l   titleGrob[axis.title.y.left..titleGrob.3014]
    14 13 ( 7- 7, 7- 7)     ylab-r                                 zeroGrob[NULL]
    15 14 ( 4- 4, 5- 5)   subtitle         zeroGrob[plot.subtitle..zeroGrob.3016]
    16 15 ( 3- 3, 5- 5)      title            zeroGrob[plot.title..zeroGrob.3015]
    17 16 (10-10, 5- 5)    caption          zeroGrob[plot.caption..zeroGrob.3018]
    18 17 ( 2- 2, 2- 2)        tag              zeroGrob[plot.tag..zeroGrob.3017]
    
    > gtable::gtable_show_layout(gt)
    

    plot

    To get the height of individual geom layers, we can dig deeper to look at the children grobs of the panel grob:

    > gt$grobs[[which(gt$layout$name == "panel")]]$children
    (gTree[grill.gTree.2992], zeroGrob[NULL], rect[geom_rect.rect.2978], 
    points[geom_point.points.2980], text[GRID.text.2981], zeroGrob[NULL], 
    zeroGrob[panel.border..zeroGrob.2982]) 
    

    In this case, we know (since the example's created that way) that the offending geom is the text layer, so we can go straight to the 5th children grob and look at the height(s) there:

    > gt$grobs[[which(gt$layout$name == "panel")]]$children[[5]]$y
    [1] 2.3125native 2.3125native 2.3125native 2.3125native 2.3125native 2.3125native
    [7] 2.3125native 2.3125native 2.3125native 2.3125native 2.3125native 2.3125native
    ...
    

    Referencing the ?unit help file from the grid package, the "native" coordinate system means that the measurements are relative to the viewport's xscale & yscale. Hence 2.3125native can be interpreted as 2.3125 x panel height (4cm) = 9.25cm.

    More generally, to get the height limits in both directions:

    # rect grobs such as those created by geom_bar() have "height" / "width" measurements,
    # while point & text grobs have "y" / "x" measurements, & we look for both
    max.grob.heights <- sapply(gt$grob[[which(gt$layout$name == "panel")]]$children,
                           function(x) ifelse(!is.null(x$height) & "unit" %in% class(x$height),
                                              max(as.numeric(x$height)),
                                              ifelse(!is.null(x$y) & "unit" %in% class(x$y),
                                                     max(as.numeric(x$y)),
                                                     0)))
    max.grob.heights = max(max.grob.heights)
    
    min.grob.heights <- sapply(gt$grob[[which(gt$layout$name == "panel")]]$children,
                               function(x) ifelse(!is.null(x$height) & "unit" %in% class(x$height),
                                                  min(as.numeric(x$height)),
                                                  ifelse(!is.null(x$y) & "unit" %in% class(x$y),
                                                         min(as.numeric(x$y)),
                                                         0)))
    min.grob.heights = min(min.grob.heights)
    
    # identify panel row & calculate panel height
    panel.row <- gt$layout[gt$layout$name == "panel", "t"] # = 7
    panel.height <- as.numeric(grid::convertUnit(gt$heights[panel.row],"mm"))
    

    If you just want the height of the panel component including all geom layers (& don't care how they relate to the overall grob object's combined height), you can use the max / min grob heights as a multiplier for the panel height:

    panel.multiplier <- max(1, max.grob.heights) + abs(min.grob.heights)
    result <- panel.multiplier * panel.height
    

    If you want the calculate the overall height of the plot, you'll have to separately compare the height of the top / bottom out-of-bounds objects: if they are within the confines of the plot, use the original heights; if they exceed that, use their heights instead.

    # calculate height of all the grobs above the panel
    height.above.panel <- gt$heights[1:(panel.row - 1)]
    height.above.panel <- sum(as.numeric(grid::convertUnit(height.above.panel, "mm")))
    
    # check whether the out-of-bound object (if any) exceeds this height, & replace if necessary
    if(max.grob.heights > 1){
      oob.height.above.panel <- (max.grob.heights - 1) * panel.height
      height.above.panel <- max(height.above.panel, oob.height.above.panel)
    }
    
    # as above, calculate the height of all the grobs below the panel
    height.below.panel <- gt$heights[(panel.row + 1):length(gt$heights)]
    height.below.panel <- sum(as.numeric(grid::convertUnit(height.below.panel, "mm")))
    
    # as above
    if(min.grob.heights < 0){
      oob.height.below.panel <- abs(min.grob.heights) * panel.height
      height.below.panel <- max(height.below.panel, oob.height.below.panel)
    }
    
    # sum the result
    result <- height.above.panel + panel.height + height.below.panel