Skip to contents

Installation

# install.packages("remotes")
remotes::install_github("gcol33/BiHeat")

biheat() takes two matrices and returns a finished figure. geom_biheat() draws the same split cells as a ggplot2 layer, from a long data frame, inside a plot you build yourself. Faceting, coordinate systems, other layers, and any theme are then available in the usual way.

The data shape

The layer wants one row per cell, with the two values in their own columns:

set.seed(1)
species   <- LETTERS[1:6]
sites     <- letters[1:8]
observed  <- matrix(runif(48), 6, 8, dimnames = list(species, sites))
predicted <- observed + matrix(rnorm(48, sd = 0.15), 6, 8,
                               dimnames = list(species, sites))

cells <- comes_out(breed(observed, predicted))
head(cells)
#> <biheat_data> 6 cells (6 x 8)
#>   upper = observed
#>   lower = predicted
#>   row col i j     upper     lower
#> 1   A   a 1 1 0.2655087 0.3584825
#> 2   B   a 2 1 0.3721239 0.3637046
#> 3   C   a 3 1 0.5728534 0.5494840
#> 4   D   a 4 1 0.9082078 0.6875949
#> 5   E   a 5 1 0.2016819 0.1299594
#> 6   F   a 6 1 0.8983897 0.9610809

comes_out() is a convenient source when the data started as matrices, and any long table works. Reshaping from two matrices by hand takes one call:

manual <- expand.grid(site = sites, species = species,
                      stringsAsFactors = FALSE)
manual$observed  <- as.vector(t(observed))
manual$predicted <- as.vector(t(predicted))
head(manual, 3)
#>   site species  observed predicted
#> 1    a       A 0.2655087 0.3584825
#> 2    b       A 0.9446753 1.1484772
#> 3    c       A 0.6870228 0.6278794

Four required aesthetics

x, y, upper, and lower are required. The first two place the cell, the last two fill its triangles:

ggplot(cells, aes(col, row, upper = upper, lower = lower)) +
  geom_biheat() +
  theme_biheat()

Rows come out in the alphabetical order a discrete axis gives them, running bottom to top. Reversing the factor levels puts the first species at the top, matching the matrix layout biheat() produces:

cells$site    <- factor(cells$col, levels = sites)
cells$species <- factor(cells$row, levels = rev(species))

ggplot(cells, aes(site, species, upper = upper, lower = lower)) +
  geom_biheat() +
  theme_biheat()

split flips the diagonal, as it does elsewhere in BiHeat:

ggplot(cells, aes(site, species, upper = upper, lower = lower)) +
  geom_biheat(split = "up") +
  theme_biheat()

Colour ranges

The layer carries its own pair of palettes and maps values to colours itself. Each triangle is stretched across the range of its own data by default, which is the scale = "separate" behaviour of biheat(). upper_range and lower_range set those ranges explicitly, and passing the same range to both gives a shared scale:

shared <- range(c(cells$upper, cells$lower))

ggplot(cells, aes(site, species, upper = upper, lower = lower)) +
  geom_biheat(upper_range = shared, lower_range = shared) +
  theme_biheat()

Fixing the ranges is what makes several panels comparable. Facets computed from the data alone would each get their own range, and a cell in one panel would then mean something different from the same colour in the next.

Values outside a supplied range are clamped to the nearest end rather than dropped, so a range chosen for the bulk of the data still draws every cell.

Palettes work as they do in incubate(), taking a palettes() object or a bare vector with lower_palette:

ggplot(cells, aes(site, species, upper = upper, lower = lower)) +
  geom_biheat(palettes = palettes(c("white", "darkorange", "darkred"),
                                  c("white", "steelblue", "navy"))) +
  theme_biheat()

Cell geometry and outlines

cell_inset opens a gap between neighbours, and colour with linewidth outlines each triangle. Both are the same controls incubate() exposes, and colour is an aesthetic here, so it can vary per cell:

ggplot(cells, aes(site, species, upper = upper, lower = lower)) +
  geom_biheat(cell_inset = 0, colour = "white", linewidth = 0.4) +
  theme_biheat()

The full set of aesthetics:

Aesthetic Role
x, y Cell centre. Required.
upper, lower The two values. Required.
width, height Cell footprint. Defaults to the resolution of the positions.
colour, linewidth, linetype Triangle outline.
alpha Triangle opacity.

colour, linewidth, linetype, and alpha behave as they do in any ggplot2 layer, settable as constants in the call or mapped through aes(). The fills are the exception, since the layer resolves them from upper and lower against the palettes rather than through a scale.

Mapping colour marks a subset without disturbing the fills:

cells$flagged <- abs(cells$upper - cells$lower) > 0.25

ggplot(cells, aes(site, species, upper = upper, lower = lower)) +
  geom_biheat(aes(colour = flagged), cell_inset = 0.02, linewidth = 0.6) +
  scale_colour_manual(values = c(`TRUE` = "black", `FALSE` = NA),
                      guide = "none") +
  theme_biheat()

Missing values

A missing value belongs to one triangle. The layer keeps the cell, draws the half it has, and fills the other with na_colour. Only a missing position drops a cell entirely.

gappy <- cells
gappy$upper[c(3, 11, 26)] <- NA
gappy$lower[40] <- NA

ggplot(gappy, aes(site, species, upper = upper, lower = lower)) +
  geom_biheat(na_colour = "grey75") +
  theme_biheat()

Faceting

Faceting is the reason to reach for the layer. Splitting a model comparison by group, with a range fixed across panels so the colours mean the same thing in each:

cells$region <- ifelse(cells$col %in% letters[1:4], "north", "south")

ggplot(cells, aes(site, species, upper = upper, lower = lower)) +
  geom_biheat(upper_range = shared, lower_range = shared) +
  facet_wrap(~region, scales = "free_x") +
  theme_biheat()

coord_fixed() keeps the cells square whatever the panel dimensions:

ggplot(cells, aes(site, species, upper = upper, lower = lower)) +
  geom_biheat() +
  coord_fixed() +
  theme_biheat()

Continuous axes

Nothing requires a discrete axis. With numeric x and y the cell footprint comes from the resolution of the positions, which is the smallest gap between them, so cells stay square and evenly sized however the positions are spread.

width and height override that per cell, which lets the footprint carry a third variable. Sizing each cell by how much data went into it:

transects <- data.frame(
  x     = c(1, 2, 3, 4, 5),
  y     = rep(1, 5),
  upper = c(0.2, 0.9, 0.5, 0.7, 0.3),
  lower = c(0.4, 0.6, 0.8, 0.2, 0.5),
  n     = c(5, 40, 12, 30, 8)
)

ggplot(transects, aes(x, y, upper = upper, lower = lower)) +
  geom_biheat(aes(width = 0.3 + n / 50, height = 0.3 + n / 50),
              upper_range = c(0, 1), lower_range = c(0, 1)) +
  coord_fixed() +
  theme_biheat()

The two small cells came from a handful of observations, and the split inside them is worth less than the split inside the large ones. The ranges are anchored at c(0, 1) here rather than left to the data, which keeps the smallest value off the white end of each palette; a triangle drawn in the palette’s first colour is easy to mistake for an absent one. A continuous axis also lets cells sit at real coordinates, so a grid of sites with known positions keeps its geometry.

Layers on top

The cells are polygons in a normal panel, so anything else can be drawn over them. Labelling the cells where the two matrices disagree most:

worst <- cells[order(-abs(cells$upper - cells$lower)), ][1:5, ]
worst$gap <- round(worst$upper - worst$lower, 2)

ggplot(cells, aes(site, species, upper = upper, lower = lower)) +
  geom_biheat() +
  geom_text(data = worst, aes(site, species, label = gap),
            size = 2.8, fontface = "bold", inherit.aes = FALSE) +
  theme_biheat()

geom_point(), geom_segment(), and annotations work the same way. Setting inherit.aes = FALSE on the added layer keeps it from inheriting upper and lower, which it has no use for.

The fill scale stays free

Neither triangle consumes ggplot2’s fill scale, which leaves it for another layer. Here a column of per-species mean error sits beside the heatmap, on its own continuous fill scale:

margin <- data.frame(
  site    = factor("summary", levels = c(levels(cells$site), "summary")),
  species = factor(species, levels = rev(species)),
  mae     = as.vector(tapply(abs(cells$upper - cells$lower), cells$row, mean))
)
cells$site <- factor(cells$col, levels = levels(margin$site))

ggplot(mapping = aes(site, species)) +
  geom_biheat(data = cells, aes(upper = upper, lower = lower)) +
  geom_tile(data = margin, aes(fill = mae), width = 0.92, height = 0.92) +
  scale_fill_viridis_c(name = "MAE") +
  theme_biheat()

The same freedom applies to colour, size, and any other scale the layer does not touch, so points, labels, and segments can be added over the cells.

Layer plumbing

stat and position are the standard ggplot2 layer arguments and default to "identity", which is what a heatmap wants: one row of data becomes one cell, at the position given. data and mapping behave as in any layer, so several geom_biheat() calls can draw different subsets with different palettes in one panel.

show.legend is accepted for consistency with other layers. Since the fills never pass through a scale there is no key for ggplot2 to draw, which is why bi_legend() exists.

Each cell becomes two polygons of three vertices, so a 50 by 50 grid is 5,000 polygons and 15,000 vertices. That draws quickly to a raster device, and vector output grows with the same count, so an SVG or PDF of a large grid gets heavy. Rasterising just this layer is worth considering past a few thousand cells.

A legend of your own

geom_biheat() draws no legend, since the colours never pass through a scale. bi_legend() builds the paired key as a standalone grob, and base grid places it next to the panel:

panel <- ggplot(cells, aes(site, species, upper = upper, lower = lower)) +
  geom_biheat(upper_range = shared, lower_range = shared) +
  theme_biheat()

key <- bi_legend("observed", "predicted",
                 upper_range = shared,
                 lower_range = shared)

grid::grid.newpage()
grid::pushViewport(grid::viewport(
  layout = grid::grid.layout(1, 2, widths = grid::unit(c(1, 4.6),
                                                       c("null", "cm")))
))
grid::pushViewport(grid::viewport(layout.pos.col = 1))
grid::grid.draw(ggplotGrob(panel))
grid::popViewport()
grid::pushViewport(grid::viewport(layout.pos.col = 2))
grid::grid.draw(key)
grid::popViewport(2)

Pass bi_legend() the same ranges the layer used and the ticks describe the colours on screen. Its split argument should match the layer’s, so the glyph shows the diagonal that was drawn.

Which entry point

biheat() covers the case where two matrices go in and one figure comes out, with alignment, comparison statistics, and the legend handled. geom_biheat() covers everything that needs the rest of ggplot2: facets, a second layer, a coordinate system, a theme applied across a set of figures, or a panel that is one piece of a larger composition.

The practical differences:

biheat() geom_biheat()
Input Two matrices One long data frame
Alignment on dimnames Done by breed() Yours to do
Comparison statistics On the clutch Compute from the table
Colour ranges scale = "separate" or "shared" upper_range, lower_range
Legend Drawn, with the key glyph bi_legend(), placed by you
Return A gtable, or a ggplot without the key Always a ggplot layer
Facets, extra layers, coords Through show_key = FALSE Directly

Going from matrices to the layer takes one call, since comes_out() returns exactly the table the layer wants. A workflow can start with biheat() for a first look, then move to the layer when the figure needs facets or a companion panel.

They share the drawing code, so a plot built either way puts the same triangles in the same places.

Where to go next