Grid Graphics and Lattice
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. The page uses the grid package (version 4.6.1, a base package that ships with R) and the lattice package (version 0.22-9, a recommended package that ships with R and was published on CRAN on 2026-02-09). One example uses ggplot2 4.0.3 to show that its plots are grid objects. Four more add-on packages are named in a sentence each and not run, because they are not installed in the image used for the checks: latticeExtra, gridExtra, gridBase and gridGraphics. R has two graphics systems on top of one graphics engine: the older base system (Base Graphics and Devices) and grid, which describes a page as nested rectangular regions, called viewports, with several coordinate systems each, and draws objects into them. lattice implements Trellis graphics (many small plots of subsets of the data, laid out in a grid) on top of grid, and so does ggplot2 (ggplot2 and the Grammar of Graphics). This page explains the grid model first, because it is the machinery under the other two, and then lattice in detail.
Every example is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines that start with #> are what it printed. The scripts also pass the default lintr linters. The graphics are not reproduced here: a script that draws opens a file device first (svg(tempfile(fileext = ".svg")), or pdf(NULL)) and closes it at the end, so that Rscript writes no Rplots.pdf and no window is needed, and what the page shows is the structure of what was drawn: the viewport tree, the list of grobs from grid.ls(), unit values from convertUnit() and the components of a "trellis" object. The data are the library-shelf scenario described on the section index: a script that needs books and loans starts with source("scenario.R"). The lattice examples join the two data frames by ISBN, compute the loan duration in days (days) and keep the returned loans with a sensible duration, which leaves 50 rows with six genres, as in Statistical Models; the grid examples need no data. Where a message contains quotation marks, they are the typographic quotes R prints in a UTF-8 locale. Counters in automatically generated names (GRID.rect.1) are real output of a fresh session.
This page follows Base Graphics and Devices, which covers devices and the traditional functions, and precedes ggplot2 and the Grammar of Graphics. The formula that lattice reads is the one of Formulas, with one addition: the bar | that introduces the conditioning variables.
Two graphics systems on one engine
The layers
R Internals describes the structure in a few paragraphs (chapter 6, 6.1 Graphics Devices for the device layer). The graphics internals were redesigned so that several graphics systems can be installed on top of one graphics engine, and two exist: the base system (the R code of package graphics, 6.3 Base graphics) and the one implemented in package grid. At the bottom is the graphics device, which manages a plotting surface, a window or a file, and implements a small set of primitives: a circle, a rectangle, a line, connected lines, a polygon, a path, text and a raster image, plus a clipping rectangle, and answers questions such as the width of a string. Above it is the engine, which clips lines, rectangles and polygons, centres and adjusts text, renders plotmath expressions, maps colour names to the internal representation and manages display lists and snapshots. At the top are the graphics systems, base and grid. The engine has provision for 24 of them (since about 2001), but only these two exist. Base graphics records its operations on the engine’s display list as .Internal calls, and grid records its own as .Call.graphics calls.
The packages follow the layers. grDevices holds the devices (pdf(), svg(), png()) and the colour tools; graphics is the base system; grid is the other system. lattice is a recommended package: it ships with R, it is installed in the default library, and it depends only on the base packages. The first script shows the package priorities and what the lattice DESCRIPTION says it needs.
# grid and lattice both ship with R: grid is a base package, lattice a
# recommended one.
pkgs <- installed.packages(priority = c("base", "recommended"))
pkgs[
c("graphics", "grDevices", "grid", "lattice"),
c("Package", "Version", "Priority")
]
#> Package Version Priority
#> graphics "graphics" "4.6.1" "base"
#> grDevices "grDevices" "4.6.1" "base"
#> grid "grid" "4.6.1" "base"
#> lattice "lattice" "0.22-9" "recommended"
# lattice declares what it needs from the system.
desc <- packageDescription("lattice")
desc[c("Version", "Priority", "Depends", "Imports", "License")]
#> $Version
#> [1] "0.22-9"
#>
#> $Priority
#> [1] "recommended"
#>
#> $Depends
#> [1] "R (>= 4.0.0)"
#>
#> $Imports
#> [1] "grid, grDevices, graphics, stats, utils"
#>
#> $License
#> [1] "GPL (>= 2)"
#>
# grid is attached by hand: library(lattice) loads it but does not attach it.
"package:grid" %in% search()
#> [1] FALSE
library(lattice)
"package:grid" %in% search()
#> [1] FALSE
"grid" %in% loadedNamespaces()
#> [1] TRUE
library(lattice) loads the grid namespace but does not attach it. That is why the examples that call grid functions directly, such as grid.ls() or unit(), start with library(grid), while the lattice examples that only call lattice functions do not need it.
The section on grid in R Internals (6.4 Grid graphics) is a placeholder that only promises pointers to documentation. The documentation of grid is its help pages, listed under help(package = "grid"), and thirteen vignettes that ship with the package and that vignette(package = "grid") lists; a vignette is read with vignette("viewports", package = "grid") or its siblings. Paul Murrell’s book R Graphics, the author’s own account of grid, is community material and is cited here by name only.
# The documentation of grid ships with R as vignettes (PDF and source).
v <- vignette(package = "grid")$results
v[, "Item"]
#> [1] "moveline" "displaylist" "interactive" "frame" "grid"
#> [6] "locndimn" "sharing" "nonfinite" "saveload" "rotated"
#> [11] "grobs" "viewports" "plotexample"
v[v[, "Item"] %in% c("grid", "viewports", "grobs"), "Title"]
#> [1] "Introduction to grid (source, pdf)"
#> [2] "Working with grid grobs (source, pdf)"
#> [3] "Working with viewports (source, pdf)"
# lattice ships one vignette, about using grid code with lattice.
vignette(package = "lattice")$results[, "Item"]
#> [1] "grid"
Source: R documentation: Grid
Why grid exists
Base graphics draws into a single plotting region at a time. It has a state, the par() list, and each high-level function (plot(), hist()) draws a complete figure into a figure region it computes from par("mfrow"), par("mar") and friends. A drawn figure is pixels or vector commands; the only way to change a title is to draw the plot again. That model is simple and fast; chapter 12 of An Introduction to R describes its procedures, and the base graphics page shows how far it goes. grid was written to remove four limits.
-
Regions are objects. A viewport is a rectangular region of the device (or of another viewport) with its own coordinate systems. A figure is made of viewports inside viewports, and layouts divide a viewport into rows and columns that may contain further layouts.
-
Units are symbolic. A length can be given as a fraction of the region, in centimetres, in lines of text, or as the width of a string, and lengths can be added:
unit(1, "npc") - unit(2, "lines")is "the height of the region minus two lines". The sum is worked out when the thing is drawn, in the region and font that apply then. -
Graphical objects are R objects. A rectangle, a text label or a whole axis can be created without drawing (
rectGrob()), stored, combined in a tree (gTree()), edited and drawn. -
A drawn plot can be edited. Grid keeps its own display list of what was drawn, so
grid.edit()can change a drawn label or colour and redraw.
Lattice and ggplot2 use all four. Lattice’s panels, strips, axes and keys are grid viewports and grobs, which is why a lattice plot can be changed after it is drawn (the section on trellis.focus() below), and ggplot2’s ggplotGrob() returns a table of grobs. The cost is that grid is lower-level and more verbose than base graphics for a quick plot, and that it is a different system.
Grid and base graphics do not mix
The two systems share the engine and the device but not their state. The overview help page of the package, ?Grid, says in a single sentence that the two systems do not mix. Output from both can be put on the same page, and the script below does it, but the systems do not know about each other: base graphics does not know where grid put a viewport, grid does not list a base plot among its objects, and par(mfrow = ...) has no effect on grid or lattice output. The error "cannot pop the top-level viewport ('grid' and 'graphics' output mixed?)" is raised when popViewport() is called with nothing left to pop, and its wording points at the usual cause. The add-on package gridBase (not installed here and not run) provides functions to share a page between the two systems, and gridGraphics provides grid.echo(), which redraws a base graphics plot as grid objects so that it can be edited with grid tools; both are named here so that you know they exist, and their documentation is on CRAN.
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
library(grid)
svg(tempfile(fileext = ".svg"), width = 6, height = 4)
# Base graphics keeps its state in par(); grid keeps it in viewports.
old <- par(mfrow = c(1, 2))
par("mfrow")
#> [1] 1 2
plot(dur$pages, dur$days)
# grid output does not know about base graphics: it has its own display list.
grid.ls()
#>
grid.rect(name = "on_top")
grid.ls()
#> on_top
par(old)
# par() settings have no effect on a lattice plot.
width_of_panel <- \() {
trellis.focus("panel", 1, 1, highlight = FALSE)
w <- convertWidth(unit(1, "npc"), "inches", valueOnly = TRUE)
trellis.unfocus()
round(w, 2)
}
grid.newpage()
print(xyplot(days ~ pages, data = dur))
width_of_panel()
#> [1] 4.85
par(mfrow = c(2, 2), mar = c(1, 1, 1, 1))
grid.newpage()
print(xyplot(days ~ pages, data = dur))
width_of_panel()
#> [1] 4.85
par(old)
# base layout() returns the number of figures; grid.layout() returns an object.
n_figures <- layout(matrix(1:4, nrow = 2))
n_figures
#> [1] 4
class(grid.layout(2, 2))
#> [1] "layout"
invisible(dev.off())
Source: R documentation: Grid
The last lines of that script show the other trap in the vocabulary. Base graphics has a function called layout(), which splits the device into figure regions by a matrix of numbers and returns the number of figures; grid has grid.layout(), which returns an object that describes the rows and columns of a viewport. The help page of grid.layout() warns not to confuse them, and not to use the base layout() together with grid graphics.
Devices
Both systems draw on a graphics device (Base Graphics and Devices). With no device open, the first drawing call opens the default one. In an interactive session that is a window; under Rscript it is a PDF file called Rplots.pdf, which is why every script on this page opens an svg() device with a file in the session’s temporary directory first. svg() is a Cairo-based device, and it is a good test device for grid because it supports the newer engine features (below). The default size of an svg() or pdf() device is 7 by 7 inches, and the conversions in later examples show it.
# Nothing is drawn on a screen here: open a file device first, close it last.
dev.cur()
#> null device
#> 1
svg(tempfile(fileext = ".svg"))
dev.cur()
#> svg
#> 2
dev.size("in")
#> [1] 7 7
library(grid)
grid.rect()
grid.ls()
#> GRID.rect.1
dev.off()
#> null device
#> 1
dev.cur()
#> null device
#> 1
Source: R documentation: svg
Viewports
What a viewport is
viewport() describes a rectangular region of its parent, which is the device at the top level or another viewport. The object is only a description: it has no effect until it is pushed on the viewport tree. The arguments below are the ones in viewport(x, y, width, height, default.units, just, gp, clip, mask, xscale, yscale, angle, layout, layout.pos.row, layout.pos.col, name).
| Argument | Meaning |
|---|---|
|
Position and size, as units or numbers. A bare number is read in |
|
Where the viewport hangs from its |
|
The data range of the region, |
|
A |
|
|
|
|
|
Rotation of the region in degrees, anticlockwise. |
|
A |
|
The cells of the parent’s layout that this viewport occupies (rows and columns as a number or a range). |
|
An identifier used to find the viewport in the tree. If it is missing, grid makes one up, such as |
Source: R documentation: viewport
Clipping obeys only the most recent viewport that sets clip: if one viewport clips and a viewport inside it clips again, the second replaces the first. The help page of viewport() states this; it is why a clipping viewport inside a clipping viewport does not give the intersection.
The viewport tree
Grid keeps a tree of viewports. Its root, called ROOT, is the whole device. pushViewport() adds a viewport below the current one and makes it current. popViewport(n) removes the current viewport and its descendants and makes the parent current. upViewport(n) goes to the parent without removing anything. downViewport(name) goes to a viewport further down the tree and seekViewport(name) goes to one anywhere in it. upViewport(0) goes up to the root, and popViewport(0) pops everything. All drawing and all conversion of units is relative to the current viewport. A viewport pushed with the same name as a sibling replaces it.
current.viewport() returns the current viewport, current.vpPath() the path from the root as a vpPath (names joined by ::, and NULL at the root), current.vpTree() the whole tree, and current.transform() the transformation matrix of the viewport. The script pushes, goes up, goes down and pops, and prints the tree after each step.
library(grid)
svg(tempfile(fileext = ".svg"))
# A viewport is a rectangular region with its own coordinate systems.
vp <- viewport(x = 0.5, y = 0.5, width = 0.8, height = 0.6, name = "panel")
vp
#> viewport[panel]
class(vp)
#> [1] "viewport"
vp$width
#> [1] 0.8npc
# Nothing is pushed yet: the current viewport is the top-level one.
current.viewport()
#> viewport[ROOT]
# pushViewport() makes the new region current and keeps its parent.
pushViewport(vp)
current.viewport()
#> viewport[panel]
current.vpPath()
#> panel
pushViewport(viewport(width = 0.5, name = "inner"))
current.vpPath()
#> panel::inner
current.vpTree()
#> viewport[ROOT]->(viewport[panel]->(viewport[inner]))
# upViewport() leaves a viewport on the tree; popViewport() removes it.
upViewport()
current.vpPath()
#> panel
current.vpTree()
#> viewport[ROOT]->(viewport[panel]->(viewport[inner]))
downViewport("inner")
current.vpPath()
#> panel::inner
popViewport()
current.vpTree()
#> viewport[ROOT]->(viewport[panel])
popViewport()
current.vpTree()
#> viewport[ROOT]
invisible(dev.off())
Source: R documentation: pushViewport
Two points in that output are easy to miss. After upViewport() the inner viewport is still in the tree (current.vpTree() shows it), so downViewport("inner") finds it; after popViewport() it is gone. And a viewport object is not tied to the tree: the same object can be pushed many times, in different places.
Several viewports at once
vpStack(a, b) pushes a and then b inside it; vpList(a, b) pushes a and b as siblings, going back up after each; and vpTree(parent, children) pushes a parent and a vpList of children. All three build one object, so a whole structure can be pushed with one call. After pushing a vpTree the current viewport is the last child. A path is built with vpPath("page", "right"), which prints as page::right, and downViewport() accepts it, which is the way to name a viewport that has the same name as another one elsewhere in the tree.
library(grid)
svg(tempfile(fileext = ".svg"))
# Three ways to describe several viewports at once.
tree <- vpTree(
viewport(name = "page"),
vpList(viewport(name = "left"), viewport(name = "right"))
)
tree
#> viewport[page]->(viewport[left], viewport[right])
class(tree)
#> [1] "vpTree" "viewport"
chain <- vpStack(viewport(name = "outer"), viewport(name = "inner"))
chain
#> viewport[outer]->viewport[inner]
class(chain)
#> [1] "vpStack" "viewport"
# Pushing a tree pushes the parent and every child; the last one is current.
pushViewport(tree)
current.vpTree()
#> viewport[ROOT]->(viewport[page]->(viewport[right], viewport[left]))
current.vpPath()
#> page::right
# Go back to the root, then find a viewport by name or by path.
upViewport(0)
current.vpPath()
#> NULL
seekViewport("left")
current.vpPath()
#> page::left
upViewport(0)
downViewport(vpPath("page", "right"))
current.vpPath()
#> page::right
vpPath("page", "right")
#> page::right
invisible(dev.off())
Source: R documentation: vpTree
The order of the children in the printed tree after the push is the reverse of the order they were given in. This is how the tree prints; navigation by name does not depend on it.
Errors and warnings
The messages below are the ones R 4.6.1 gives. A viewport name that is not in the tree is an error. Popping more levels than the tree has is an error from the C code, with the wording that mentions mixed output. A bad width is only a warning in viewport() (it makes NA the width), and an unknown unit name is an error in unit().
library(grid)
svg(tempfile(fileext = ".svg"))
pushViewport(viewport(name = "only"))
msg <- \(expr) tryCatch(expr, error = \(e) conditionMessage(e))
# A name that is not in the tree.
msg(downViewport("missing"))
#> [1] "Viewport 'missing' was not found"
msg(seekViewport("missing"))
#> [1] "Viewport 'missing' was not found"
# Popping more levels than the tree has (the top-level viewport cannot go).
msg(popViewport(3))
#> [1] "cannot pop the top-level viewport ('grid' and 'graphics' output mixed?)"
# The width must be a unit or a number (read as npc): a string is only a
# warning.
tryCatch(viewport(width = "wide"), warning = \(w) conditionMessage(w))
#> [1] "NAs introduced by coercion"
# A unit name that grid does not know is an error.
msg(viewport(x = unit(1, "furlongs")))
#> [1] "Invalid unit"
invisible(dev.off())
Source: R documentation: popViewport
The usual causes of "cannot pop the top-level viewport" are a popViewport() without a matching push, and a script that was interrupted by an error between a push and its pop in an interactive session, so that the next run starts with a different tree. upViewport(0) followed by grid.newpage() starts clean.
Units
The unit types
A grid length is a unit: a number and the name of a coordinate system. Both are vectors, so unit(1:3, c("cm", "npc", "lines")) has length 3. The table lists the names; help("unit") has the full text and the aliases ("in" and "inch" for "inches", "centimetre" and "centimeter" for "cm").
| Name | Meaning |
|---|---|
|
Normalised parent coordinates: 0 is the left or bottom edge of the viewport, 1 the right or top edge. The default unit of a viewport’s position and size. |
|
The viewport’s own data scale, from |
|
Square npc: the lesser of the npc width and height, so that a length in it is the same horizontally and vertically. |
|
Physical lengths (1 in = 2.54 cm). |
|
Printer’s points (72.27 per inch) and big points (72 per inch, the PostScript point). |
|
Other typesetting units (1 pica = 12 points; 65536 scaled points = 1 point). |
|
Lines of text: multiples of |
|
Multiples of the nominal font height, |
|
The width or height of the string in the third argument ( |
|
The width or height of the grob in the |
|
A relative share, which only means something for the widths and heights of a layout (below). |
Source: R documentation: unit
R 4.0.0 replaced the internal implementation of units (R NEWS: the visible effects are a slightly different print format for unit arithmetic, faster unit operations and the new functions unitType() and unit.psum()). The class of a unit is now c("unit", "unit_v2"); everything in this page uses the exported functions, not the structure.
Arithmetic
Arithmetic on units is symbolic. Units of the same name are simplified (2 * unit(1, "cm") is 2cm, and sum(unit(1:3, "cm")) is 6cm); a mixture is stored as a sum or a maximum and kept until it is converted in a viewport. +, -, a number times a unit, max(), min(), sum() and the parallel unit.pmax(), unit.pmin() and unit.psum() work on units, and unit.c() joins units into one vector. c() is not the way: it drops the unit class and returns plain numbers, as the script shows. Two units cannot be multiplied or divided by each other.
library(grid)
svg(tempfile(fileext = ".svg"))
# A unit is a number plus a unit name; both are vectorised.
u <- unit(1:3, c("cm", "npc", "lines"))
u
#> [1] 1cm 2npc 3lines
length(u)
#> [1] 3
u[2]
#> [1] 2npc
unitType(u)
#> [1] "cm" "npc" "lines"
as.numeric(u)
#> [1] 1 2 3
class(u)
#> [1] "unit" "unit_v2"
# The second argument is required: a unit has no default name.
unit(0.5, "npc")
#> [1] 0.5npc
unit(c(1, 2), "inches")
#> [1] 1inches 2inches
# Arithmetic is symbolic: nothing is converted until drawing time.
unit(1, "npc") - unit(2, "lines")
#> [1] sum(1npc, -2lines)
unit(1, "npc") - unit(2, "lines") + unit(1, "cm")
#> [1] sum(1npc, -2lines, 1cm)
2 * unit(1, "cm")
#> [1] 2cm
unit(1, "npc") / 2
#> [1] 0.5npc
unit.c(unit(1, "cm"), unit(2, "inches"), unit(1, "null"))
#> [1] 1cm 2inches 1null
# c() does not know about units: it drops them and returns plain numbers.
c(unit(1, "cm"), unit(2, "inches"))
#> [1] 1 2
# max(), min() and sum() build summary units, again unevaluated.
max(unit(1, "cm"), unit(1, "inches"))
#> [1] max(1cm, 1inches)
sum(unit(1:3, "cm"))
#> [1] 6cm
unit.pmax(unit(1:2, "cm"), unit(0.5, "inches"))
#> [1] max(1cm, 0.5inches) max(2cm, 0.5inches)
rep(unit(1, "mm"), 3)
#> [1] 1mm 1mm 1mm
invisible(dev.off())
Source: R documentation: unit
The text of the symbolic sums is worth reading: sum(1npc, -2lines) is exactly the expression written.
Converting units
convertUnit(x, unitTo, axisFrom, typeFrom, axisTo, typeTo, valueOnly) converts in the current viewport on the current device; convertX(), convertY(), convertWidth() and convertHeight() are the shorthands that fix the axis and the type (location or dimension), which matters for "native" units because a location and a dimension differ by the scale offset. With valueOnly = TRUE the result is a plain number. A conversion needs an open device, because the answer depends on its size and its fonts; the device in the script is 6 by 4 inches.
library(grid)
svg(tempfile(fileext = ".svg"), width = 6, height = 4)
# Conversion needs a device and a current viewport: the answer is in inches
# because this device is 6 by 4 inches.
convertWidth(unit(1, "npc"), "inches")
#> [1] 6inches
convertHeight(unit(1, "npc"), "inches")
#> [1] 4inches
convertUnit(unit(1, "inches"), "cm")
#> [1] 2.54cm
convertX(unit(0.25, "npc"), "inches", valueOnly = TRUE)
#> [1] 1.5
# Relative units depend on the font: a line is fontsize * lineheight points.
convertHeight(unit(1, "lines"), "points")
#> [1] 14.454points
convertHeight(unit(1, "char"), "points")
#> [1] 12.045points
convertWidth(unit(1, "lines"), "mm", valueOnly = TRUE)
#> [1] 5.08
# Text units measure a string or a grob in the current font.
convertWidth(stringWidth("loan"), "points")
#> [1] 24.09points
convertWidth(stringWidth("loan"), "points", valueOnly = TRUE) <
convertWidth(stringWidth("loan period"), "points", valueOnly = TRUE)
#> [1] TRUE
convertHeight(unit(1, "strheight", "Ag"), "points")
#> [1] 9.03375points
convertWidth(grobWidth(textGrob("shelf")), "points")
#> [1] 26.0975points
# "snpc" is the smaller of the width and the height: a square unit (R 4.6.0
# lets convertWidth() and its relatives convert to it).
convertWidth(unit(1, "snpc"), "inches")
#> [1] 4inches
convertHeight(unit(1, "snpc"), "inches")
#> [1] 4inches
# A mixed expression is evaluated now, in the current viewport.
convertHeight(unit(1, "npc") - unit(2, "lines"), "inches")
#> [1] 3.6inches
# The same npc is a different size in a smaller viewport.
pushViewport(viewport(width = 0.5, height = 0.25))
convertWidth(unit(1, "npc"), "inches")
#> [1] 3inches
convertHeight(unit(1, "npc"), "inches")
#> [1] 1inches
popViewport()
invisible(dev.off())
Source: R documentation: convertUnit
Reading the output: one line of text is 14.454 points on this device, because the default fontsize is 12 big points, the default lineheight is 1.2 and 72.27 points make an inch while 72 big points do: 12 * 1.2 * 72.27 / 72 = 14.454. A "char" is 12.045 points for the same reason. The same 1npc is 6 inches wide at the top level and 3 inches inside a viewport half as wide. convertHeight(unit(1, "npc") - unit(2, "lines"), "inches") is 3.6 inches (4 inches minus 28.908 points).
Native units and data scales
xscale and yscale give a viewport a data range, and the "native" unit then converts like any other. This is how a data plot is made in grid: a viewport whose scales are the range of the data, and primitives that take "native" locations. Lattice panels are exactly that.
library(grid)
svg(tempfile(fileext = ".svg"), width = 6, height = 4)
# xscale and yscale give a viewport its own data coordinates ("native" units).
pushViewport(viewport(
width = 0.8, height = 0.8,
xscale = c(0, 400), yscale = c(0, 40), name = "data"
))
current.viewport()$xscale
#> [1] 0 400
current.viewport()$yscale
#> [1] 0 40
# 200 pages is the middle of the x range; 30 days is 3/4 of the y range.
convertX(unit(200, "native"), "npc")
#> [1] 0.5npc
convertY(unit(30, "native"), "npc")
#> [1] 0.75npc
convertX(unit(200, "native"), "inches")
#> [1] 2.4inches
# The scale also converts the other way, and works for widths.
convertX(unit(0.25, "npc"), "native")
#> [1] 100native
convertWidth(unit(100, "native"), "npc")
#> [1] 0.25npc
# Native and absolute units mix freely.
x <- unit(200, "native") + unit(1, "cm")
convertX(x, "inches")
#> [1] 2.79370078740157inches
popViewport()
# A location and a width differ when the scale does not start at zero.
pushViewport(viewport(xscale = c(100, 500)))
convertWidth(unit(100, "native"), "npc")
#> [1] 0.25npc
convertX(unit(100, "native"), "npc")
#> [1] 0npc
popViewport()
invisible(dev.off())
Source: R documentation: unit
200 pages is half of the 0 to 400 range, so it is 0.5npc from the left of the viewport; 30 days of a 0 to 40 range is 0.75npc; and the viewport is 80 percent of a 6 inch wide device, so the middle of the range is 2.4 inches from the viewport’s left edge. convertWidth(unit(100, "native"), "npc") is 0.25npc (a quarter of the range) and convertX(unit(0.25, "npc"), "native") is 100native. The two agree here because the scales start at zero. With xscale = c(100, 500), which the last lines of the script use, a width of 100 native units is still 0.25npc, but a location of 100 native units is the left edge, 0npc: that is why there are separate convertX() and convertWidth().
Unit errors and surprises
library(grid)
svg(tempfile(fileext = ".svg"))
msg <- \(expr) tryCatch(expr, error = \(e) conditionMessage(e))
# An unknown unit name is an error.
msg(unit(1, "furlongs"))
#> [1] "Invalid unit"
# Names are recycled against values without a warning.
unit(1:2, c("cm", "mm", "npc"))
#> [1] 1cm 2mm 1npc
# A "null" unit has no size of its own: outside a layout it converts to 0.
convertWidth(unit(1, "null"), "cm")
#> [1] 0cm
# Mixed arithmetic cannot be simplified, so it is stored as a sum.
unit(1, "null") + unit(1, "cm")
#> [1] sum(1null, 1cm)
# Only one operand of a product may be a unit.
msg(unit(1, "cm") * unit(1, "cm"))
#> [1] "only one operand may be a unit"
invisible(dev.off())
Source: R documentation: unit
A "null" unit outside a layout converts to zero, and unit(1, "null") + unit(1, "cm") is a legal sum that stays symbolic; grid does not warn. unit(1:2, c("cm", "mm", "npc")) recycles the shorter vector to the length of the longer one without a warning, as R usually does. And unit(1, "cm") * unit(1, "cm") fails with "only one operand may be a unit".
Layouts
A layout divides a viewport into a grid of rows and columns. grid.layout(nrow, ncol, widths, heights, default.units, respect, just) creates the object; the viewport that holds the layout is created with viewport(layout = lay); and the viewports that occupy its cells are created with layout.pos.row and layout.pos.col, each a cell number or a range. Row 1 is the top row, as in a table.
Widths and heights are units. Fixed units ("cm", "lines") take exactly their size. A "null" unit takes a share of what is left after the fixed ones, in proportion to its value: with widths = unit(c(2, 1, 1), c("cm", "null", "null")) the first column is 2 cm and the other two split the rest equally. The default widths and heights are unit(1, "null") for every column and row, so grid.layout(2, 3) is six equal cells.
library(grid)
svg(tempfile(fileext = ".svg"), width = 8, height = 6)
# Two rows, three columns: fixed first and last column, flexible middle.
lay <- grid.layout(
nrow = 2, ncol = 3,
widths = unit(c(2, 1, 1), c("cm", "null", "null")),
heights = unit(c(1, 3), c("lines", "null"))
)
class(lay)
#> [1] "layout"
dim(lay)
#> [1] 2 3
lay$widths
#> [1] 2cm 1null 1null
lay$heights
#> [1] 1lines 3null
lay$respect
#> [1] FALSE
# A layout belongs to a viewport; children choose cells by row and column.
pushViewport(viewport(layout = lay, name = "page"))
pushViewport(viewport(layout.pos.row = 2, layout.pos.col = 2:3, name = "body"))
current.vpPath()
#> page::body
convertWidth(unit(1, "npc"), "inches")
#> [1] 7.21259842519685inches
convertHeight(unit(1, "npc"), "inches")
#> [1] 5.8inches
upViewport()
pushViewport(viewport(layout.pos.row = 1, layout.pos.col = 1, name = "corner"))
convertWidth(unit(1, "npc"), "cm")
#> [1] 2cm
convertHeight(unit(1, "npc"), "points")
#> [1] 14.454points
popViewport(2)
invisible(dev.off())
Source: R documentation: grid.layout
The cell layout.pos.row = 2, layout.pos.col = 2:3 is the whole bottom row minus the first column: on the 8 by 6 inch device the layout has 2 cm for the first column, the rest (8 inches minus 2 cm) is divided equally between the other two columns, and the viewport that spans both is 7.2126 inches wide. The bottom row is 3null of 1lines + 3null, so it takes the height left after the one line. The top-left cell is exactly 2 cm wide and one line high.
respect
By default null units in widths and in heights are independent: the cells fill the region, whatever its shape. With respect = TRUE all null widths and heights keep their ratio to each other, so a layout of 1null by 1null cells has square cells, as large as will fit, and is justified in the region according to just. respect can also be a matrix of the same shape as the layout whose non-zero entries mark which cells are respected.
library(grid)
svg(tempfile(fileext = ".svg"), width = 8, height = 2)
cell_size <- \(respect) {
lay <- grid.layout(1, 2, widths = unit(c(1, 1), "null"),
heights = unit(1, "null"), respect = respect)
pushViewport(viewport(layout = lay))
pushViewport(viewport(layout.pos.col = 1))
size <- c(
width = convertWidth(unit(1, "npc"), "inches", valueOnly = TRUE),
height = convertHeight(unit(1, "npc"), "inches", valueOnly = TRUE)
)
popViewport(2)
size
}
# Without respect the cells fill the 8 by 2 inch device: 4 by 2 inches each.
cell_size(FALSE)
#> width height
#> 4 2
# With respect = TRUE a null width and a null height keep the ratio 1 : 1,
# so the cells are 2 by 2 inch squares, the largest that fit.
cell_size(TRUE)
#> width height
#> 2 2
# A matrix selects which cells are respected.
m <- rbind(c(1, 0))
m
#> [,1] [,2]
#> [1,] 1 0
grid.layout(1, 2, respect = m)$respect.mat
#> [,1] [,2]
#> [1,] 1 0
invisible(dev.off())
Source: R documentation: grid.layout
grid.show.layout(lay) draws a diagram of a layout and grid.show.viewport(vp) one of a viewport, to look at while designing; they draw on the device and have nothing to print, so no example runs them.
Grid layouts nest: a viewport that occupies one cell can itself carry a layout. That is how a lattice plot is built: a layout with rows for the title, the strips, the panels, the axes and the labels, whose panel cells are viewports named plot_01.panel.1.1.vp, and so on.
Graphical parameters
gpar() collects graphical parameters in a named list of class "gpar". It is the value of the gp argument of every viewport and every grob. The parameters are:
| Parameter | Meaning |
|---|---|
|
Colour of lines and borders; colour of the inside of shapes. In R 4.1.0 and later |
|
Opacity from 0 to 1; combined with the alpha of the individual colour by multiplying. |
|
Line type, line width, and a multiplier applied to the width. |
|
How lines end ( |
|
The size of text in points, and a multiplier on it; text is |
|
Family, face ( |
Source: R documentation: gpar
The rules of inheritance are the reason to keep the tree in mind. The root viewport takes its settings from the device (the default fill differs between devices: on svg() it is "white"). A viewport that is pushed and a grob that is drawn enforce their gp for as long as they are current, and the settings go back when the viewport is popped or the grob has finished drawing. A grob’s own gp overrides the viewport’s, for that grob only. cex, alpha and lex are cumulative: a viewport with cex = 0.5 inside another one with cex = 0.5 has an effective cex of 0.25. get.gpar() returns the settings in force at the current viewport.
library(grid)
svg(tempfile(fileext = ".svg"))
# gpar() collects graphical parameters; it is a named list with a class.
gp <- gpar(col = "red", fill = "grey90", lwd = 2, lty = "dashed", cex = 1.5)
class(gp)
#> [1] "gpar"
names(gp)
#> [1] "col" "fill" "lwd" "lty" "cex"
# The defaults come from the device, and every viewport inherits.
unlist(get.gpar(c("col", "fill", "lwd", "fontsize", "cex", "lineheight")))
#> col fill lwd fontsize cex lineheight
#> "black" "white" "1" "12" "1" "1.2"
# Setting a parameter in a viewport changes it for everything inside.
pushViewport(viewport(gp = gpar(fontsize = 20, col = "blue")))
unlist(get.gpar(c("fontsize", "col", "cex")))
#> fontsize col cex
#> "20" "blue" "1"
# cex multiplies fontsize; this viewport changes cex, not fontsize.
pushViewport(viewport(gp = gpar(cex = 0.5)))
unlist(get.gpar(c("fontsize", "col", "cex")))
#> fontsize col cex
#> "20" "blue" "0.5"
# One line is fontsize * cex * lineheight: 20 * 0.5 * 1.2 big points here.
convertHeight(unit(1, "lines"), "bigpts")
#> [1] 12bigpts
popViewport(2)
# A grob can set parameters for itself, which override the viewport's.
t1 <- textGrob("a", gp = gpar(fontsize = 8))
t2 <- textGrob("a")
pushViewport(viewport(gp = gpar(fontsize = 30)))
convertWidth(grobWidth(t1), "points", valueOnly = TRUE) <
convertWidth(grobWidth(t2), "points", valueOnly = TRUE)
#> [1] TRUE
popViewport()
# alpha multiplies down the tree too.
pushViewport(viewport(gp = gpar(alpha = 0.5)))
pushViewport(viewport(gp = gpar(alpha = 0.5)))
get.gpar("alpha")$alpha
#> [1] 0.25
popViewport(2)
invisible(dev.off())
Source: R documentation: gpar
That script prints get.gpar() values coerced to character by unlist(); the numbers are numbers in R. Notice that the second viewport changes only cex, so fontsize stays 20 and a line is 20 * 0.5 * 1.2 = 12 big points.
Graphical objects
Draw now or make an object
Every grid primitive comes in two forms. grid.rect(), grid.text(), grid.lines(), grid.points(), grid.polygon(), grid.circle(), grid.segments(), grid.xaxis() and grid.yaxis() create the object and draw it at once, and return the object invisibly. rectGrob(), textGrob(), linesGrob(), pointsGrob(), polygonGrob(), circleGrob(), segmentsGrob(), xaxisGrob() and yaxisGrob() only create it. A grob is an ordinary R object (a list with class c("rect", "grob", "gDesc")): it describes what to draw, and grid.draw() draws it, in whatever viewport is current. That makes the *Grob() form the one to use when the object is to be stored, modified, put in a tree or drawn in several places, and the grid.*() form the one for a direct drawing.
library(grid)
svg(tempfile(fileext = ".svg"))
# rectGrob() makes an object and draws nothing.
r <- rectGrob(width = 0.5, name = "frame", gp = gpar(fill = "grey90"))
r
#> rect[frame]
class(r)
#> [1] "rect" "grob" "gDesc"
grid.ls() # an empty listing prints one blank line
#>
# grid.draw() draws it; the display list now has one entry.
grid.draw(r)
grid.ls()
#> frame
# grid.rect() is rectGrob() followed by grid.draw(); it returns the grob.
g <- grid.rect(height = 0.5, name = "band")
class(g)
#> [1] "rect" "grob" "gDesc"
grid.ls()
#> frame
#> band
# The fields of a grob are ordinary list elements, and a grob is a copy:
# changing r does not change what is on the page.
r$width
#> [1] 0.5npc
r$width <- unit(0.9, "npc")
r$width
#> [1] 0.9npc
grid.get("frame")$width
#> [1] 0.5npc
# grid.newpage() clears the page and the display list.
grid.newpage()
grid.ls()
#>
invisible(dev.off())
Source: R documentation: grid.rect
Three things in that output matter. rectGrob() drew nothing: grid.ls() listed nothing until grid.draw(). A grob is a value: r$width <- unit(0.9, "npc") changed the copy r and left the drawn object, found with grid.get(), at 0.5npc. And the names (frame, band) are what the display list is searched by: when a name is not given, grid makes one up from a counter (GRID.rect.1), which is fine for a quick figure and unhelpful for editing.
The primitives and their units
The x, y, width and height of the primitives are read in "npc" unless a unit is given, so rectGrob() fills its viewport. pointsGrob() and grid.points() read bare numbers as "native". The way to draw data is therefore a viewport with xscale and yscale, and primitives with native locations; axes (grid.xaxis(), grid.yaxis()) read the scale of the viewport they are drawn in, and take an at argument to fix the ticks.
library(grid)
svg(tempfile(fileext = ".svg"), width = 6, height = 4)
# Each primitive has a grid.*() function that draws and a *Grob() that makes.
prims <- list(
rect = rectGrob(), text = textGrob("loans"), lines = linesGrob(),
points = pointsGrob(1:3, 1:3), polygon = polygonGrob(),
circle = circleGrob(), segments = segmentsGrob(),
xaxis = xaxisGrob(at = 1:3), yaxis = yaxisGrob(at = 1:3)
)
vapply(prims, \(g) class(g)[[1L]], character(1L))
#> rect text lines points polygon circle segments
#> "rect" "text" "lines" "points" "polygon" "circle" "segments"
#> xaxis yaxis
#> "xaxis" "yaxis"
# Default units: x, y, width and height are "npc" unless given a unit;
# points() also take "native" (the units of the viewport's scales).
prims$rect$x
#> [1] 0.5npc
prims$points$x
#> [1] 1native 2native 3native
prims$points$pch
#> [1] 1
# Data coordinates need a viewport with a scale.
pushViewport(viewport(xscale = c(0, 400), yscale = c(0, 40), name = "data"))
grid.points(c(214, 198, 301), c(10, 19, 31), name = "loans", pch = 16)
grid.xaxis(at = c(0, 200, 400), name = "x")
grid.yaxis(name = "y")
grid.text("pages", y = unit(-3, "lines"), name = "xlab")
grid.lines(unit(c(0, 400), "native"), unit(c(5, 35), "native"), name = "trend")
grid.ls(viewports = TRUE)
#> ROOT
#> data
#> loans
#> x
#> major
#> ticks
#> labels
#> y
#> xlab
#> trend
popViewport()
invisible(dev.off())
Source: R documentation: grid.points
The listing shows the tree for the data viewport: the points, the x axis (an axis is a gTree of major, ticks and labels), the y axis, the label and the line. The y axis shows no children. That is deliberate: an axis without at works out its ticks when it is drawn, and until then it has no children. grid.force() (below) makes the children appear.
The general constructors are grob(..., name, gp, vp, cl) and gTree(..., name, gp, vp, children, childrenvp, cl); every primitive is built on them. Other primitives that this page does not run are grid.raster() for images, grid.path() for shapes with holes, grid.curve() and grid.xspline() for curves, grid.roundrect(), grid.function() and the grid.legend() family; they all follow the same grid.*() and *Grob() pattern.
gTrees
A gTree is a grob with children. Its children argument is a gList, a list of grobs, and drawing a gTree draws its children in order. The vp argument of any grob is a viewport that is pushed before the grob draws and popped after, which is how a gTree puts its parts in a region, and childrenvp is a viewport pushed before the children and popped (with upViewport) so that each child can name a place inside it with a vpPath. All the children of a gTree must have different names; the gTree itself need not be unique on the page.
gPath("card", "title"), written card::title, names a grob inside a gTree, in the same way that vpPath names a viewport. childNames() lists the children of one gTree, getGrob(tree, "title") returns a child, editGrob(tree, "title", label = ...) returns a copy with a changed child, addGrob(tree, grob) and removeGrob(tree, "name") add and remove a child, and setGrob(tree, "name", newGrob) replaces one. All of them return a modified copy and leave the tree they were given unchanged. The grid.* forms of the same operations change what has been drawn, and are the subject of the next subsection.
library(grid)
svg(tempfile(fileext = ".svg"))
# A gTree is a grob whose children are grobs; gList() is a list of grobs.
card <- gTree(
children = gList(
rectGrob(name = "frame", gp = gpar(fill = "grey95")),
textGrob("The Dispossessed", y = 0.7, name = "title"),
textGrob("Ursula K. Le Guin", y = 0.3, name = "author",
gp = gpar(fontsize = 8))
),
name = "card",
vp = viewport(width = 0.6, height = 0.4, name = "card_vp")
)
card
#> gTree[card]
class(card)
#> [1] "gTree" "grob" "gDesc"
childNames(card)
#> [1] "frame" "title" "author"
length(card$children)
#> [1] 3
# Nothing is on the page until the tree is drawn.
grid.draw(card)
grid.ls()
#> card
#> frame
#> title
#> author
grid.ls(viewports = TRUE, fullNames = TRUE)
#> viewport[ROOT]
#> viewport[card_vp]
#> gTree[card]
#> rect[frame]
#> text[title]
#> text[author]
#> upViewport[1]
# Look inside a gTree with a path, and change a copy of it.
getGrob(card, "title")$label
#> [1] "The Dispossessed"
card2 <- editGrob(card, gPath("title"), label = "Pride and Prejudice")
getGrob(card2, "title")$label
#> [1] "Pride and Prejudice"
getGrob(card, "title")$label
#> [1] "The Dispossessed"
# addGrob(), removeGrob() and setGrob() also return a modified copy.
card3 <- addGrob(card, circleGrob(r = 0.05, name = "dot"))
childNames(card3)
#> [1] "frame" "title" "author" "dot"
childNames(removeGrob(card3, "dot"))
#> [1] "frame" "title" "author"
childNames(setGrob(card, "author", textGrob("J. Austen", name = "author")))
#> [1] "frame" "title" "author"
invisible(dev.off())
Source: R documentation: gTree
In the second listing (grid.ls(viewports = TRUE, fullNames = TRUE)) the gTree draws inside its own vp, the viewport card_vp, and upViewport[1] is the pop that returns to the root. fullNames = TRUE adds the type of each entry: gTree[card], rect[frame], text[title].
Editing what is drawn
A grob that has been drawn lives on grid’s display list, and grid.edit() changes it in place and redraws the page. The functions are grid.get(), grid.edit(), grid.add(), grid.remove() and grid.set(), the drawn versions of getGrob(), editGrob(), addGrob(), removeGrob() and setGrob(). They take a gPath, and a bare name is searched for at any depth unless strict = TRUE; grep = TRUE and global = TRUE match by regular expression and every match. redraw = FALSE makes the change without repainting. A name that is not on the display list is an error:
library(grid)
svg(tempfile(fileext = ".svg"))
# grid.edit() changes an object that is already drawn, by name or path.
grid.draw(gTree(
children = gList(
rectGrob(name = "frame", gp = gpar(fill = "white")),
textGrob("loans", name = "label")
),
name = "card"
))
grid.get("card::label")$label
#> [1] "loans"
grid.edit("card::label", label = "returned")
grid.get("card::label")$label
#> [1] "returned"
grid.edit("card::frame", gp = gpar(fill = "grey80"))
grid.get("card::frame")$gp$fill
#> [1] "grey80"
# grid.add() and grid.remove() change the tree in place.
grid.add("card", circleGrob(name = "dot", r = 0.1))
grid.ls()
#> card
#> frame
#> label
#> dot
grid.remove("card::dot")
grid.ls()
#> card
#> frame
#> label
# A bare name is searched for at any depth in the tree ...
grid.get("label")$label
#> [1] "returned"
# ... and redraw = FALSE changes the object without repainting the page.
grid.edit("label", gp = gpar(col = "red"), redraw = FALSE)
grid.get("label")$gp$col
#> [1] "red"
# A name that is not on the display list is an error.
tryCatch(grid.edit("nothing", gp = gpar(col = "red")),
error = \(e) conditionMessage(e))
#> [1] "'gPath' (nothing) not found"
invisible(dev.off())
Source: R documentation: grid.edit
This is what makes a drawn plot editable: after a lattice plot is drawn, its axis labels and strips are grobs with names that can be edited in the same way (the section on trellis.focus() below uses it).
The display list
Grid records all drawing and viewport operations on a display list of its own (as well as the engine’s), which supports editing and redrawing. It is on by default. grid.display.list(FALSE) turns it off, which saves memory for a page of many objects and disables editing and redrawing (and, the help page warns, turning the list on again erases it). grid.newpage() clears the page and the list, grid.refresh() redraws from the list, and grid.ls() lists it.
library(grid)
svg(tempfile(fileext = ".svg"))
# grid keeps its own display list of what has been drawn on the page; it is
# on by default (grid.display.list(FALSE) would turn it off).
grid.rect(name = "a")
grid.text("b", name = "b")
grid.ls()
#> a
#> b
# Drawing a name a second time adds a second entry; it does not replace.
grid.rect(name = "a")
grid.ls()
#> a
#> b
#> a
# grid.newpage() starts a page with an empty list ...
grid.newpage()
grid.ls()
#>
# ... and grid.refresh() redraws whatever the list holds.
grid.rect(name = "c")
grid.refresh()
grid.ls()
#> c
invisible(dev.off())
Source: R documentation: grid.newpage
grid.ls() has arguments that decide how much it shows: viewports = TRUE shows the viewport pushes and pops between the grobs, fullNames = TRUE the type of each entry, grobs = FALSE hides grobs, print = FALSE returns the listing as an object of class "flatGridListing" (a list with the columns name, gDepth, vpDepth, gPath, vpPath and type) instead of printing it, and grid.ls(x) lists a grob that has not been drawn. The lattice examples below use grid.ls(print = FALSE)$name to test what was drawn.
grid.force() replaces the grobs on the display list that work out their content at draw time with the grobs actually drawn (an axis without at is the usual case), so that they can be listed and edited. grid.grab() captures everything on the page as one gTree, and grid.grabExpr(expr) draws an expression on a scratch device and grabs the result, which is the way to get a grob from code that draws with grid.*() functions.
library(grid)
svg(tempfile(fileext = ".svg"))
# grid.xaxis() with no `at` works out its ticks when it is drawn.
pushViewport(viewport(xscale = c(0, 400), name = "data"))
grid.xaxis(name = "pages")
grid.ls()
#> pages
# grid.force() replaces such objects by the grobs actually drawn.
grid.force()
grid.ls()
#> pages
#> major
#> ticks
#> labels
popViewport()
# grid.grab() turns everything on the page into one gTree.
grabbed <- grid.grab()
class(grabbed)
#> [1] "gTree" "grob" "gDesc"
childNames(grabbed)
#> [1] "pages"
# grid.grabExpr() draws an expression on a scratch device and grabs it.
g <- grid.grabExpr(grid.rect(name = "box"))
class(g)
#> [1] "gTree" "grob" "gDesc"
childNames(g)
#> [1] "box"
invisible(dev.off())
Source: R documentation: grid.force
Custom gTrees
A gTree can build its children when it is drawn instead of when it is created. The class defines a makeContent() method that receives the gTree and returns it with its children set (setChildren()); makeContext() does the same for the viewport slots, and drawDetails(), widthDetails() and heightDetails() customise the drawing and the size of a grob. The help page names grid:::makeContent.xaxis as an example: it is how an axis decides its ticks after it has seen the viewport it is drawn in. The script defines a bar chart of loan durations as a gTree with a days field: the bars are made only when the tree is drawn, so a listing before grid.force() shows nothing below the tree.
library(grid)
svg(tempfile(fileext = ".svg"))
# A gTree subclass can build its children when it is drawn.
loan_bars <- \(days, name = NULL) {
gTree(days = days, name = name, cl = "loan_bars")
}
makeContent.loan_bars <- \(x) { # nolint: object_name_linter.
n <- length(x$days)
bars <- rectGrob(
x = unit((seq_len(n) - 0.5) / n, "npc"), y = 0,
width = unit(0.8 / n, "npc"),
height = unit(x$days / max(x$days), "npc"),
just = c("centre", "bottom"), name = "bars"
)
setChildren(x, gList(bars))
}
bars <- loan_bars(c(10, 19, 31, 25, 23), name = "durations")
childNames(bars)
#> character(0)
grid.draw(bars)
# The children exist only while drawing, so a plain listing shows none ...
grid.ls()
#> durations
# ... and grid.force() makes the content visible.
grid.force()
grid.ls()
#> durations
#> bars
invisible(dev.off())
Source: R documentation: makeContent
Two details of that code are specific to S3 on grid. The method is named makeContent.loan_bars, after the class in cl, and is found by the usual S3 dispatch because it is defined in the global environment (in a package it needs S3method(makeContent, loan_bars) in the NAMESPACE; see S3 Classes and Generics). And makeContent() receives and returns the whole gTree: it must return the modified object, and changes made by makeContext() must be kept in it.
Engine features: gradients, patterns, clipping paths, masks, groups
Since R 4.1.0 the graphics engine, and grid on top of it, supports several features that base graphics did not have. They are released in steps, and a device must support each one.
| Release | What grid and the engine gained (R NEWS) |
|---|---|
R 4.1.0 |
|
R 4.2.0 |
|
R 4.3.0 |
New functions for rendering typeset glyphs, |
R 4.5.0 |
|
R 4.6.0 |
|
What a device supports is reported by dev.capabilities(), a list with an entry for patterns, clippingPaths, masks, compositing, transformations, paths, glyphs, variableFonts and the older capabilities. Where a device lacks a feature the output degrades (the help page of linearGradient() says that closed shapes are then drawn with a transparent fill, and that not every value of extend is supported on every device), so the capabilities list is the way to find out in code. The script runs the features on an svg() device, which is Cairo-based, and prints what it reports; pdf() supports a different set of masks and compositing operators.
library(grid)
svg(tempfile(fileext = ".svg"))
# What does this device support? (the answer depends on the device)
caps <- dev.capabilities()
caps$patterns
#> [1] "LinearGradient" "RadialGradient" "TilingPattern"
caps$clippingPaths
#> [1] TRUE
caps$masks
#> [1] "alpha"
length(caps$compositing)
#> [1] 25
"dest.out" %in% caps$compositing
#> [1] TRUE
caps$paths
#> [1] TRUE
# A gradient is a value for gpar(fill =); it is not a colour.
blue_fade <- linearGradient(
colours = c("white", "steelblue"), stops = c(0, 1),
x1 = 0, y1 = 0, x2 = 1, y2 = 0
)
class(blue_fade)
#> [1] "GridLinearGradient" "GridPattern"
blue_fade$colours
#> [1] "white" "steelblue"
blue_fade$extend
#> [1] "pad"
bar <- rectGrob(width = 0.8, height = 0.3, name = "bar",
gp = gpar(fill = blue_fade, col = NA))
class(bar$gp$fill)
#> [1] "GridLinearGradient" "GridPattern"
grid.draw(bar)
# A tiled pattern repeats a small grob.
dots <- pattern(circleGrob(r = 0.25, gp = gpar(fill = "grey40")),
width = unit(5, "mm"), height = unit(5, "mm"),
extend = "repeat")
class(dots)
#> [1] "GridTilingPattern" "GridPattern"
grid.rect(width = 0.5, height = 0.3, gp = gpar(fill = dots), name = "dotted")
# A grob as the clip of a viewport is a clipping path; a grob as the mask of
# a viewport is a mask; both need device support.
pushViewport(viewport(clip = circleGrob(r = 0.4), name = "round"))
class(current.viewport()$clip)
#> [1] "GridClipPath"
grid.rect(gp = gpar(fill = "orange"), name = "orange")
popViewport()
# grid.group() draws a grob in isolation and can combine two with an operator.
grp <- grid.group(rectGrob(width = 0.5, height = 0.5, gp = gpar(fill = "red")),
op = "over", dst = circleGrob(r = 0.3), name = "pair")
grid.ls()
#> bar
#> dotted
#> orange
#> pair
invisible(dev.off())
# The pdf() device supports luminance masks and fewer compositing operators.
pdf(NULL)
dev.capabilities()$masks
#> [1] "alpha" "luminance"
length(dev.capabilities()$compositing)
#> [1] 11
"dest.out" %in% dev.capabilities()$compositing
#> [1] FALSE
invisible(dev.off())
Source: R documentation: linearGradient
The gradient and the pattern are not colours: gpar(fill = blue_fade) stores the object, and the class of bar$gp$fill is "GridLinearGradient". A linearGradient() takes the colours, the stops at which each colour is reached (0 to 1 along the gradient), the two end points x1, y1, x2, y2 (npc by default) and extend, which says what happens outside the gradient: "pad" (the default) repeats the end colours, "repeat" and "reflect" tile the gradient, and "none" draws nothing outside it. A radialGradient() has two circles instead of two points. pattern(grob, width, height, extend = "repeat") tiles a grob. group = TRUE (the default) makes the gradient relative to the bounding box of the whole grob, FALSE makes it relative to each shape in it. A grob as a clip is a clipping path, and a grob as a mask is a mask whose alpha (or luminance) decides how much of what is drawn inside shows. In every case the feature is part of the object, not of the output, so it works the same on any device that has it.
grid.group(src, op, dst) draws src (and dst, if given) in isolation and combines them with a compositing operator before putting the result on the page. "over" is the default; the Porter-Duff operators "clear", "source", "over", "in", "out", "atop", "dest", "dest.over", "dest.in", "dest.out", "dest.atop", "xor", "add" and "saturate" and the blend modes ("multiply", "screen", "overlay" and so on) are available on devices that report them. grid.define() and grid.use() define a group once and draw it many times, with an affine transformation each time.
None of this is needed for a default lattice plot; it matters when you draw with grid directly. In an R Markdown or Quarto chunk, a lattice object at the end of the chunk is printed, and so drawn, as at the console; a grob is only printed as its name (rect[frame], as in the output above), so a chunk that builds a grob ends with grid.draw().
lattice
The Trellis model
lattice is the R implementation of Trellis graphics. Trellis was developed for S and S-PLUS at Bell Labs by R. A. Becker, W. S. Cleveland and colleagues, extending ideas in Cleveland’s 1993 book Visualizing Data; the lattice help page names that book and the paper "The Visual Design and Control of Trellis Display" (Becker, Cleveland and Shyu, 1996) as the sources. Deepayan Sarkar’s Lattice: Multivariate Data Visualization with R is the book about the package, and it is community material, named here and not used; the package also has its own site, lattice package documentation: project site. The R FAQ answers the question "Are Trellis displays implemented in R?" with lattice, and explains the name: because Trellis was claimed as a trademark, R does not use that word for the package, which is called lattice (the class of its objects is still "trellis").
The idea is that one figure is an array of small plots, each of a different subset of the data, drawn with the same axes, so that the eye compares them. The subsets are defined by conditioning variables, factors or shingles written after a bar in the formula: days ~ pages | genre is "days against pages, for each genre". Each combination of levels is a packet of rows, and each packet is drawn in a panel by a panel function. The panels are arranged in a rectangular array, which can run over several pages, and a strip above each panel names its packet. Because the packets share the scales (by default), a difference between panels is a difference in the data.
The user interface is a set of high-level generic functions, each of which draws one kind of display by default. They have common arguments for conditioning, layout, scales, keys and titles, documented mainly in ?xyplot, and a default panel function named panel. followed by the name of the function (panel.xyplot, panel.bwplot). A high-level function does not plot. It returns an object of class "trellis", and printing the object draws it.
The high-level functions
| Function | Display | Typical formula |
|---|---|---|
|
Scatterplots, and time series and much more through |
|
|
Box-and-whisker plots, one box per level of a factor |
|
|
Histograms |
|
|
Kernel density estimates |
|
|
Cleveland dot plots |
|
|
Bar plots; also takes a table or an array |
|
|
One-dimensional scatterplots, optionally jittered |
|
|
Quantile plots against a theoretical distribution, or of two samples |
|
|
A matrix of values as coloured cells, or as contour lines |
|
|
Three-dimensional scatterplots and surfaces |
|
|
Scatterplot matrix and parallel coordinates of the columns of a data frame |
|
The remaining functions of the family (tmd(), rfs(), oneway()) are specialised diagnostics. parallel() was deprecated for a long time in favour of parallelplot() and is defunct since lattice 0.22. cloud() is built in the first example below, and wireframe(), which shares its help page and its arguments, draws a surface from the same formula or from a matrix. Also since lattice 0.22, every high-level function has a "data.frame" method, in which the formula is the second, unnamed argument or the argument formula, so xyplot(dur, days ~ pages) and xyplot(days ~ pages, dur) are both accepted.
The first example builds the whole family from the shelf data, checks that each is a trellis object, and prints the name of its default panel function.
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
svg(tempfile(fileext = ".svg"))
# One call per high-level function: each returns a "trellis" object.
plots <- list(
xyplot = xyplot(days ~ pages, data = dur),
bwplot = bwplot(genre ~ days, data = dur),
histogram = histogram(~days, data = dur),
densityplot = densityplot(~days, data = dur, plot.points = FALSE),
dotplot = dotplot(genre ~ days, data = dur),
stripplot = stripplot(genre ~ days, data = dur, jitter.data = TRUE),
barchart = barchart(table(dur$genre)),
splom = splom(dur[c("days", "pages", "year")]),
levelplot = levelplot(table(dur$genre, cut(dur$days, c(0, 10, 20, 40)))),
cloud = cloud(days ~ pages + year, data = dur)
)
vapply(plots, \(p) class(p)[[1L]], character(1L))
#> xyplot bwplot histogram densityplot dotplot stripplot
#> "trellis" "trellis" "trellis" "trellis" "trellis" "trellis"
#> barchart splom levelplot cloud
#> "trellis" "trellis" "trellis" "trellis"
# Each stores the name of its default panel function: "panel." plus its own.
vapply(plots, \(p) p$panel, character(1L))
#> xyplot bwplot histogram densityplot
#> "panel.xyplot" "panel.bwplot" "panel.histogram" "panel.densityplot"
#> dotplot stripplot barchart splom
#> "panel.dotplot" "panel.stripplot" "panel.barchart" "panel.pairs"
#> levelplot cloud
#> "panel.levelplot" "panel.cloud"
# Every figure in the list prints without error.
for (p in plots) print(p)
invisible(dev.off())
splom() uses panel.pairs, whose cells call panel.splom; the others use the panel function of their own name.
The trellis object
An object of class "trellis" is a list with some forty components. It holds the formula, the call, the data of every panel, the limits of the scales, the layout, the key, the strip functions and the graphical settings to use. print() reads it and draws; summary() describes it; dim() and dimnames() give the shape of the array of panels; update() returns a changed copy; and [ selects packets. The next example builds a plot with one conditioning variable and looks inside.
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
svg(tempfile(fileext = ".svg"))
# xyplot() returns a "trellis" object; it has not drawn anything yet.
p <- xyplot(days ~ pages | genre, data = dur)
class(p)
#> [1] "trellis"
p$call
#> xyplot(days ~ pages | genre, data = dur)
deparse1(p$formula)
#> [1] "days ~ pages | genre"
dim(p)
#> [1] 6
dimnames(p)
#> $genre
#> [1] "classic" "fiction" "mystery" "non-fiction"
#> [5] "poetry" "science fiction"
#>
summary(p)
#>
#> Call:
#> xyplot(days ~ pages | genre, data = dur)
#>
#> Number of observations:
#> genre
#> classic fiction mystery non-fiction poetry
#> 5 18 4 5 5
#> science fiction
#> 13
# The data of each panel is stored in the object, one entry per packet.
length(p$panel.args)
#> [1] 6
str(p$panel.args[[2L]])
#> List of 2
#> $ x: int [1:18] 214 214 214 214 214 214 198 198 198 198 ...
#> $ y: num [1:18] 10 19 31 25 23 4 31 7 1 23 ...
p$packet.sizes
#> genre
#> classic fiction mystery non-fiction poetry
#> 5 18 4 5 5
#> science fiction
#> 13
p$x.limits
#> [1] 63.92 456.08
p$y.limits
#> [1] -1.52 39.52
# The object is a list with some forty components that print() reads.
length(names(p))
#> [1] 45
head(names(p), 8L)
#> [1] "formula" "as.table" "aspect.fill" "legend" "panel"
#> [6] "page" "layout" "skip"
invisible(dev.off())
Notice that dimnames(p) and dim(p) describe the conditioning array (six genres, one dimension), that p$panel.args[[2L]] holds the x and y of the second packet (fiction, 18 loans) with nothing drawn, and that p$x.limits is a single pair because the default relation is "same". The components are documented in ?trellis.object; they are meant to be read when you extend lattice or debug a plot, rarely to be assigned to.
The plot is drawn when the object is printed
A trellis object is drawn by its print method. At the console the value of an expression is printed automatically, so xyplot(...) appears to draw. The automatic printing is suppressed where R does not print values: inside a function, inside a loop (for, while) and inside source(). There an explicit print() (or plot(), which is an alias) is needed. The R FAQ lists it as 7.22, "Why do lattice/trellis graphics not work?", and the same is true of ggplot2.
source("scenario.R")
library(lattice)
library(grid)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
svg(tempfile(fileext = ".svg"))
drawn <- \() length(grid.ls(print = FALSE)$name)
# At the top level the value is printed automatically, which draws the plot.
xyplot(days ~ pages, data = dur)
drawn() > 0
#> [1] TRUE
grid.newpage()
# Inside a function the value is returned, not printed: nothing is drawn.
plot_loans <- \() xyplot(days ~ pages, data = dur)
invisible(plot_loans())
drawn()
#> [1] 0
# In a loop the value is discarded, so nothing is drawn either.
for (g in levels(dur$genre)[1:2]) {
xyplot(days ~ pages, data = dur[dur$genre == g, ])
}
drawn()
#> [1] 0
# An explicit print() (or plot()) draws.
for (g in levels(dur$genre)[1:2]) {
print(xyplot(days ~ pages, data = dur[dur$genre == g, ]))
}
drawn() > 0
#> [1] TRUE
invisible(dev.off())
The script tests "was anything drawn" with grid.ls(print = FALSE)$name: the listing is empty after the function call and after the first loop, and non-empty after the top-level call and the loop that prints. A script in a file run with Rscript is the same: a top-level xyplot() call is printed, because Rscript prints visible top-level values, but a call inside a function body or a loop is not.
Formulas
The formula of the lattice functions is y ~ x | g1 * g2. The variables left and right of the tilde are the primary variables, those after the bar are the conditioning variables, and * and + between conditioning variables are equivalent (g1 + g2 is also accepted). A formula without a bar makes one panel. The formula can contain expressions (sqrt(days) ~ log(pages)), and the variables are looked up in data first and then in the environment of the formula, as in Formulas.
For the displays with one numeric and one categorical variable (bwplot(), dotplot(), stripplot(), barchart()), the factor is the one that is not the measurement, and which side it is on decides the orientation: horizontal is FALSE when the variable on the right of the tilde is the factor (or shingle) and TRUE otherwise. genre ~ days gives horizontal boxes and days ~ genre vertical boxes. The univariate displays (histogram(), densityplot(), qqmath()) take a one-sided formula. barchart() and dotplot() also accept a table. In xyplot() both sides are numeric, and a factor on either side labels the axis with its levels.
Several terms on either side, y1 + y2 ~ x | g, are the "extended formula interface": the two responses are superposed in each panel, as if they were groups. I(y1 + y2) or allow.multiple = FALSE make the + an arithmetic sum. outer = TRUE puts the terms in separate panels instead.
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
svg(tempfile(fileext = ".svg"))
# The factor goes on the left and the number on the right: boxes lie flat.
flat <- bwplot(genre ~ days, data = dur)
flat$panel.args.common$horizontal
#> [1] TRUE
# Swap the sides and the boxes stand up.
upright <- bwplot(days ~ genre, data = dur)
upright$panel.args.common$horizontal
#> [1] FALSE
# The axis that carries the factor is labelled with its levels.
flat$y.limits
#> [1] "classic" "fiction" "mystery" "non-fiction"
#> [5] "poetry" "science fiction"
upright$x.limits
#> [1] "classic" "fiction" "mystery" "non-fiction"
#> [5] "poetry" "science fiction"
# A one-sided formula for the univariate displays.
h <- histogram(~days | genre, data = dur, type = "count", nint = 6)
dim(h)
#> [1] 6
h$panel.args.common[c("type", "nint")]
#> $type
#> [1] "count"
#>
#> $nint
#> [1] 6
#>
# A condition on a number turns it into a shingle (see below).
class(histogram(~days | pages, data = dur)$condlevels[[1L]])
#> [1] "shingleLevel"
# A table is accepted by barchart() and dotplot().
tab <- table(dur$genre)
bc <- barchart(tab)
bc$panel.args[[1L]]
#> $x
#> [1] 5 18 4 5 5 13
#>
#> $y
#> [1] classic fiction mystery non-fiction
#> [5] poetry science fiction
#> Levels: classic fiction mystery non-fiction poetry science fiction
#>
# xyplot() with a factor on one axis uses the levels as tick labels.
xf <- xyplot(days ~ genre, data = dur)
xf$x.limits
#> [1] "classic" "fiction" "mystery" "non-fiction"
#> [5] "poetry" "science fiction"
invisible(dev.off())
Smoothers, grids and the type argument
panel.xyplot() has an argument type, a character vector whose elements are combined: "p" points, "l" lines, "b" and "o" both, "h" vertical lines to the origin, "s" and "S" steps, "r" a regression line, "smooth" a loess fit, "spline" a smoothing spline, "a" the line of means for each distinct x, and "g" a background grid. Because arguments that a high-level function does not use itself are passed to the panel function, type = c("p", "r") can be given to xyplot() directly. The help page of panel.xyplot() recommends two arguments over these letters: smooth ("lm", "loess", "spline", "average", or TRUE for loess; added in lattice 0.21) in place of the smoothing letters, and grid (TRUE, or a list passed on to panel.grid(); added in lattice 0.19) in place of "g". grid is not data-dependent like the other type values, and the two combine without the string algebra. type is kept for backwards compatibility.
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
svg(tempfile(fileext = ".svg"))
# The older way: letters in type= add a regression line ("r") and a grid ("g").
old <- xyplot(days ~ pages, data = dur, type = c("p", "r", "g"))
old$panel.args.common$type
#> [1] "p" "r" "g"
# The documented preferred way, since lattice 0.21: smooth= and grid=.
new <- xyplot(days ~ pages, data = dur, smooth = "lm", grid = TRUE)
new$panel.args.common[c("smooth", "grid")]
#> $smooth
#> [1] "lm"
#>
#> $grid
#> [1] TRUE
#>
# Both draw without error; rows with a missing pages are skipped by the fits.
for (p in list(old, new)) print(p)
# smooth takes "lm", "loess", "spline" and "average"; TRUE means "loess".
both <- xyplot(days ~ year, data = dur, smooth = c("lm", "loess"))
both$panel.args.common$smooth
#> [1] "lm" "loess"
print(both)
invisible(dev.off())
Conditioning
Conditioning is the central idea, and it has four parts: which variables, how the panels are arranged, how they are ordered, and what they share.
Which variables. Conditioning variables must be factors or shingles; a character variable is converted to a factor and a numeric variable to a shingle. Unused factor levels are dropped (drop.unused.levels, TRUE by default for the conditioning variables and for the data).
Arrangement. layout = c(columns, rows) or c(columns, rows, pages) fixes the array. With one conditioning variable the default is c(0, n): a 0 in the first place means "work it out from the number of panels per page, the aspect ratio and the device size" when the plot is printed. With two conditioning variables the first is across and the second is down by default. as.table = TRUE fills the panels from the top left, like a table, rather than from the bottom left, like a graph. between = list(x = 0.5, y = 0.5) puts space between the panels, in character heights. skip leaves positions empty, and aspect sets the shape of a panel ("fill", "xy" for banking to 45 degrees, "iso" for equal scales, or a ratio).
Order. perm.cond permutes the conditioning variables and index.cond reorders or drops levels, either of which is better applied to an existing object with update() than recomputed. index.cond can also be a function of the panel’s data, which sorts the panels by, say, their median.
What they share. The default scales$relation = "same" gives every panel the same axis limits. See Scales below.
If the panels do not fit in the layout they continue on further pages. The next script uses two conditioning variables, changes the arrangement with update(), prints a plot that spans two pages, subsets by packet index and reorders.
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
svg(tempfile(fileext = ".svg"), onefile = TRUE)
dur$late <- factor(dur$days > loan_days, levels = c(FALSE, TRUE),
labels = c("on time", "overdue"))
# Two conditioning variables make a two-way array of panels.
p2 <- xyplot(days ~ pages | late * genre, data = dur)
dim(p2)
#> [1] 2 6
dimnames(p2)
#> $late
#> [1] "on time" "overdue"
#>
#> $genre
#> [1] "classic" "fiction" "mystery" "non-fiction"
#> [5] "poetry" "science fiction"
#>
# No layout is stored unless one is given: print() decides from the device.
p2$layout
#> NULL
# Change the arrangement without recomputing the packets.
p2b <- update(p2, layout = c(2, 3), as.table = TRUE)
p2b$layout
#> [1] 2 3
p2b$as.table
#> [1] TRUE
# Panels beyond the layout spill onto further pages.
p6 <- xyplot(days ~ pages | genre, data = dur, layout = c(2, 2))
dim(p6)
#> [1] 6
print(p6)
# Subsetting a trellis object keeps the packets you name, by position.
sub <- p6[c(2, 5)]
dim(sub)
#> [1] 2
# dimnames() reports every level; the levels still shown are in index.cond.
dimnames(sub)$genre
#> [1] "classic" "fiction" "mystery" "non-fiction"
#> [5] "poetry" "science fiction"
Map(\(lev, i) lev[i], sub$condlevels, sub$index.cond)
#> $genre
#> [1] "fiction" "poetry"
#>
# With two conditioning variables, index each dimension.
dim(p2[1, c(2, 5)])
#> [1] 1 2
# Character indices are not accepted.
tryCatch(p6[c("fiction", "poetry")], error = \(e) conditionMessage(e))
#> [1] "Invalid indices"
# perm.cond reorders the dimensions; index.cond reorders or drops levels.
ord <- update(p2, perm.cond = c(2, 1))
ord$perm.cond
#> [1] 2 1
idx <- update(p6, index.cond = list(c(2, 1)))
idx$index.cond
#> [[1]]
#> [1] 2 1
#>
invisible(dev.off())
A few details are worth noting. p2$layout is NULL for a plot where none was given: the layout is worked out when the plot is printed. dim() reports the number of panels that will be shown, and it responds to [ (dim(sub) is 2 after p6[c(2, 5)]), but dimnames() returns all the levels in the object, as the script shows; the levels still shown are condlevels indexed by index.cond. [ takes positions, not names, and with two conditioning variables one index per dimension. Printing a multi-page plot draws the pages one after another (the script uses svg(onefile = TRUE) so that the device accepts several pages in one file).
Shingles
A shingle is lattice’s generalisation of a factor to a numeric variable: a number together with a set of possibly overlapping intervals, which are its "levels". equal.count(x, number, overlap) makes the intervals so that each contains about the same number of values, with overlap given as a fraction; shingle(x, intervals) takes the intervals you choose; as.shingle(), is.shingle(), levels() and nlevels() work as for factors. A shingle as a conditioning variable makes one panel per interval, and a row can appear in more than one panel. A numeric conditioning variable that is not a shingle is converted with shingle(), whose default intervals are the unique values, which is a panel for each distinct number and rarely what is wanted. Use equal.count() (the lattice documentation says it is often more appropriate) or cut() for a factor.
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
svg(tempfile(fileext = ".svg"))
# A shingle is a number with overlapping intervals as its "levels".
pg <- dur$pages[!is.na(dur$pages)]
s <- equal.count(pg, number = 3, overlap = 0.1)
class(s)
#> [1] "shingle"
nlevels(s)
#> [1] 3
levels(s)
#> [,1] [,2]
#> [1,] 84 218
#> [2,] 210 326
#> [3,] 318 436
# equal.count() asks for about the same number of values in each interval.
vapply(seq_len(nlevels(s)), \(i) {
lim <- levels(s)[[i]]
sum(pg >= lim[[1L]] & pg <= lim[[2L]])
}, numeric(1L))
#> [1] 19 17 16
# shingle() takes the intervals you choose.
short_long <- shingle(pg, intervals = rbind(c(0, 250), c(200, 500)))
levels(short_long)
#> [,1] [,2]
#> [1,] 0 250
#> [2,] 200 500
# Used as a condition, a shingle gives one panel per interval; rows may
# appear in more than one panel.
dur$pages_band <- equal.count(dur$pages, number = 3, overlap = 0.1)
p <- xyplot(days ~ year | pages_band, data = dur, layout = c(3, 1))
dim(p)
#> [1] 3
p$packet.sizes
#> pages_band
#> [ 84, 218 ] [ 210, 326 ] [ 318, 436 ]
#> 19 17 16
# A numeric condition that is not a shingle is converted to one with shingle().
p_raw <- xyplot(days ~ year | pages, data = dur)
dim(p_raw)
#> [1] 13
invisible(dev.off())
The three panels have 19, 17 and 16 rows, 52 in all although there are 42 loans with a known number of pages: the rows whose page count falls in an overlap are in two panels. xyplot(days ~ year | pages) made 13 panels, one for each distinct page count.
groups and keys
groups superposes subsets inside each panel and varies the graphical parameters by group, taken from the superpose.symbol and superpose.line entries of the theme. A grouping variable makes the panel function receive groups and subscripts; the default panel functions handle them, and a custom panel function must accept them or use panel.superpose(). A key (a legend) explains the groups: auto.key = TRUE draws a simple one with the levels of groups as text, auto.key = list(...) adjusts it (space is one of "top", "bottom", "left", "right", with columns, title, points, lines, rectangles, cex), key = list(...) builds one in detail, and legend = list(right = list(fun = ...)) places arbitrary grobs. With the current defaults the auto.key legend is placed on the right (the example shows names(p$legend)), and lattice 0.21 improved its default placement and its handling of the type argument.
The difference between auto.key and key is when the symbols are fixed. auto.key stores only the labels and the key is built at plotting time, so it matches the settings that are in force when the plot is drawn. key is built when the object is created, with the settings that were current then, and keeps them even if the object is printed on a device with different settings.
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
svg(tempfile(fileext = ".svg"))
dur$kind <- factor(ifelse(dur$genre %in% c("fiction", "science fiction"),
"fiction", "other"))
# groups= superposes subsets in one panel, with one style per level.
p <- xyplot(days ~ pages, data = dur, groups = kind, auto.key = TRUE)
names(p$legend)
#> [1] "right"
# auto.key stores only the labels: drawSimpleKey() looks up the symbols and
# colours of the theme when the plot is drawn.
p$legend$right$fun
#> [1] "drawSimpleKey"
p$legend$right$args$text
#> [1] "fiction" "other"
levels(dur$kind)
#> [1] "fiction" "other"
p$panel.args.common$groups[1:3]
#> [1] fiction fiction fiction
#> Levels: fiction other
# A key can be placed and shaped with a list.
p2 <- xyplot(days ~ pages, data = dur, groups = kind,
auto.key = list(space = "top", title = "Kind", columns = 2))
names(p2$legend)
#> [1] "top"
# key= is fixed when the object is made; auto.key adapts when it is drawn.
p3 <- xyplot(days ~ pages, data = dur, groups = kind,
key = simpleKey(levels(dur$kind), points = TRUE))
names(p3$legend)
#> [1] "top"
p3$legend$top$fun
#> [1] "draw.key"
names(p3$legend$top$args$key)
#> [1] "text" "col" "cex" "alpha" "font"
#> [6] "fontface" "fontfamily" "points"
# Without groups there is nothing to key, so auto.key adds no legend.
p4 <- xyplot(days ~ pages, data = dur, auto.key = TRUE)
length(p4$legend)
#> [1] 0
# subscripts= are the row numbers of each panel's rows in the data.
p5 <- xyplot(days ~ pages | kind, data = dur, subscripts = TRUE)
p5$panel.args[[1L]]$subscripts[1:5]
#> [1] 1 2 3 4 5
invisible(dev.off())
Panel functions
The panel function is where the drawing happens. A panel function receives the x and y of one packet and every argument of the high-level call that lattice did not use itself, so pch = 16 given to xyplot() arrives in the panel function. The panel function must be written with grid, not with base graphics: lines() and points() do not work inside a panel (the error is "plot.new has not been called yet"), and lattice supplies grid versions that behave like them, named panel.* and l*: panel.points()/lpoints(), panel.lines()/llines(), panel.text()/ltext(), panel.segments(), panel.rect(), panel.polygon() and so on.
The building blocks in ?panel.functions are the usual ones:
| Function | What it draws |
|---|---|
|
The default panel function of |
|
Lines: a slope and intercept, a horizontal or vertical line, or a fitted model ( |
|
Smoothers and summaries added on top of the points. |
|
A background grid; positive |
|
A rug, a curve from an expression, a background fill, and a theoretical density. |
|
Calls |
|
Where in the array the current panel is (below). |
Two numbers say which panel is being drawn. panel.number() is the order of drawing, 1, 2, 3, …, and packet.number() the position of the packet in the natural order of the conditioning levels. They coincide unless perm.cond or index.cond changes the order, which is what the second half of the example shows. They are available inside the panel function, the strip function and the axis function, and while interacting with a plot through trellis.focus(), but not in the prepanel function, which runs before the plotting.
The panel function is evaluated when the plot is printed, not when xyplot() is called. The script records what each panel receives in an environment, and the count is zero until print(). Note ... in the panel function: it passes pch = 16 on to panel.xyplot().
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
svg(tempfile(fileext = ".svg"))
seen <- new.env()
# A panel function gets the x and y of one packet plus everything else that
# the high-level call did not use. Keep `...` so those extras flow through.
loan_panel <- \(x, y, ...) {
panel.grid(h = -1, v = -1, col = "grey85")
panel.xyplot(x, y, ...)
ok <- !is.na(x) & !is.na(y)
if (sum(ok) > 2L) {
panel.lmline(x[ok], y[ok], col = "firebrick")
}
panel.text(min(x, na.rm = TRUE), max(y, na.rm = TRUE),
labels = panel.number(), adj = c(0, 1))
assign(
sprintf("panel_%d", panel.number()),
c(packet = packet.number(), n = length(x)),
envir = seen
)
}
p <- xyplot(days ~ pages | genre, data = dur, panel = loan_panel, pch = 16)
# xyplot() has not called the panel function: that happens when it is drawn.
length(ls(seen))
#> [1] 0
print(p)
length(ls(seen))
#> [1] 6
mget(c("panel_1", "panel_2", "panel_6"), envir = seen)
#> $panel_1
#> packet n
#> 1 5
#>
#> $panel_2
#> packet n
#> 2 18
#>
#> $panel_6
#> packet n
#> 6 13
#>
# panel.number counts the panels in drawing order; packet.number is the
# position of the packet in the natural order of the conditioning levels.
# They differ once the order is changed.
rev_p <- update(p, index.cond = list(6:1))
rm(list = ls(seen), envir = seen)
print(rev_p)
vapply(mget(sprintf("panel_%d", 1:3), envir = seen), \(v) v[["packet"]],
numeric(1L))
#> panel_1 panel_2 panel_3
#> 6 5 4
invisible(dev.off())
Because the panel function runs when the plot is printed, anything it reads from the global environment is read then, not when the object was made. The script lets the panel function write to an environment it was given rather than using <<-. Also remember that x and y can contain NA: lattice passes the packet to a custom panel function as it is (the first panel of the example has missing page counts), so the line-fitting call above checks ok first.
Scales
scales is a list that controls the axes: tick marks, labels, limits, and whether the limits are shared. It can hold the same components for both axes or a sub-list for x and y, and a bare character string is read as relation.
| Component | Effect |
|---|---|
|
|
|
|
|
Positions and labels of ticks, or the suggested number of ticks. |
|
As |
|
Whether to draw the axis at all, and whether the labels alternate between the two sides of the array (default), or a vector choosing for each row or column: 0 none, 1 bottom or left, 2 top or right, 3 both. |
|
Appearance of tick labels and marks. |
|
Abbreviate long labels, as in |
|
Equally spaced ticks for log scales (default) or ticks as in traditional graphics. |
With relation = "same", x.limits holds one pair; with "free" or "sliced" it holds a list with one pair per panel, which the script prints.
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
svg(tempfile(fileext = ".svg"))
n3 <- dur[dur$genre %in% c("fiction", "science fiction", "poetry"), ]
n3$genre <- droplevels(n3$genre)
# relation = "same" (the default) gives every panel the same limits.
same <- xyplot(days ~ pages | genre, data = n3, layout = c(3, 1))
same$x.limits
#> [1] 65.46 432.54
same$y.limits
#> [1] -1.52 39.52
# relation = "free" works the limits out panel by panel: a list, not a pair.
free <- update(same, scales = list(relation = "free"))
class(free$x.limits)
#> [1] "list"
length(free$x.limits)
#> [1] 3
free$x.limits[[3L]]
#> [1] 315.84 416.16
# "sliced" keeps the length of the range the same but moves it.
sliced <- update(same, scales = list(y = list(relation = "sliced")))
vapply(sliced$y.limits, diff, numeric(1L))
#> [1] 41.04 41.04 41.04
# log = TRUE log-transforms the data; the tick labels show the original scale.
logx <- xyplot(days ~ pages | genre, data = n3, layout = c(3, 1),
scales = list(x = list(log = TRUE)))
logx$x.limits
#> [1] 1.897702 2.659565
logx$x.scales$log
#> [1] TRUE
logx$panel.args[[1L]]$x[1:3]
#> [1] 2.330414 2.330414 2.330414
# The log scale is part of how the packets are built: update() refuses it.
tryCatch(update(same, scales = list(x = list(log = TRUE))),
warning = \(w) conditionMessage(w))
#> [1] "log scales cannot be changed via 'update'"
# Explicit limits, tick positions and labels.
fixed <- update(same, xlim = c(0, 500),
scales = list(x = list(at = c(0, 250, 500),
labels = c("0", "250", "500"))))
fixed$x.limits
#> [1] 0 500
fixed$x.scales$at
#> [1] 0 250 500
invisible(dev.off())
The warning "log scales cannot be changed via 'update'" is real and instructive: a log scale transforms the data when the packets are built, so it cannot be applied to an existing object, and update(p, scales = list(x = list(log = TRUE))) warns and leaves the scale unchanged. The help page of xyplot() says that scales = list(x = list(log = 2)) is equivalent to writing log2(x) in the formula, apart from the axis labelling. More generally the help page of update.trellis() says that anything that would change the data within each panel (formula, data, groups, subscripts, subset) is not available to update().
Strips and titles
The strips above the panels show the conditioning level. strip = FALSE removes them, strip = strip.custom(...) changes them (strip.names = TRUE shows the variable name with the level, strip.levels = c(TRUE, FALSE) controls whether levels are shown, style changes the look, bg and fg the colours), strip.left = TRUE puts strips on the left of the panels, which suits wide, short panels, and par.strip.text = list(cex, col, font, lines, abbreviate, minlength) formats the text. The title and labels are main, sub, xlab, ylab, and xlab.top and ylab.right for the other sides; each can be a character string, an expression (plotmath), a list that passes arguments such as cex, col and font to the text grob, or a grob. xlab = NULL removes a label.
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
svg(tempfile(fileext = ".svg"))
# Strips label the panels; strip.custom() changes how they are drawn.
p <- xyplot(days ~ pages | genre, data = dur)
class(p$strip)
#> [1] "character"
class(update(p, strip = FALSE)$strip)
#> [1] "logical"
named <- update(
p, strip = strip.custom(strip.names = TRUE, strip.levels = TRUE)
)
class(named$strip)
#> [1] "function"
# par.strip.text controls the text of the strips: size, colour and height.
tall <- update(p, par.strip.text = list(cex = 0.8, lines = 2))
tall$par.strip.text
#> $cex
#> [1] 0.8
#>
#> $lines
#> [1] 2
#>
# Long labels can be abbreviated rather than wrapped.
abbr <- update(p, par.strip.text = list(abbreviate = TRUE, minlength = 4))
abbr$par.strip.text
#> $abbreviate
#> [1] TRUE
#>
#> $minlength
#> [1] 4
#>
# Strips can sit on the left of each panel.
left <- update(p, strip = FALSE, strip.left = TRUE)
class(left$strip.left)
#> [1] "function"
print(abbr)
invisible(dev.off())
Themes and par.settings
Lattice does not read par(). Its graphical parameters are in a settings list per device, queried with trellis.par.get(), changed with trellis.par.set(), and set for one object with the par.settings argument. A change to par(mfrow = ...) or par(mar = ...) has no effect on lattice plots (the lattice help page says so, and the base-versus-grid script above proves it for the panel width). Grid parameters that apply to the whole plot go in the grid.pars component of the settings.
The settings are kept per device name, not per device instance. Opening a second svg() device inherits what the first one left behind, and trellis.device() resets them: the example shows the symbol colour changed, then inherited by the next svg() device, then reset by trellis.device(). The help page says the settings of a device name are created when trellis.device() opens it or when a lattice plot is first drawn on it. A change with trellis.par.set() therefore lasts for the session unless the device is reopened with trellis.device(), and par.settings is the way to change only one plot.
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
svg(tempfile(fileext = ".svg"))
# The settings belong to the current device; the first plot creates them.
settings <- trellis.par.get()
class(settings)
#> [1] "list"
length(settings)
#> [1] 36
head(names(settings), 8L)
#> [1] "grid.pars" "fontsize" "background" "panel.background"
#> [5] "clip" "add.line" "add.text" "plot.polygon"
settings$plot.symbol
#> $alpha
#> [1] 1
#>
#> $cex
#> [1] 0.8
#>
#> $col
#> [1] "#0072B2"
#>
#> $font
#> [1] 1
#>
#> $pch
#> [1] 1
#>
#> $fill
#> [1] "transparent"
#>
trellis.par.get("add.line")$col
#> [1] "black"
# trellis.par.set() changes the settings of the open device, until it closes.
trellis.par.set("plot.symbol", list(col = "firebrick", pch = 16))
trellis.par.get("plot.symbol")$col
#> [1] "firebrick"
trellis.par.set(list(add.line = list(col = "grey40", lwd = 2)))
trellis.par.get("add.line")$lwd
#> [1] 2
# par.settings= travels with one object and does not touch the device.
trellis.par.set("plot.symbol", list(col = "black", pch = 1))
blue <- list(plot.symbol = list(col = "steelblue", pch = 19))
p <- xyplot(days ~ pages, data = dur, par.settings = blue)
p$par.settings$plot.symbol$col
#> [1] "steelblue"
trellis.par.get("plot.symbol")$col
#> [1] "black"
# simpleTheme() builds the common case: symbols, lines and fill in one call.
th <- simpleTheme(col = c("steelblue", "tomato"), pch = 16)
names(th)
#> [1] "plot.symbol" "plot.line" "plot.polygon"
#> [4] "superpose.symbol" "superpose.line" "superpose.polygon"
th$superpose.symbol$col
#> [1] "steelblue" "tomato"
invisible(dev.off())
# The settings are kept per device *name*, so a second svg() device inherits
# what the first one left behind ...
svg(tempfile(fileext = ".svg"))
trellis.par.get("plot.symbol")$col
#> [1] "black"
invisible(dev.off())
# ... until trellis.device() opens it and resets the settings to the defaults.
trellis.device(svg, file = tempfile(fileext = ".svg"))
trellis.par.get("plot.symbol")$col
#> [1] "#0072B2"
invisible(dev.off())
simpleTheme(col, pch, ...) builds the common change (symbols, lines and fill colours) as a theme list. A full theme is made by standard.theme(), custom_theme() and classic.theme().
The default colours changed in R 4.3.0
Older tutorials show lattice plots in a recognisable blue and magenta. The help page of standard.theme() says that since R 4.3.0 a new set of defaults, based on HCL palettes, is used, and the NEWS of lattice 0.21 records the change to modern palettes. The default symbol colour is now #0072B2 and the superposed groups use the Okabe-Ito palette; the earlier look is classic.theme("pdf"), which can be set for the session with lattice.options(default.theme = classic.theme("pdf")) in a startup script, or for one plot with par.settings.
library(lattice)
# The default colours come from an HCL palette; classic.theme() restores the
# colours that older tutorials show.
modern <- standard.theme()
classic <- classic.theme("pdf")
modern$plot.symbol$col
#> [1] "#0072B2"
classic$plot.symbol$col
#> [1] "#0080ff"
modern$superpose.symbol$col[1:3]
#> [1] "#0072B2" "#E69F00" "#009E73"
classic$superpose.symbol$col[1:3]
#> [1] "#0080ff" "#ff00ff" "darkgreen"
# A theme is a list that trellis.par.set() or par.settings= accepts.
class(modern)
#> [1] "list"
c("plot.symbol", "superpose.line", "strip.background") %in% names(modern)
#> [1] TRUE TRUE TRUE
# The old look in one object, without changing the session.
svg(tempfile(fileext = ".svg"))
p <- xyplot(mpg ~ wt, data = mtcars, par.settings = classic.theme("pdf"))
p$par.settings$plot.symbol$col
#> [1] "#0080ff"
invisible(dev.off())
Several plots on one page
print(x, split = c(col, row, ncol, nrow), position = c(xmin, ymin, xmax, ymax), more = FALSE, newpage = TRUE) places a trellis plot on a page that holds others. split says that the plot goes in cell (col, row) of an ncol by nrow array, with the origin at the top left. position places it in a rectangle with coordinates from 0 to 1 and the origin at the bottom left, and the two can be combined. more = TRUE says the page is not finished, so the next plot is drawn on the same page instead of starting a new one. newpage = FALSE draws into the current grid viewport, which makes it possible to embed a lattice plot in an arbitrary viewport, and draw.in = "name" goes to a named viewport. The prefix argument names the plot’s viewports and grobs; by default it is plot_01, plot_02, and so on, by serial number on the current page. This replaces gridExtra::grid.arrange() (not installed here) for lattice plots, and par(mfrow) and layout() of base graphics, which do not work. The help page warns that positioning a multi-page display does not work.
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
library(grid)
svg(tempfile(fileext = ".svg"))
scatter <- xyplot(days ~ pages, data = dur, main = "Duration by length")
boxes <- bwplot(genre ~ days, data = dur, main = "Duration by genre")
# split = c(x, y, nx, ny): the plot goes in cell (x, y) of an nx by ny array;
# more = TRUE says the page is not finished.
print(scatter, split = c(1, 1, 2, 1), more = TRUE)
print(boxes, split = c(2, 1, 2, 1))
# Each plot got its own prefix in the names of its viewports.
vps <- grid.ls(viewports = TRUE, grobs = FALSE, print = FALSE)$name
unique(sub("\\..*$", "", grep("toplevel", vps, value = TRUE)))
#> [1] "plot_01" "plot_02"
# position = c(xmin, ymin, xmax, ymax) places the plot in a rectangle of the
# page, in [0, 1] coordinates, and can overlap another plot.
grid.newpage()
print(scatter, position = c(0, 0, 1, 1), more = TRUE)
print(update(boxes, main = NULL), position = c(0.55, 0.5, 0.98, 0.95))
vps <- grid.ls(viewports = TRUE, grobs = FALSE, print = FALSE)$name
unique(sub("\\..*$", "", grep("toplevel", vps, value = TRUE)))
#> [1] "plot_01" "plot_02"
# newpage = FALSE draws into the current grid viewport instead.
grid.newpage()
pushViewport(viewport(width = 0.5, height = 0.5, name = "inset"))
print(scatter, newpage = FALSE)
current.vpPath()
#> inset
popViewport()
# An explicit prefix makes the viewport names predictable.
print(scatter, newpage = TRUE, prefix = "loans")
trellis.vpname("panel", column = 1, row = 1, prefix = "loans")
#> [1] "loans.panel.1.1.vp"
invisible(dev.off())
update(), trellis.last.object() and the plot arguments
update(p, ...) returns a copy of a trellis object with the named components replaced, using the same names as the high-level call; it records the change in the call component. The help page says that perm.cond and index.cond change the arrangement without recalculating the data subsets. A component set to NULL is removed. Some changes cannot be made this way: the data cannot change, and neither can the log scale above. t() transposes a plot with two conditioning variables. trellis.last.object() returns the last object that was printed (lattice saves it on printing, which the save.object option controls), and trellis.last.object(...) is update() on it. The plot.args component holds arguments for print() that travel with the object, such as a split.
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
svg(tempfile(fileext = ".svg"))
base <- xyplot(days ~ pages | genre, data = dur, layout = c(3, 2))
# update() returns a modified copy; the original object does not change.
titled <- update(base, main = "Loan duration by length", xlab = "Pages",
ylab = "Days", type = c("p", "r"))
base$main
#> NULL
titled$main
#> [1] "Loan duration by length"
base$call
#> xyplot(days ~ pages | genre, data = dur, layout = c(3, 2))
titled$call
#> xyplot(days ~ pages | genre, data = dur, layout = c(3, 2), main = "Loan duration by length",
#> xlab = "Pages", ylab = "Days", type = c("p", "r"))
identical(base$layout, titled$layout)
#> [1] TRUE
# Components that are not named stay as they were. NULL removes one.
untitled <- update(titled, main = NULL)
is.null(untitled$main)
#> [1] TRUE
# update() of a plot that was drawn without being saved: the last one drawn.
print(base)
last <- trellis.last.object()
class(last)
#> [1] "trellis"
last$call
#> xyplot(days ~ pages | genre, data = dur, layout = c(3, 2))
trellis.last.object(xlab = "Pages in the book")$xlab
#> [1] "Pages in the book"
invisible(dev.off())
Lattice is grid: trellis.focus()
Because a lattice plot is a tree of grid viewports and grobs, it can be changed after it is drawn. The names are predictable: for the plot with prefix plot_01, the panel in column 2 and row 1 is the viewport plot_01.panel.2.1.vp, its strip is plot_01.strip.2.1.vp, and trellis.vpname() builds the names. trellis.focus(name, column, row) makes the viewport current, so that any grid function can draw in it, and trellis.unfocus() returns to the top. Rows count from the bottom unless the plot was made with as.table = TRUE. The help page calls these interaction functions experimental, so their details may change. trellis.panelArgs() returns the data of the focused panel, current.panel.limits() its data limits, and panel.identify() and trellis.grobname() are the tools for interactive use. The lattice-aware panel functions (panel.abline(), panel.text()) draw into the focused panel, and what they draw is a named grob, so it can also be found with grid.ls().
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
library(grid)
svg(tempfile(fileext = ".svg"))
print(xyplot(days ~ pages | genre, data = dur, layout = c(3, 2)))
# Every panel is a grid viewport with a predictable name.
trellis.vpname("panel", column = 2, row = 1)
#> [1] "plot_01.panel.2.1.vp"
trellis.vpname("strip", column = 2, row = 1)
#> [1] "plot_01.strip.2.1.vp"
# trellis.focus() makes a panel the current viewport, so grid code can draw.
trellis.focus("panel", column = 2, row = 1)
current.vpPath()
#> plot_01.toplevel.vp::plot_01.panel.2.1.vp
lim <- current.panel.limits()
lim$xlim
#> [1] 63.92 456.08
lim$ylim
#> [1] -1.52 39.52
# The data of the focused panel is available as it was passed to the panel.
args <- trellis.panelArgs()
length(args$x)
#> [1] 18
panel.abline(h = loan_days, lty = "dashed", col = "grey40")
panel.number()
#> [1] 2
trellis.unfocus()
current.vpPath()
#> NULL
# The abline is now an object on the page, and the page is a big tree: this
# six-panel plot has one entry for every viewport it pushed.
length(grid.ls(viewports = TRUE, grobs = FALSE, print = FALSE)$name)
#> [1] 122
grep("abline", grid.ls(print = FALSE)$name, value = TRUE, ignore.case = TRUE)
#> [1] "plot_01.abline.h.panel.2.1"
invisible(dev.off())
The lattice package ships one vignette, vignette("grid", package = "lattice"), about using raw grid code with lattice (added in lattice 0.21; lattice package documentation: the grid vignette). The help page of print.trellis() says that the grid display tree is kept after the plot is produced, which older versions of lattice and grid did not do. The tree can be listed with grid.ls(viewports = TRUE), and it is long: the six-panel plot of the script has 122 entries after the focus step (viewports for each panel and strip and their "off" counterparts), which is why a script that wants to test something looks up a name rather than printing the listing.
Options, devices and a lattice-wide default
lattice.options() holds the package-wide non-graphical settings; lattice.getOption(name) reads one. The ones used most are default.args (the defaults of as.table, aspect, auto.key, between, grid, skip, strip and the scale and axis functions, as the script lists), default.theme, save.object, panel.error, drop.unused.levels and the names of the default panel functions (panel.xyplot, panel.bwplot, and so on). lattice.options(old) restores the value returned by a previous call. A call-specific change is the lattice.options = argument of a high-level function, which applies only for that call and stays with the object.
trellis.device(device, color, theme, new, retain, ...) opens a device and initialises the lattice settings for it, with theme a list, a function that returns one, or its name. It is not needed to draw a lattice plot, since any open device works and the first plot creates the settings for it. Older tutorials call trellis.device() and trellis.par.set() at the top of every script to get a white background and a good colour scheme; with the current defaults that is rarely needed. The script shows the call with svg and col.whitebg(), the older theme that sets a transparent background.
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
svg(tempfile(fileext = ".svg"))
# Package-wide options that are not graphical parameters.
opts <- lattice.options()
length(opts) > 20
#> [1] TRUE
head(sort(names(opts)), 6L)
#> [1] "axis.padding" "axis.units" "banking"
#> [4] "default.args" "drop.unused.levels" "highlight.gpar"
names(lattice.getOption("default.args"))
#> [1] "as.table" "aspect" "auto.key"
#> [4] "between" "grid" "skip"
#> [7] "strip" "xscale.components" "yscale.components"
#> [10] "axis"
lattice.getOption("default.args")$as.table
#> [1] FALSE
lattice.getOption("save.object")
#> [1] TRUE
# Change one for the whole session ... and put it back.
old <- lattice.options(default.args = list(as.table = TRUE, aspect = "fill"))
xyplot(days ~ pages | genre, data = dur)$as.table
#> [1] TRUE
lattice.options(old)
xyplot(days ~ pages | genre, data = dur)$as.table
#> [1] FALSE
# lattice.options= on one call is temporary and stays with that object.
p <- xyplot(days ~ pages | genre, data = dur,
lattice.options = list(default.args = list(as.table = TRUE)))
p$lattice.options$default.args
#> $as.table
#> [1] TRUE
#>
invisible(dev.off())
# trellis.device() opens a device and initialises the lattice settings; its
# first argument is a device function or its name.
trellis.device(svg, file = tempfile(fileext = ".svg"), theme = "col.whitebg")
dev.cur()
#> svg
#> 2
trellis.par.get("background")$col
#> [1] "transparent"
invisible(dev.off())
Other packages
The add-on package latticeExtra (not installed here) is the extension package the lattice help page itself points to; it adds, among other things, xscale.components.logpower for log axes, and it is named here and not used. gridExtra (also not installed) provides grid.arrange() and table grobs; for lattice plots print(split =) does the same job.
Grid underneath ggplot2
ggplot2 draws with grid too, in its own way: a plot is built from layers, and ggplotGrob() turns it into a gtable, a gTree that is a table of grobs (a layout whose cells hold the panel, the axes, the strips and the legend). So grid knowledge applies to ggplot2 (editing a drawn ggplot2 figure, putting it in a viewport, drawing annotations with grid functions), and lattice and ggplot2 plots can sit on the same page. The class vector of the plot in the script shows the S7 object system that ggplot2 4.0 adopted (the ggplot2 NEWS says the S3 parts were replaced with S7 ones; see The S7 Object System).
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
library(ggplot2)
library(grid)
svg(tempfile(fileext = ".svg"))
# ggplot2 builds its plot out of grid objects: ggplotGrob() returns the gtable.
paged <- dur[!is.na(dur$pages), ]
gg <- ggplot(paged, aes(pages, days)) +
geom_point()
class(gg)
#> [1] "ggplot2::ggplot" "ggplot" "ggplot2::gg" "S7_object"
#> [5] "gg"
g <- ggplotGrob(gg)
class(g)
#> [1] "gtable" "gTree" "grob" "gDesc"
is.grob(g)
#> [1] TRUE
head(g$layout$name, 6L)
#> [1] "background" "spacer" "axis-l" "spacer" "axis-t"
#> [6] "panel"
# The panel itself is a gTree of grobs.
panel <- g$grobs[[which(g$layout$name == "panel")]]
class(panel)
#> [1] "gTree" "grob" "gDesc"
any(grepl("geom_point", childNames(panel), fixed = TRUE))
#> [1] TRUE
# Both systems end up as grobs on the same page.
print(gg)
grid.ls(print = FALSE)$name[1L]
#> [1] "layout"
invisible(dev.off())
lattice, base graphics and ggplot2
| Base graphics | lattice | ggplot2 | |
|---|---|---|---|
Built on |
The graphics system on the engine |
grid |
grid |
A plot is |
A side effect of a call on the current device |
A |
A |
Panels by group |
Hand-built with |
The formula bar: |
|
Several groups in one panel |
Loop with |
|
|
Adding to a plot |
Call |
|
|
Customisation |
|
|
Themes, scales, layers, extensions |
Editing after drawing |
No: draw again |
Yes, through grid ( |
Yes, through the gtable ( |
Recommended package |
Part of base R |
Part of the recommended set |
Add-on package, not shipped with R |
The choice is partly taste and partly the data. lattice is small, ships with R, and is at its best for many comparable panels of the same display with scales shared on purpose, which was its design; ggplot2 is better when a plot is made of layers of different geometry and when the style should be consistent across a project. Both are worth reading the data from the same data frame, and neither replaces base graphics for a quick look.
Pitfalls
-
No
print()where R does not print. A lattice or ggplot2 object returned from a function or created in a loop is not drawn (R FAQ 7.22); return it and print it at the top level, or callprint(). -
Base graphics functions in a panel function.
lines(),points(),abline()andtext()fail inside a panel with "plot.new has not been called yet". Usepanel.lines()/llines(),panel.points(),panel.abline()andpanel.text(). By default lattice catches the error and writes it in the panel (panel.error), so a failed panel shows as text and the script continues; useprint(p, panel.error = stop)while developing. -
A panel function without
.... The extra arguments of the high-level call, such aspch = 16, are dropped silently, with no message. Always end a custom panel function’s arguments with...and pass it on. -
Numeric conditioning variables.
xyplot(y ~ x | z)with a numericzmakes one panel per distinct value. Useequal.count(z, number = 4)orcut(z, ...). -
Mixing systems.
par()has no effect on grid or lattice;layout()andgrid.layout()are different functions, and base functions and grid functions do not know each other’s state. To combine, useprint(split =)for lattice plots, a grid layout of viewports, or gridBase/gridGraphics. -
update()cannot change data.formula,data,groups,subscriptsandsubsetare not accepted byupdate(), and a log scale cannot be added (a warning says so). Build a new plot instead. -
The settings belong to the device name. A
trellis.par.set()made for onesvg()device is still there on the next one in the same session. Usepar.settingsfor a change that is meant for one plot, andtrellis.device()to reset. -
dimnames()is not what is shown. After[orindex.cond,dim()is right anddimnames()still lists every level. -
Panel order. With
as.table = FALSE(the default) panels start at the bottom left, like a graph, which is the reverse of reading order;as.table = TRUEstarts at the top left. -
Grob names and counters. Automatically generated grob names depend on a counter (
GRID.rect.1), and a figure that is drawn more than once in a session gets different numbers. Name grobs that code will look for. -
Interactive state.
grid.locator(),panel.identify()andtrellis.focus()called without a panel read the mouse, so they need an interactive screen device; they are not run on this page. -
The
Rplots.pdffile. A drawing call with no open device opens the default one; underRscriptthat writesRplots.pdfin the working directory. Open a device first (png(),svg(),pdf(NULL)) and close it withdev.off().
source("scenario.R")
library(lattice)
# Loan duration in days next to the pages and genre of each returned book.
books$isbn <- normalise_isbn(books$isbn)
books$genre <- factor(tolower(trimws(books$genre)))
dur <- merge(loans, books, by = "isbn")
dur$days <- as.numeric(dur$return_date - dur$loan_date)
dur <- dur[!is.na(dur$days) & dur$days >= 0, ]
svg(tempfile(fileext = ".svg"))
msg <- \(expr) {
tryCatch(
expr,
error = \(e) conditionMessage(e),
warning = \(w) conditionMessage(w)
)
}
# A variable that is not in `data` or the formula's environment.
msg(xyplot(days ~ no_such_column | genre, data = dur))
#> [1] "object 'no_such_column' not found"
# A data frame is needed when the formula uses column names.
msg(xyplot(days ~ pages | genre))
#> [1] "object 'genre' not found"
# A conditioning variable that is a number becomes a shingle: one panel for
# each distinct value, which is rarely what was meant.
dim(xyplot(days ~ year | pages, data = dur))
#> [1] 13
# An error in a panel function does not stop printing: the message is drawn
# inside the panel and print() returns normally.
failing <- \(x, y, ...) stop("no data for this packet")
p <- xyplot(days ~ pages | genre, data = dur, panel = failing)
class(print(p))
#> [1] "trellis"
# panel.error = stop raises it instead.
msg(print(p, panel.error = stop))
#> [1] "no data for this packet"
# A panel function without `...` silently ignores extra arguments.
no_dots <- \(x, y) panel.xyplot(x, y)
p2 <- xyplot(days ~ pages, data = dur, panel = no_dots, pch = 16, col = "red")
class(print(p2, panel.error = stop))
#> [1] "trellis"
# Base graphics functions do not work inside a lattice panel.
base_in_panel <- \(x, y, ...) lines(x, y)
p3 <- xyplot(days ~ pages, data = dur, panel = base_in_panel)
msg(print(p3, panel.error = stop))
#> [1] "plot.new has not been called yet"
# A factor on the response is allowed in xyplot: the levels label the axis.
yf <- xyplot(genre ~ days, data = dur)
class(yf$y.limits)
#> [1] "character"
invisible(dev.off())
The output shows each case. The missing column and the missing data are ordinary object-not-found errors from evaluating the formula. A panel function that fails does not stop print() (the class of the result is still "trellis") unless panel.error = stop is passed, and then the message is the one the panel function raised. An extra argument given to a panel function that lacks ... produces no message. A base graphics call inside a panel gives "plot.new has not been called yet". And xyplot() accepts a factor as the response and labels the axis with its levels.
Names from old tutorials that were removed from grid and lattice still exist as stubs, so that the error names the replacement. The script calls four grid names and lattice’s parallel(); grid.prompt(), removed in R 3.0.0, no longer exists at all, and the script checks that.
library(grid)
svg(tempfile(fileext = ".svg"))
msg <- \(expr) {
tryCatch(expr, error = \(e) conditionMessage(e))
}
# These names from old tutorials still exist, but only to say they are gone.
msg(push.viewport(viewport()))
#> [1] "'push.viewport' is defunct.\nUse 'pushViewport' instead.\nSee help(\"Defunct\")"
msg(grid.convertWidth(unit(1, "inches"), "cm"))
#> [1] "'grid.convertWidth' is defunct.\nUse 'convertWidth' instead.\nSee help(\"Defunct\")"
msg(grid.collection())
#> [1] "'grid.collection' is defunct.\nUse 'gTree' instead.\nSee help(\"Defunct\")"
msg(convertNative(unit(1, "native")))
#> [1] "'convertNative' is defunct.\nUse 'convertUnit' instead.\nSee help(\"Defunct\")"
# grid.prompt() was removed in R 3.0.0; the replacement is a base function.
exists("grid.prompt", envir = asNamespace("grid"))
#> [1] FALSE
is.function(devAskNewPage)
#> [1] TRUE
# lattice's parallel() is the same kind of stub since lattice 0.22.
msg(lattice::parallel(~ mtcars[1:3]))
#> [1] "'parallel' is defunct.\nUse 'parallelplot' instead.\nSee help(\"Defunct\")"
invisible(dev.off())
Source: R documentation: grid-defunct
Version notes
The grid model itself has changed little; the notable changes are additions to the graphics engine and to lattice’s defaults. The entries come from the NEWS files (R NEWS, R 3.x NEWS) and the NEWS of lattice, which is shipped with the package (NEWS.md) and published on CRAN.
| Version | Change |
|---|---|
R 3.0.0 |
|
R 4.0.0 |
New internal implementation of grid units (a different print format for unit arithmetic, faster operations, new |
R 4.1.0 |
Gradient and pattern fills ( |
R 4.2.0 |
|
R 4.3.0 |
|
R 4.5.0 |
|
R 4.6.0 |
|
lattice 0.20 |
Uses |
lattice 0.21 |
New default colour scheme ( |
lattice 0.22 |
|
The NEWS for R 4.6.1 lists no change to grid or lattice. The R-devel NEWS (the upcoming 4.7.0, labelled as such and never run here) lists only bug fixes in grid: "null" widths and heights of a layout are zero when there is no space to allocate, a crash in gpar() with a very long fontfamily is fixed, cairo_pdf() no longer ignores grid.newpage() after a glyph grob in a masked viewport, and grid.pack() with row.after, row.before, col.after and col.before is fixed. Behaviour described on this page is that of R 4.6.1.
Where older tutorials differ
| Older tutorial writes | Current practice | Why | Since |
|---|---|---|---|
|
|
The print method draws; automatic printing is off in functions and loops (R FAQ 7.22) |
always |
|
|
|
lattice 0.21 ( |
|
Open any device; use the defaults, or |
The default theme is device-independent and, since R 4.3.0, based on modern palettes |
R 4.3.0 |
Blue and magenta symbols as the default look |
The HCL/Okabe-Ito look; |
New defaults in lattice 0.21 |
R 4.3.0 |
|
|
Grid-based panel functions, and |
always |
|
|
|
always |
|
|
No extra package is needed |
always |
|
|
|
lattice 0.22 |
|
|
The old names are defunct and raise an error that names the replacement |
— |
|
|
|
R 3.0.0 |
Reaching into a unit’s list structure ( |
|
The internal representation of units changed |
R 4.0.0 |
|
A file device ( |
A script has no screen; the default device writes |
always |
|
|
|
always |
Where to go next
-
Base Graphics and Devices for devices,
par(),layout()and the traditional functions that grid replaces. -
ggplot2 and the Grammar of Graphics for the other plotting system built on grid.
-
Formulas for the formula language that lattice reads and
|extends. -
Factors for levels and
droplevels(), which decide the panels of a conditioned plot, and Data Frames formerge()and subsetting. -
Descriptive Statistics and Tests and Statistical Models for the analyses that these graphics display.
-
S3 Classes and Generics for the method dispatch behind
print.trellisandmakeContent. -
Scripts and the Command Line for
Rscriptand why a script needs an explicit device.
References
-
R Internals: 6 Graphics; 6.1 Graphics Devices, 6.3 Base graphics and 6.4 Grid graphics
-
An Introduction to R: 12 Graphical procedures
-
R FAQ 7.22: Why do lattice/trellis graphics not work?
-
R FAQ 7.11: Are Trellis displays implemented in R?
-
R NEWS: Changes in R 4.0.0 to 4.6.1; R 3.x NEWS; R-devel NEWS