Nothing
calendar_data <- function() {
x <- 0:41
data.frame(
time = as.Date("2025-04-07") + x,
value = sin(x / 7 * 2 * pi) + x / 40
)
}
#' A completed theme, as the render methods are handed one at draw time
#'
#' Registered element defaults -- the calendar's own among them -- are merged
#' into a theme only when it is completed, so a bare `theme_grey()` is not what
#' a plot actually renders with: the granule fills would inherit
#' `panel.background` rather than defaulting to blank, and the rules' `rel()`
#' linewidths would be read as absolute ones.
#' @noRd
calendar_theme <- function(theme = ggplot2::theme_grey()) {
ggplot2::complete_theme(theme)
}
#' Every granule grob drawn anywhere in a rendered layer, by name
#'
#' A label is a `titleGrob`, i.e. a named `gTree` with the text as its child, so
#' a grob's own name is checked before descending into it.
#' @noRd
calendar_grobs <- function(grob) {
found <- if (grepl("^ggtime\\.calendar\\.", grob$name %||% "")) {
stats::setNames(list(grob), grob$name)
} else {
list()
}
if (inherits(grob, "gTree")) {
found <- c(
found,
# Unnamed, or `unlist()` would prefix each grob's name with its parent's.
unlist(unname(lapply(grob$children, calendar_grobs)), recursive = FALSE)
)
}
found %||% list()
}
calendar_rule_names <- function(grob) {
names(calendar_grobs(grob)) %||% character()
}
#' The panel's own gridlines actually drawn in a tree, by element name
#'
#' A blanked element still leaves a named `zeroGrob()` behind, so drawing
#' nothing has to be told apart from drawing a line; and `ggname()` suffixes an
#' element's name with the grob's own, which is trimmed back off.
#' @noRd
panel_grid_names <- function(grob) {
if (inherits(grob, "zeroGrob")) {
return(character())
}
drawn <- c(
grob$name %||% character(),
unlist(lapply(grob$children %||% list(), panel_grid_names)) %||% character()
)
unique(sub("\\.\\..*$", "", grep("^panel\\.grid\\.", drawn, value = TRUE)))
}
#' The labels drawn for one granule, in the order they were placed
#' @noRd
calendar_labels <- function(grob, granule) {
drawn <- calendar_grobs(grob)[[paste0("ggtime.calendar.", granule, ".text")]]
if (is.null(drawn)) {
return(NULL)
}
drawn$children[[1]]$label
}
test_that("rows are laid out from cut loops", {
skip_if_no_r42_graphics()
p <- ggplot(calendar_data(), aes(x = time, y = value)) +
geom_line() +
scale_x_date(date_breaks = "1 day", date_labels = "%a")
vdiffr::expect_doppelganger(
"weekly rows",
p + coord_calendar(rows = mixtime::weeks(1L), cols = NULL),
writer = write_svg_r42
)
})
test_that("columns are laid out from cut loops", {
skip_if_no_r42_graphics()
p <- ggplot(calendar_data(), aes(x = time, y = value)) +
geom_line() +
scale_x_date(date_breaks = "1 day", date_labels = "%a")
vdiffr::expect_doppelganger(
"monthly columns",
p + coord_calendar(rows = mixtime::weeks(1L), cols = mixtime::months(1L)),
writer = write_svg_r42
)
})
test_that("panes and columns are separated by gaps", {
skip_if_no_r42_graphics()
x <- 0:167
df <- data.frame(
time = as.Date("2025-01-06") + x,
value = sin(x / 7 * 2 * pi) + x / 160
)
p <- ggplot(df, aes(x = time, y = value)) +
geom_line() +
scale_x_date(date_breaks = "1 day", date_labels = "%a")
vdiffr::expect_doppelganger(
"pane and column gaps",
p +
coord_calendar(
rows = mixtime::weeks(1L),
panes = mixtime::months(1L),
cols = mixtime::quarters(1L)
),
writer = write_svg_r42
)
# `is_flipped` threads through the layout, the rules and the axes, so it gets
# a case of its own.
vdiffr::expect_doppelganger(
"flipped pane and column gaps",
ggplot(df, aes(y = time, x = value)) +
geom_path() +
scale_y_date(date_breaks = "1 day", date_labels = "%a") +
coord_calendar(
time = "y",
rows = mixtime::weeks(1L),
panes = mixtime::months(1L),
cols = mixtime::quarters(1L)
),
writer = write_svg_r42
)
})
test_that("granules are labelled within the calendar", {
skip_if_no_r42_graphics()
x <- 0:167
df <- data.frame(
time = as.Date("2025-01-06") + x,
value = sin(x / 7 * 2 * pi) + x / 160
)
p <- ggplot(df, aes(x = time, y = value)) +
geom_line() +
scale_x_date(date_breaks = "1 day", date_labels = "%a")
# Cell labels are on by default; block and row labels opt in, and each lands
# in a corner of its own rather than on top of the others. `pane` is off so
# that the monthly boundary is ruled as a block rather than gapped as a pane.
vdiffr::expect_doppelganger(
"granule labels",
p +
coord_calendar(
rows = mixtime::weeks(1L),
blocks = mixtime::months(1L),
panes = NULL,
cols = mixtime::quarters(1L),
label_blocks = "{cyc(month, year, label = TRUE, abbreviate = TRUE)}",
label_rows = "{cyc(cal_isoweek$week, year)}"
),
writer = write_svg_r42
)
})
test_that("gridlines and axes are not folded into a single row", {
# Panel decoration describes the row window rather than data within it, so it
# is repeated across rows. Applying the row placement to it would squeeze the
# vertical gridlines and axis labels into the top row.
df <- calendar_data()
p <- ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(rows = mixtime::weeks(1L), cols = NULL)
built <- ggplot_build(p)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
bg <- coord$render_bg(params, calendar_theme())
# The cell gridlines are one grob covering the whole grid rather than one
# per tile, so a tile's worth of them is a block of segments within it: each
# block must still span its own row from top to bottom.
vgrid <- calendar_grobs(bg)[["ggtime.calendar.cell.line"]]
n_break <- length(params$cell_breaks)
expect_gt(n_break, 0L)
# One column per tile, tiles ordered row-major.
y <- matrix(as.numeric(vgrid$y), nrow = 2L * n_break)
expect_equal(ncol(y), coord$.grid$n_row * coord$.grid$n_col)
layout <- coord$grid_layout(params)
tile_row <- rep(seq_len(coord$.grid$n_row), each = coord$.grid$n_col)
expect_equal(apply(y, 2L, min), layout$row$y[tile_row])
expect_equal(
apply(y, 2L, max),
(layout$row$y + layout$row$height)[tile_row]
)
})
test_that("the panel's own grid is dropped", {
# A calendar repeats the panel's decoration in every cell, so the panel's own
# grid is dropped entirely: the time axis is ruled by `cell` instead, and
# the calendar's rows are what rule the other way.
df <- calendar_data()
p <- ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(rows = mixtime::weeks(1L), cols = NULL)
built <- ggplot_build(p)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
grid <- panel_grid_names(coord$render_bg(params, calendar_theme()))
expect_equal(grid, character())
# Flipped, the axes swap, and neither brings its gridlines back.
flipped <- ggplot_build(
ggplot(df, aes(y = time, x = value)) +
geom_line() +
coord_calendar(rows = mixtime::weeks(1L), cols = NULL, time = "y")
)
grid <- panel_grid_names(flipped$plot$coordinates$render_bg(
flipped$layout$panel_params[[1]],
calendar_theme()
))
expect_equal(grid, character())
})
test_that("a theme cannot reinstate the gridlines a calendar drops", {
# The panel's grid is blanked at each specific element name, so setting the
# more specific element does not slip past the parent it inherits from.
df <- calendar_data()
p <- ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(rows = mixtime::weeks(1L), cols = NULL) +
theme(
panel.grid.minor.y = element_line(colour = "red"),
panel.grid.major.x = element_line(colour = "red"),
panel.grid.major.y = element_line(colour = "red")
)
built <- ggplot_build(p)
bg <- built$plot$coordinates$render_bg(
built$layout$panel_params[[1]],
ggplot2:::plot_theme(p)
)
expect_equal(panel_grid_names(bg), character())
})
test_that("row layout stacks loops without overlapping", {
df <- calendar_data()
p <- ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(rows = mixtime::weeks(1L), cols = NULL)
built <- ggplot_build(p)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
# Six weekly cuts over six weeks of data, and a single column.
expect_gt(coord$.grid$n_row, 1L)
expect_equal(coord$.grid$n_col, 1L)
transformed <- coord$transform(built$data[[1]], params)
# Every point lies within its own row's horizontal band.
expect_true(all(transformed$y >= 0 & transformed$y <= 1))
expect_lt(diff(range(transformed$y)), 1)
})
test_that("column layout stacks loops side by side without overlapping", {
df <- calendar_data()
p <- ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(rows = NULL, cols = mixtime::weeks(1L))
built <- ggplot_build(p)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
# Six weekly columns over six weeks of data, and a single row per column.
expect_gt(coord$.grid$n_col, 1L)
expect_equal(coord$.grid$n_row, 1L)
transformed <- coord$transform(built$data[[1]], params)
# Every point lies within its own column's vertical band.
expect_true(all(transformed$x >= 0 & transformed$x <= 1))
expect_lt(diff(range(transformed$x)), 1)
})
#' Where each of a day's points lands in the calendar grid
#'
#' The whole point of a calendar layout is that a value can be read off the
#' grid, so the grid position of every day is derived the way a reader derives
#' it: from the npc coordinates `transform()` produces and the tile geometry
#' the panel is drawn with, rather than from the coord's internal cuts.
#' @param dates One point per date, in order.
#' @param ... Passed to `coord_calendar()`.
#' @returns A data frame of `date` and its 1-based `col`, `row` and `cell`
#' (the position within the row, counted in `cells` per row).
#' @noRd
calendar_places <- function(dates, ..., cells = 7L) {
df <- data.frame(time = dates, value = 1)
built <- ggplot_build(
ggplot(df, aes(x = time, y = value)) + geom_point() + coord_calendar(...)
)
coord <- built$plot$coordinates
panel_params <- built$layout$panel_params[[1]]
pos <- coord$transform(
built$data[[1]][, c("x", "y")],
panel_params
)
layout <- coord$grid_layout(panel_params)
col <- findInterval(pos$x, layout$col$x)
within <- (pos$x - layout$col$x[col]) / layout$col$width[col]
data.frame(
date = dates,
col = col,
# Rows are indexed from the top, against the npc `y` they are placed in.
row = length(layout$row$y) - findInterval(pos$y, rev(layout$row$y)) + 1L,
cell = floor(within * cells + 1e-9) + 1L
)
}
test_that("a day is placed in the calendar cell a printed calendar puts it in", {
# A quarter of daily data, laid out as ISO weeks within months: the layout
# every calendar plot is read as, and the one to check against a calendar.
dates <- seq(as.Date("2015-01-01"), as.Date("2015-03-31"), by = "day")
places <- calendar_places(
dates,
rows = mixtime::weeks(1L),
cols = mixtime::months(1L),
panes = NULL
)
# A cell of the grid is a weekday, in every column alike -- the axis is drawn
# once beneath the whole calendar, so a column that shifted the weekdays
# along would be read off wrongly. `weeks()` is ISO, so Monday opens a row.
expect_equal(places$cell, as.integer(format(dates, "%u")))
# A day is in the column of its own month, and in the week of the month that
# a calendar prints it in, counting the week the 1st falls in as the first.
expect_equal(places$col, as.integer(format(dates, "%m")))
first <- as.Date(format(dates, "%Y-%m-01"))
weeks_in <- as.integer(dates - first + (as.integer(format(first, "%u")) - 1L))
expect_equal(places$row, weeks_in %/% 7L + 1L)
# March 2015 runs Sunday to Tuesday, so it needs six rows where January and
# February need five; the shared grid takes the largest.
expect_equal(max(places$row), 6L)
})
test_that("a row holds only the days of one column and pane", {
# The granule hierarchy is `col` over `pane` over `block` over `row`: a week
# running from one month into the next belongs to two panes, so it is cut in
# two and each half is a row of its own pane rather than one row of both.
dates <- seq(as.Date("2015-01-01"), as.Date("2015-06-30"), by = "day")
places <- calendar_places(
dates,
rows = mixtime::weeks(1L),
panes = mixtime::months(1L),
cols = mixtime::quarters(1L)
)
# No row of the grid mixes the days of two months.
months <- split(format(places$date, "%Y-%m"), places[c("col", "row")])
expect_true(all(lengths(lapply(months, unique)) <= 1L))
# A day is still under its own weekday, cut row or not.
expect_equal(places$cell, as.integer(format(dates, "%u")))
# A day is in the column of its own quarter.
expect_equal(
places$col,
(as.integer(format(dates, "%m")) - 1L) %/% 3L + 1L
)
# The day a month opens on starts a row, and the day before it closes the one
# above -- unless the month opens a column, which starts the grid again.
opens <- which(format(places$date, "%d") == "01")[-1L]
stays <- places$col[opens] == places$col[opens - 1L]
expect_equal(places$row[opens[stays]], places$row[opens[stays] - 1L] + 1L)
expect_true(all(places$row[opens[!stays]] == 1L))
})
test_that("panes are aligned across the calendar's columns", {
dates <- seq(as.Date("2015-01-01"), as.Date("2015-06-30"), by = "day")
built <- ggplot_build(
ggplot(data.frame(time = dates, value = 1), aes(x = time, y = value)) +
geom_point() +
coord_calendar(
rows = mixtime::weeks(1L),
panes = mixtime::months(1L),
cols = mixtime::quarters(1L)
)
)
coord <- built$plot$coordinates
# A pane is as tall as the most rows any column puts in it, so that one gap
# separates the same two months in every column. January, February, April and
# May need five rows each once split; March and June need six and five, so
# the third pane takes six.
expect_equal(coord$.grid$pane_rows, c(5L, 5L, 6L))
expect_equal(coord$.grid$n_row, 16L)
expect_equal(coord$.grid$row_pane, rep(1:3, c(5L, 5L, 6L)))
})
test_that("calendar_pieces cuts a row at every coarser boundary", {
# Rows of 3 over columns of 10 and panes of 5, so that neither the column nor
# the pane boundaries fall on a row boundary; the grid closes at 21, past the
# last column, as cutting a range always does.
col_cuts <- c(0, 10, 20)
row_cuts <- seq(0, 21, by = 3)
pane_cuts <- seq(0, 20, by = 5)
# The row grid rounds out to 21, but the calendar closes with its columns:
# the rest of that row is time no column of it has room for.
close <- calendar_close(col_cuts, row_cuts, pane_cuts, NULL, has_col = TRUE)
expect_equal(close, 20)
pieces <- calendar_pieces(
col_cuts = col_cuts,
row_cuts = row_cuts,
pane_cuts = pane_cuts,
block_cuts = NULL,
close = close
)
# A piece per row of each column, cut wherever a column or pane starts.
expect_equal(pieces$cuts, c(0, 3, 5, 6, 9, 10, 12, 15, 18, 20))
# Each is folded onto the row it is a part of, not onto its own start, so a
# piece cut short still measures where in the row it sits.
expect_equal(pieces$origins, c(0, 3, 3, 6, 9, 9, 12, 15, 18))
expect_equal(pieces$col, c(1L, 1L, 1L, 1L, 1L, 2L, 2L, 2L, 2L))
# Panes and rows are counted within the column, so the grid can align the
# second pane of every column at the same row.
expect_equal(pieces$pane, c(1L, 1L, 2L, 2L, 2L, 1L, 1L, 2L, 2L))
expect_equal(pieces$pane_row, c(1L, 2L, 1L, 2L, 3L, 1L, 2L, 1L, 2L))
expect_equal(
pieces$pane_start,
c(TRUE, FALSE, TRUE, FALSE, FALSE, TRUE, FALSE, TRUE, FALSE)
)
expect_null(pieces$block_start)
# Two rows in the first pane of either column and three in the second, so the
# grid is five rows deep and each column's second pane starts at the third.
expect_equal(calendar_pane_rows(pieces), c(2L, 3L))
expect_equal(
calendar_piece_rows(pieces, c(2L, 3L)),
c(1L, 2L, 3L, 4L, 5L, 1L, 2L, 3L, 4L)
)
})
test_that("cuts a second apart survive on a modern time axis", {
# The tolerance cuts are compared with has to scale with how far apart they
# are, not with how far the epoch is from zero: scaled to the epoch, it is
# ~1.8 seconds on any present-day `POSIXct` axis, and every row of a calendar
# with rows shorter than that is silently merged away.
epoch <- as.numeric(as.POSIXct("2015-01-01", tz = "UTC"))
col_cuts <- epoch + c(0, 60, 120)
row_cuts <- epoch + seq(0, 120, by = 1)
close <- calendar_close(col_cuts, row_cuts, NULL, NULL, has_col = TRUE)
expect_equal(close, epoch + 120)
pieces <- calendar_pieces(
col_cuts = col_cuts,
row_cuts = row_cuts,
pane_cuts = NULL,
block_cuts = NULL,
close = close
)
# A one-second row of each of the two one-minute columns.
expect_equal(length(pieces$cuts) - 1L, 120L)
expect_equal(calendar_pane_rows(pieces), 60L)
expect_equal(pieces$col, rep(1:2, each = 60L))
# The other half of the contract: a boundary reached by flooring at two
# different granules can differ in the last bit or two (1e-6 here is a few
# ULPs at this epoch), and those two cuts must still count as one rather
# than opening a row of nothing.
nudged <- calendar_pieces(
col_cuts = col_cuts,
row_cuts = row_cuts,
pane_cuts = NULL,
block_cuts = col_cuts + 1e-6,
close = close
)
expect_equal(nudged$cuts, pieces$cuts)
})
test_that("a calendar of one-second rows is laid out row by row", {
# End to end, through the granules rather than the numeric cuts: nothing else
# in the suite exercises a granule finer than a day.
time <- mixtime::datetime(
seq(as.POSIXct("2015-01-01", tz = "UTC"), by = "1 sec", length.out = 120)
)
built <- ggplot_build(
ggplot(data.frame(time = time, value = seq_len(120)), aes(time, value)) +
geom_point() +
coord_calendar(
cells = NULL,
rows = mixtime::seconds(1L),
blocks = NULL,
panes = NULL,
cols = mixtime::minutes(1L)
)
)
params <- built$layout$panel_params[[1]]
expect_equal(params$grid$n_col, 2L)
expect_equal(params$grid$n_row, 60L)
expect_equal(length(params$pieces$cuts) - 1L, 120L)
})
test_that("calendar_close() falls back to `end` when no cut reaches it", {
# Every current caller guarantees a cut at or past `end`, but if `row_cuts`,
# `pane_cuts` and `block_cuts` are all absent and `has_col = FALSE` drops
# `col_cuts` too, the filtered vector is empty. `min()` on that would
# silently return `Inf`; the guard should return `end` instead.
col_cuts <- c(0, 10, 20)
close <- calendar_close(
col_cuts,
row_cuts = NULL,
pane_cuts = NULL,
block_cuts = NULL,
has_col = FALSE
)
expect_equal(close, 20)
expect_false(is.infinite(close))
})
#' Where each of a day's points lands in a flipped calendar's grid
#'
#' `calendar_places()` for `time = "y"`. A flipped calendar transposes the
#' grid *and* reflects each of its dimensions, so that it still reads the way
#' a calendar reads: rows run left to right along `x`, and columns run top to
#' bottom down `y`, with time still running up the panel within a column.
#' Each dimension therefore sits at `1 - (pos + extent)` on the axis it now
#' runs along, which is all this has to undo -- there is no reversed index
#' left over.
#' @inheritParams calendar_places
#' @noRd
calendar_places_flipped <- function(dates, ..., cells = 7L) {
df <- data.frame(time = dates, value = 1)
built <- ggplot_build(
ggplot(df, aes(y = time, x = value)) +
geom_point() +
coord_calendar(time = "y", ...)
)
coord <- built$plot$coordinates
panel_params <- built$layout$panel_params[[1]]
pos <- coord$transform(
built$data[[1]][, c("x", "y")],
panel_params
)
layout <- coord$grid_layout(panel_params)
# The left edge of each row, and the bottom edge of each column.
row_x <- 1 - (layout$row$y + layout$row$height)
col_y <- 1 - (layout$col$x + layout$col$width)
# Columns are indexed from the top, so they are looked up by how far down
# the panel a point sits.
col <- findInterval(1 - pos$y, layout$col$x)
within <- (pos$y - col_y[col]) / layout$col$width[col]
data.frame(
date = dates,
col = col,
row = findInterval(pos$x, row_x),
cell = floor(within * cells + 1e-9) + 1L
)
}
test_that("the calendar grid is arranged the same way flipped", {
dates <- seq(as.Date("2015-01-01"), as.Date("2015-03-31"), by = "day")
args <- list(
rows = mixtime::weeks(1L),
cols = mixtime::months(1L),
panes = NULL
)
expect_equal(
inject(calendar_places_flipped(dates, !!!args)),
inject(calendar_places(dates, !!!args))
)
})
test_that("a flipped calendar reads left to right and top to bottom", {
# A transpose on its own is a reflection about the main diagonal, which
# reverses *both* reading directions: the first week would be drawn at the
# right edge with later weeks marching leftwards, and January would sit
# below February. Pinned here on the raw npc positions, so that the grid
# cannot quietly turn back to front again.
dates <- as.Date(c(
"2015-01-01",
"2015-01-02",
"2015-01-08",
"2015-02-01",
"2015-02-08"
))
built <- ggplot_build(
ggplot(data.frame(time = dates, value = 1), aes(y = time, x = value)) +
geom_point() +
coord_calendar(
time = "y",
rows = mixtime::weeks(1L),
cols = mixtime::months(1L),
panes = NULL
)
)
pos <- built$plot$coordinates$transform(
built$data[[1]][, c("x", "y")],
built$layout$panel_params[[1]]
)
names(pos) <- c("x", "y")
rownames(pos) <- format(dates)
# Rows run left to right: a later week of the same month is further right.
expect_lt(pos["2015-01-01", "x"], pos["2015-01-08", "x"])
expect_lt(pos["2015-02-01", "x"], pos["2015-02-08", "x"])
# The first row of the grid starts at the left edge rather than the right.
expect_lt(pos["2015-01-01", "x"], 0.5)
# Columns run top to bottom: January sits above February.
expect_gt(pos["2015-01-01", "y"], pos["2015-02-01", "y"])
expect_gt(pos["2015-01-08", "y"], pos["2015-02-08", "y"])
# The first column of the grid starts at the top edge rather than the
# bottom, and time still runs up the panel within a row of it.
expect_gt(pos["2015-01-01", "y"], 0.5)
expect_gt(pos["2015-01-02", "y"], pos["2015-01-01", "y"])
})
test_that("the derived row count does not leak between builds", {
# The coord object outlives a single build, so deriving the row count by
# accumulating a maximum must be reset each time or a reused coord keeps
# the largest row count it has ever seen. `cols = NULL` keeps a single
# column spanning the data's own range, so shrinking the data directly
# shrinks the row count (unlike with `cols` set, where a column's width --
# and so the row count -- comes from the `cols` granule, not the data).
df <- calendar_data()
coord <- coord_calendar(rows = mixtime::weeks(1L), cols = NULL)
invisible(ggplot_build(
ggplot(df, aes(x = time, y = value)) + geom_line() + coord
))
many_row <- coord$.grid$n_row
invisible(ggplot_build(
ggplot(df[1:5, ], aes(x = time, y = value)) + geom_line() + coord
))
expect_lt(coord$.grid$n_row, many_row)
})
test_that("the derived column count does not leak between builds", {
df <- calendar_data()
coord <- coord_calendar(rows = NULL, cols = mixtime::weeks(1L))
invisible(ggplot_build(
ggplot(df, aes(x = time, y = value)) + geom_line() + coord
))
many_col <- coord$.grid$n_col
invisible(ggplot_build(
ggplot(df[1:5, ], aes(x = time, y = value)) + geom_line() + coord
))
expect_lt(coord$.grid$n_col, many_col)
})
#' All grobs of a given grid class found anywhere in a grob tree
#' @noRd
grobs_of_class <- function(grob, class) {
found <- if (inherits(grob, class)) list(grob) else list()
if (inherits(grob, "gTree")) {
found <- c(
found,
unlist(
lapply(grob$children, grobs_of_class, class = class),
recursive = FALSE
)
)
}
found
}
test_that("blanked panel grid elements are not tiled as zeroGrobs", {
# The panel's own grid is blanked (see "the panel's own grid is dropped"
# above), which leaves a zeroGrob per blanked element in the parent's
# render_bg() output. Tiling strips those, rather than carrying dead grobs
# across the grid for them only to be walked and skipped at draw time.
df <- calendar_data()
p <- ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(rows = mixtime::weeks(1L), cols = NULL)
built <- ggplot_build(p)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
bg <- coord$render_bg(params, calendar_theme())
expect_equal(length(grobs_of_class(bg, "zeroGrob")), 0L)
})
test_that("a build is not corrupted by drawing after a later build reused its coord", {
# The coord object outlives a single build and can be shared by more than
# one plot (a template, a `patchwork`, a `ggplot_build()` result drawn
# later). The layout state a build derives is read back only at *draw*
# time (`ggplot_gtable()`), so building a second, smaller plot against the
# same coord -- even though its own draw never happens -- must not corrupt
# the first plot's draw.
make_data <- function(n) {
data.frame(
time = as.Date("2020-01-01") + seq_len(n) - 1L,
value = seq_len(n)
)
}
coord <- coord_calendar(rows = mixtime::weeks(1L), cols = NULL)
p_big <- ggplot(make_data(200), aes(x = time, y = value)) +
geom_point() +
coord
p_small <- ggplot(make_data(10), aes(x = time, y = value)) +
geom_point() +
coord
built_big <- ggplot_build(p_big)
invisible(ggplot_build(p_small))
gt <- ggplot_gtable(built_big)
panel <- gt$grobs[[which(gt$layout$name == "panel")[1]]]
points <- grobs_of_class(panel, "points")
expect_length(points, 1L)
x <- grid::convertX(points[[1]]$x, "npc", valueOnly = TRUE)
y <- grid::convertY(points[[1]]$y, "npc", valueOnly = TRUE)
expect_true(all(is.finite(x)))
expect_true(all(is.finite(y)))
})
test_that("self$limits is restored even if the parent setup_panel_params() errors", {
# `setup_panel_params()` temporarily overwrites `self$limits` with the row
# window so the parent's expansion/breaks/limits logic applies to what is
# actually drawn, then restores it. That restore has to happen even if the
# parent call errors, or the coord is left permanently holding the row
# window as its user limits, corrupting every later build.
df <- calendar_data()
p <- ggplot(df, aes(x = time, y = value)) +
geom_point() +
coord_calendar(rows = mixtime::weeks(1L), cols = NULL)
built <- ggplot_build(p)
layout <- built$layout
scale_x <- layout$panel_scales_x[[1]]
scale_y <- layout$panel_scales_y[[1]]
coord <- layout$coord
params <- layout$coord_params
# `ggplot_build()` above already exercised `setup_panel_params()` once;
# reset to a known baseline before poisoning the scale.
coord$limits <- list(x = NULL, y = NULL)
before <- coord$limits
# `setup_panel_params()` calls the parent's `setup_panel_params()` twice:
# once for the uncut range, once (with `self$limits` overwritten) for the
# row window. Erroring only on the second call exercises the restore
# without preventing the method from getting that far.
orig_get_limits <- scale_x$get_limits
calls <- 0L
scale_x$get_limits <- function() {
calls <<- calls + 1L
if (calls == 2L) {
stop("forced error for testing")
}
orig_get_limits()
}
expect_error(
coord$setup_panel_params(scale_x, scale_y, params),
"forced error for testing"
)
expect_identical(coord$limits, before)
})
test_that("cell and block granules can be disabled", {
df <- calendar_data()
p <- ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(
rows = mixtime::weeks(1L),
cols = NULL,
cells = NULL,
blocks = NULL
)
built <- ggplot_build(p)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
expect_null(params$cell_breaks)
expect_null(params$block_cuts)
# No cell/block rules drawn, but rows are still separated from each other.
rules <- calendar_rule_names(coord$render_bg(params, calendar_theme()))
expect_false(any(grepl("cell|block", rules)))
expect_true("ggtime.calendar.row.line" %in% rules)
})
test_that("cell and block granules add extra gridline layers", {
df <- calendar_data()
p <- ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(
rows = mixtime::weeks(1L),
cols = NULL,
cells = mixtime::days(1L),
blocks = mixtime::months(1L),
# A default monthly pane would gap this boundary instead of ruling it.
panes = NULL
)
built <- ggplot_build(p)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
expect_false(is.null(params$cell_breaks))
expect_false(is.null(params$block_cuts))
# The block rule is layered on top of (rather than tiled into) the row x
# column grid, so it is a sibling of the tiled grid as a whole rather than
# part of a tile. Data spans April into May, so there is a block boundary to
# draw.
bg <- coord$render_bg(params, calendar_theme())
expect_true("ggtime.calendar.block.line" %in% calendar_rule_names(bg))
# The grid itself is drawn as geometry rather than replicated grobs: one
# rect describing every tile's background, and one polyline describing every
# tile's cell rules.
n_tile <- coord$.grid$n_row * coord$.grid$n_col
rects <- grobs_of_class(bg, "rect")
expect_length(rects, 1L)
expect_length(as.numeric(rects[[1]]$x), n_tile)
expect_equal(sum(calendar_rule_names(bg) == "ggtime.calendar.cell.line"), 1L)
cell_rules <- calendar_grobs(bg)[["ggtime.calendar.cell.line"]]
expect_equal(
length(cell_rules$id.lengths),
n_tile * length(params$cell_breaks)
)
})
test_that("the drawn background does not grow with the grid", {
# A calendar's decoration is the same handful of shapes in every tile, so it
# is drawn as geometry (one rect and one polyline over the whole grid)
# rather than replicated per tile. The number of grobs the background costs
# is therefore a small constant, whether the grid holds six tiles or a
# couple of hundred -- which is what makes the draw cost independent of the
# size of the calendar.
calendar <- function(df, ...) {
built <- ggplot_build(
ggplot(df, aes(x = time, y = value)) + geom_line() + coord_calendar(...)
)
list(
coord = built$plot$coordinates,
params = built$layout$panel_params[[1]]
)
}
background <- function(plot, theme) {
bg <- plot$coord$render_bg(plot$params, calendar_theme(theme))
list(
tiles = plot$coord$.grid$n_row * plot$coord$.grid$n_col,
# Every grob inherits "grob", so this counts the whole tree.
grobs = length(grobs_of_class(bg, "grob")),
rects = length(grobs_of_class(bg, "rect")),
cell_rules = sum(calendar_rule_names(bg) == "ggtime.calendar.cell.line")
)
}
small_plot <- calendar(
calendar_data(),
rows = mixtime::weeks(1L),
cols = NULL
)
big_df <- data.frame(
time = seq(as.Date("2022-01-01"), as.Date("2024-12-31"), by = "day")
)
big_df$value <- seq_len(nrow(big_df))
big_plot <- calendar(
big_df,
cells = mixtime::days(1L),
rows = mixtime::days(7L),
panes = mixtime::months(1L),
cols = mixtime::quarters(1L)
)
# The fast path recognises the shapes a theme's panel decoration is made of,
# so it is checked against more than the default theme: a theme that draws a
# border, one that draws the panel over the data, and one that draws almost
# nothing must all keep the constant cost, not quietly fall back to a grob
# per tile.
themes <- list(
grey = theme_grey(),
bw = theme_bw(),
linedraw = theme_linedraw(),
dark = theme_dark(),
minimal = theme_minimal(),
ontop = theme_grey() + theme(panel.ontop = TRUE),
bordered = theme_grey() +
theme(panel.background = element_rect(fill = NA, colour = "black")),
# A granule fill is one vectorised rect however many instances it has, so
# it must not push the panel's own decoration off the geometry path.
filled = theme_grey() +
theme(ggtime.calendar.cell.background = element_rect(fill = "grey90"))
)
for (name in names(themes)) {
small <- background(small_plot, themes[[name]])
big <- background(big_plot, themes[[name]])
# The grids are of very different sizes ...
expect_gt(big$tiles, 20 * small$tiles)
# ... but cost the same, constant, handful of grobs to draw.
expect_equal(big$rects, small$rects, label = paste0(name, ": rects"))
expect_equal(
big$cell_rules,
small$cell_rules,
label = paste0(name, ": cell rules")
)
expect_lt(big$grobs, 2 * small$grobs, label = paste0(name, ": grobs"))
expect_lt(big$grobs, 20L, label = paste0(name, ": grobs"))
}
})
test_that("decoration that cannot be drawn as geometry is copied per tile", {
# Tiling by geometry only knows the shapes a calendar itself draws; a
# wrapped coord's decoration could be anything, and anything else still has
# to be drawn -- as a copy of the grob in every tile's own viewport.
df <- calendar_data()
built <- ggplot_build(
ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(rows = mixtime::weeks(1L), cols = NULL)
)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
ctx <- calendar_render_context(coord, params, calendar_theme())
tiled <- tile_grob_in_grid(circleGrob(), ctx)
expect_length(grobs_of_class(tiled, "circle"), coord$.grid$n_row)
# One tile's viewport each, in the tile's own place, rather than all of them
# drawn over each other.
layout <- coord$grid_layout(params)
expect_equal(
vapply(tiled$children, function(g) as.numeric(g$vp$y), numeric(1L)),
layout$row$y,
ignore_attr = TRUE
)
})
test_that("a block rule replaces the row rule at the boundary it falls on", {
df <- calendar_data()
built <- ggplot_build(
ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(
rows = mixtime::weeks(1L),
blocks = mixtime::months(1L),
# A default monthly pane would gap this boundary instead of ruling it.
panes = NULL,
cols = NULL
)
)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
bg <- coord$render_bg(params, calendar_theme())
rules <- calendar_grobs(bg)
row_rule <- rules[["ggtime.calendar.row.line"]]
block_rule <- rules[["ggtime.calendar.block.line"]]
# Five interior boundaries between six rows, each ruled exactly once.
expect_equal(
length(row_rule$id.lengths) + length(block_rule$id.lengths),
coord$.grid$n_row - 1L
)
expect_equal(length(block_rule$id.lengths), 1L)
# The two never coincide.
expect_false(any(as.numeric(block_rule$y) %in% as.numeric(row_rule$y)))
})
test_that("pane gaps replace the rules they separate", {
df <- data.frame(
time = as.Date("2025-01-06") + 0:167,
value = seq_len(168L)
)
built <- ggplot_build(
ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(
rows = mixtime::weeks(1L),
panes = mixtime::months(1L),
blocks = mixtime::months(1L),
cols = mixtime::quarters(1L)
)
)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
expect_false(is.null(coord$.grid$row_pane))
expect_equal(length(coord$.grid$row_pane), coord$.grid$n_row)
layout <- coord$grid_layout(params)
gaps <- layout$row$y[-coord$.grid$n_row] -
(layout$row$y[-1] + layout$row$height[-1])
expect_equal(sum(gaps > 1e-9), sum(diff(coord$.grid$row_pane) != 0L))
# `block` and `pane` are the same granule here, so every block boundary is
# already separated by a gap and no block rule is left to draw.
rules <- calendar_rule_names(coord$render_bg(params, calendar_theme()))
expect_false("ggtime.calendar.block.line" %in% rules)
})
test_that("rules stop at a column gap", {
df <- data.frame(time = as.Date("2025-01-06") + 0:167, value = seq_len(168L))
built <- ggplot_build(
ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(rows = mixtime::weeks(1L), cols = mixtime::months(1L))
)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
bg <- coord$render_bg(params, calendar_theme())
layout <- coord$grid_layout(params)
row_rule <- calendar_grobs(bg)[["ggtime.calendar.row.line"]]
# One rule per row boundary per column, each spanning its own column only.
expect_equal(
length(row_rule$id.lengths),
(coord$.grid$n_row - 1L) * coord$.grid$n_col
)
expect_setequal(
unique(as.numeric(row_rule$x)),
c(layout$col$x, layout$col$x + layout$col$width)
)
})
test_that("coord_calendar defaults produce a sensible layout without arguments", {
df <- calendar_data()
p <- ggplot(df, aes(x = time, y = value)) + geom_line() + coord_calendar()
built <- ggplot_build(p)
coord <- built$plot$coordinates
expect_gt(coord$.grid$n_row, 1L)
params <- built$layout$panel_params[[1]]
transformed <- coord$transform(built$data[[1]], params)
expect_true(all(transformed$x >= 0 & transformed$x <= 1))
expect_true(all(transformed$y >= 0 & transformed$y <= 1))
})
test_that("calendar_layout tiles the panel seamlessly without spacing", {
layout <- calendar_layout(n_row = 4L, n_col = 3L)
expect_equal(layout$col$width, rep(1 / 3, 3))
expect_equal(layout$col$x, c(0, 1 / 3, 2 / 3))
expect_equal(layout$row$height, rep(1 / 4, 4))
# Rows are indexed from the top, so the first row is the highest up.
expect_equal(layout$row$y, c(3 / 4, 2 / 4, 1 / 4, 0))
# Tiles meet edge to edge and fill the panel.
expect_equal(layout$col$x + layout$col$width, c(1 / 3, 2 / 3, 1))
expect_equal(max(layout$row$y + layout$row$height), 1)
})
test_that("calendar_layout spaces tiles by a fraction of a tile", {
# Two columns with a quarter-tile gap: 2 tiles + 0.25 of a tile spans 1.
layout <- calendar_layout(1L, 2L, spacing = list(col = 0.25))
expect_equal(layout$col$width, rep(1 / 2.25, 2))
expect_equal(layout$col$x, c(0, 1.25 / 2.25))
expect_equal(max(layout$col$x + layout$col$width), 1)
# Pane gaps land only between rows of different panes, so four rows in two
# panes take a single gap.
layout <- calendar_layout(
4L,
1L,
row_pane = c(1L, 1L, 2L, 2L),
spacing = list(pane = 0.5)
)
expect_equal(layout$row$height, rep(1 / 4.5, 4))
expect_equal(diff(rev(layout$row$y)), c(1, 1.5, 1) / 4.5)
expect_equal(max(layout$row$y + layout$row$height), 1)
expect_equal(min(layout$row$y), 0)
# Spacing with every row in its own pane gaps every row.
layout <- calendar_layout(3L, 1L, row_pane = 1:3, spacing = list(pane = 0.5))
expect_equal(layout$row$height, rep(1 / 4, 3))
})
test_that("calendar_dim_tracks turns gaps into empty tracks", {
layout <- calendar_layout(1L, 3L)
# A seamless layout is all tile and no filler, so the axis gtable is
# unchanged from the equal `1 / n` division it used to compute itself.
track <- calendar_dim_tracks(layout$col, reverse = FALSE)
expect_equal(track$sizes, rep(1 / 3, 3))
expect_true(all(track$tile))
track <- calendar_dim_tracks(
calendar_layout(1L, 2L, spacing = list(col = 0.25))$col,
reverse = FALSE
)
expect_equal(track$tile, c(TRUE, FALSE, TRUE))
expect_equal(track$sizes, c(1, 0.25, 1) / 2.25)
expect_equal(sum(track$sizes), 1)
# Reversed, tracks are ordered downwards from the top of the panel, so the
# first row of the calendar comes first.
track <- calendar_dim_tracks(
calendar_layout(3L, 1L, row_pane = c(1L, 1L, 2L), list(pane = 1))$row,
reverse = TRUE
)
expect_equal(track$tile, c(TRUE, TRUE, FALSE, TRUE))
expect_equal(track$sizes, c(1, 1, 1, 1) / 4)
})
test_that("repeated axes get one track per row and column", {
df <- calendar_data()
built <- ggplot_build(
ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(rows = mixtime::weeks(1L), cols = mixtime::months(1L))
)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
# Columns are gapped by default, so the axis gtable holds a filler track
# between each pair of label sets to keep them aligned with their column.
h <- coord$render_axis_h(params, calendar_theme())[[1]]
expect_equal(length(h$widths), 2L * coord$.grid$n_col - 1L)
expect_equal(
as.numeric(h$widths)[c(TRUE, FALSE)],
coord$grid_layout(params)$col$width
)
# Rows are not gapped without `pane`, so their axes still tile end to end.
v <- coord$render_axis_v(params, calendar_theme())[[1]]
expect_equal(length(v$heights), coord$.grid$n_row)
})
test_that("granule elements inherit the theme's own panel styling", {
# `plot_theme()` rather than `theme_grey()` directly: registered element
# defaults are only merged in when the plot's theme is completed.
p <- ggplot(calendar_data(), aes(x = time, y = value)) +
geom_line() +
coord_calendar()
theme <- ggplot2:::plot_theme(p)
grid <- calc_element("panel.grid", theme)
cell <- calendar_element(theme, "cell", "line")
block <- calendar_element(theme, "block", "line")
# No colour of our own: the rules follow the theme's gridlines, so ink/paper
# theming carries over.
expect_equal(cell@colour, grid@colour)
expect_equal(block@colour, grid@colour)
# A hairline cell rule and a heavy block rule, either side of the theme's.
expect_lt(cell@linewidth, grid@linewidth)
expect_gt(block@linewidth, grid@linewidth)
dark <- ggplot2:::plot_theme(p + theme_grey(ink = "white", paper = "black"))
expect_equal(
calendar_element(dark, "block", "line")@colour,
calc_element("panel.grid", dark)@colour
)
# Granule fills are opt-in rather than inherited over the panel.
expect_null(calendar_element(theme, "cell", "background"))
})
test_that("granule elements can be blanked and overridden", {
p <- ggplot(calendar_data(), aes(x = time, y = value)) +
geom_line() +
coord_calendar()
blanked <- ggplot2:::plot_theme(p + theme(panel.grid = element_blank()))
expect_null(calendar_element(blanked, "cell", "line"))
expect_null(calendar_element(blanked, "block", "line"))
styled <- ggplot2:::plot_theme(
p + theme(ggtime.calendar.cell.line = element_line(colour = "red"))
)
expect_equal(calendar_element(styled, "cell", "line")@colour, "red")
})
test_that("blank granule elements drop their gridlines", {
built <- ggplot_build(
ggplot(calendar_data(), aes(x = time, y = value)) +
geom_line() +
coord_calendar(rows = mixtime::weeks(1L), cols = NULL)
)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
theme <- calendar_theme(
theme_grey() +
theme(
ggtime.calendar.cell.line = element_blank(),
ggtime.calendar.block.line = element_blank(),
ggtime.calendar.pane.line = element_blank(),
ggtime.calendar.row.line = element_blank()
)
)
expect_equal(calendar_rule_names(coord$render_bg(params, theme)), character())
})
#' A theme setting one granule's background, completed as a plot's would be
#' @noRd
granule_fill_theme <- function(granule, fill = "grey90") {
element <- stats::setNames(
list(if (is.null(fill)) element_blank() else element_rect(fill = fill)),
calendar_element_name(granule, "background")
)
calendar_theme(theme_grey() + inject(theme(!!!element)))
}
#' Every rect one granule's background draws, as plain npc numbers
#' @noRd
granule_background <- function(bg, granule) {
rect <- calendar_grobs(bg)[[calendar_element_name(granule, "background")]]
if (is.null(rect)) {
return(NULL)
}
data.frame(
x = as.numeric(rect$x),
y = as.numeric(rect$y),
width = as.numeric(rect$width),
height = as.numeric(rect$height)
)
}
#' The name of every grob of a tree, in the order it is drawn
#' @noRd
draw_order <- function(grob) {
if (inherits(grob, "gTree")) {
return(unlist(lapply(grob$children, draw_order)) %||% character())
}
grob$name %||% ""
}
#' A calendar exercising all five granules at once
#' @noRd
every_granule_calendar <- function(...) {
df <- data.frame(time = as.Date("2025-01-06") + 0:363)
df$value <- seq_len(nrow(df))
built <- ggplot_build(
ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(
cells = mixtime::days(1L),
rows = mixtime::weeks(1L),
blocks = mixtime::months(1L),
panes = mixtime::months(1L),
cols = mixtime::quarters(1L),
...
)
)
list(
coord = built$plot$coordinates,
params = built$layout$panel_params[[1]]
)
}
test_that("every granule's background is drawn behind its own rules", {
plot <- every_granule_calendar()
plain <- plot$coord$render_bg(plot$params, calendar_theme())
# Blank by default: nothing drawn, and no grob left behind to walk past.
expect_false(any(grepl("\\.background$", calendar_rule_names(plain))))
expect_length(grobs_of_class(plain, "rect"), 1L)
for (granule in calendar_granules) {
name <- calendar_element_name(granule, "background")
bg <- plot$coord$render_bg(plot$params, granule_fill_theme(granule))
drawn <- calendar_grobs(bg)[[name]]
expect_s3_class(drawn, "rect")
expect_equal(drawn$gp$fill, "grey90", label = granule)
# One vectorised rect for the granule as a whole, however many instances
# it has -- a fill costs a grob, not a grob per instance.
expect_length(grobs_of_class(bg, "rect"), 2L)
order <- draw_order(bg)
at <- which(order == name)
expect_length(at, 1L)
# Over the panel's own background ...
expect_gt(at, max(grep("^panel\\.background", order)))
# ... and under every rule the calendar draws, its own included. The
# labels are drawn later still, in `render_fg()`, over the data.
expect_lt(at, min(grep("^ggtime\\.calendar\\..*\\.line$", order)))
blanked <- plot$coord$render_bg(
plot$params,
granule_fill_theme(granule, fill = NULL)
)
expect_false(name %in% calendar_rule_names(blanked))
}
})
test_that("a granule background covers the instances it fills", {
# `row`, `block` and `col` are unlabelled here, so their instance tables are
# not built when the panel is set up; a fill is the other thing that needs
# them, and it is only known about once the theme is in hand.
plot <- every_granule_calendar()
expect_null(plot$params$granule_instances$col)
layout <- plot$coord$grid_layout(plot$params)
# A column's fill spans the whole of the first row it heads.
col <- granule_background(
plot$coord$render_bg(plot$params, granule_fill_theme("col")),
"col"
)
expect_equal(nrow(col), length(layout$col$x))
expect_equal(col$x, layout$col$x)
expect_equal(col$width, layout$col$width)
expect_equal(col$y, rep(layout$row$y[1], nrow(col)))
expect_equal(col$height, rep(layout$row$height[1], nrow(col)))
# A pane's fill covers every row the pane holds, one rect per row, rather
# than only the row it begins at -- see "a group's background covers every
# row it spans" below.
pane <- granule_background(
plot$coord$render_bg(plot$params, granule_fill_theme("pane")),
"pane"
)
expect_gt(nrow(pane), nrow(plot$params$granule_instances$pane))
expect_setequal(pane$width, layout$col$width)
expect_setequal(pane$height, layout$row$height)
# A cell's fill meets its neighbours exactly at the cell rules, so the two
# cannot drift apart.
filled <- plot$coord$render_bg(plot$params, granule_fill_theme("cell"))
cell <- granule_background(filled, "cell")
rules <- calendar_grobs(filled)[["ggtime.calendar.cell.line"]]
expect_true(all(
round(unique(as.numeric(rules$x)), 9) %in%
round(unique(c(cell$x, cell$x + cell$width)), 9)
))
# Every fill is inside the panel, and none is empty.
expect_true(all(cell$x >= -1e-9 & cell$x + cell$width <= 1 + 1e-9))
expect_true(all(cell$width > 0 & cell$height > 0))
})
test_that("a group's background covers every row it spans", {
# `block` and `pane` hold whole rows, and a fill of one covers all of them
# rather than just the row the group begins at (the row that carries its
# rule and its label). Every row of this calendar belongs to a pane and to a
# block, so either fill covers exactly the tiles the calendar has pieces
# for -- 78 of them, against the 15 groups the two granules cut.
plot <- every_granule_calendar()
params <- plot$params
layout <- plot$coord$grid_layout(params)
rows <- plot$coord$piece_rows(params)
cols <- params$pieces$col
tile <- function(x, y) paste(round(x, 9), round(y, 9))
for (granule in c("pane", "block")) {
bg <- granule_background(
plot$coord$render_bg(params, granule_fill_theme(granule)),
granule
)
groups <- calendar_instances(params, granule)
expect_gt(nrow(bg), nrow(groups))
expect_equal(nrow(bg), length(cols), label = granule)
expect_setequal(
tile(bg$x, bg$y),
tile(layout$col$x[cols], layout$row$y[rows])
)
# One rect the size of its own tile per row covered, rather than one box
# drawn from the group's first row to its last.
expect_setequal(bg$width, layout$col$width)
expect_setequal(bg$height, layout$row$height)
# January is a pane (and a block) of five rows: all five are filled.
first <- groups$piece[1]
expect_equal(
sum(tile(bg$x, bg$y) %in% tile(layout$col$x[1], layout$row$y[1:5])),
5L
)
expect_equal(rows[first], 1L)
}
# The instance table itself still holds one instance per group, placed at
# the group's first row -- so one label per group, where the extent above
# would otherwise have multiplied them.
panes <- params$granule_instances$pane
expect_equal(nrow(panes), sum(params$pieces$pane_start))
expect_length(
calendar_labels(plot$coord$render_fg(params, calendar_theme()), "pane"),
nrow(panes)
)
})
test_that("a group's fill covers its rows while its rule keeps to the first", {
# Blocks without panes, so that the block boundary is ruled rather than
# gapped (see "a block rule replaces the row rule at the boundary it falls
# on"), and the rule can be compared against the fill it now sits inside.
built <- ggplot_build(
ggplot(calendar_data(), aes(x = time, y = value)) +
geom_line() +
coord_calendar(
rows = mixtime::weeks(1L),
blocks = mixtime::months(1L),
panes = NULL,
cols = NULL,
label_blocks = function(x) rep("", length(x))
)
)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
layout <- coord$grid_layout(params)
rows <- coord$piece_rows(params)
first <- which(params$pieces$block_start)
bg <- coord$render_bg(params, granule_fill_theme("block"))
fill <- granule_background(bg, "block")
# Two blocks (April and May) over six rows, and every row is filled.
expect_equal(length(first), 2L)
expect_equal(nrow(fill), length(rows))
expect_setequal(fill$y, layout$row$y)
# The rule still marks only where the second block starts: one rule, at the
# boundary above that block's first row, rather than one per row filled.
rule <- calendar_grobs(coord$render_bg(params, calendar_theme()))[[
"ggtime.calendar.block.line"
]]
expect_equal(
unique(as.numeric(rule$y)),
layout$row$y[rows[first[2]] - 1L]
)
# And one label per block, not one per row.
expect_length(
calendar_labels(coord$render_fg(params, calendar_theme()), "block"),
length(first)
)
})
test_that("a group's background stops at a column boundary", {
# `calendar_pieces()` starts a new group at every column boundary, so a
# group is always within a single column however coarse its granule is: a
# quarterly block over monthly columns is cut into one group per column, and
# no rect of its fill spans two of them.
df <- data.frame(time = as.Date("2025-01-01") + 0:363)
df$value <- seq_len(nrow(df))
built <- ggplot_build(
ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(
rows = mixtime::weeks(1L),
blocks = mixtime::quarters(1L),
panes = NULL,
cols = mixtime::months(1L)
)
)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
layout <- coord$grid_layout(params)
expect_equal(sum(params$pieces$block_start), length(layout$col$x))
fill <- granule_background(
coord$render_bg(params, granule_fill_theme("block")),
"block"
)
expect_equal(nrow(fill), length(params$pieces$col))
expect_setequal(fill$x, layout$col$x)
expect_setequal(fill$width, layout$col$width)
})
test_that("a group's background leaves the gaps within it clear", {
# A quarterly block holds three monthly panes, so the rows it covers are
# separated by the gaps between those panes -- and a column with fewer rows
# than the pane it shares with another leaves the rest of that pane empty.
# One rect per row covered keeps both clear; one box from the group's first
# row to its last would paint over them.
df <- data.frame(time = as.Date("2025-01-01") + 0:729)
df$value <- seq_len(nrow(df))
built <- ggplot_build(
ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(
rows = mixtime::weeks(1L),
blocks = mixtime::quarters(1L),
panes = mixtime::months(1L),
cols = mixtime::years(1L)
)
)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
layout <- coord$grid_layout(params)
rows <- coord$piece_rows(params)
cols <- params$pieces$col
tile <- function(x, y) paste(round(x, 9), round(y, 9))
# The grid gaps rows within a block, and the second column is a row short of
# the first, so the two cases are both in play here.
gaps <- layout$row$y[-length(layout$row$y)] -
(layout$row$y[-1] + layout$row$height[-1])
expect_true(any(gaps > 1e-9))
expect_lt(length(cols), 2L * length(layout$row$y))
fill <- granule_background(
coord$render_bg(params, granule_fill_theme("block")),
"block"
)
expect_equal(nrow(fill), length(cols))
expect_setequal(
tile(fill$x, fill$y),
tile(layout$col$x[cols], layout$row$y[rows])
)
# No rect is taller than one row, so nothing is painted over a gap or over
# the row the shorter column never filled.
expect_setequal(fill$height, layout$row$height)
})
test_that("granule backgrounds reflect with the grid when flipped", {
# A flipped calendar reflects the grid of tiles but not the inside of one
# (see `calendar_transposition()`), so a fill covering part of a row's
# window has to follow the tile's interior rather than being reversed with
# the grid. With a single column the two are told apart cleanly: a tile's
# own `x` becomes `y` unchanged, while the grid's rows are reflected.
df <- calendar_data()
fill <- function(time_aes, granule) {
mapping <- if (time_aes == "x") {
aes(x = time, y = value)
} else {
aes(x = value, y = time)
}
built <- ggplot_build(
ggplot(df, mapping) +
geom_line() +
coord_calendar(
rows = mixtime::weeks(1L),
cols = NULL,
time = time_aes
)
)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
granule_background(
coord$render_bg(params, granule_fill_theme(granule)),
granule
)
}
for (granule in c("cell", "row")) {
flat <- fill("x", granule)
flipped <- fill("y", granule)
expect_equal(nrow(flipped), nrow(flat), label = granule)
# Rows are reflected onto `x`, so the first row is at the left edge ...
expect_equal(flipped$x, 1 - (flat$y + flat$height), label = granule)
expect_equal(flipped$width, flat$height, label = granule)
# ... while time still runs up the panel within a row, so a position
# inside the row's window carries over unchanged.
expect_equal(flipped$y, flat$x, label = granule)
expect_equal(flipped$height, flat$width, label = granule)
}
})
test_that("cell breaks fall strictly within a row", {
built <- ggplot_build(
ggplot(calendar_data(), aes(x = time, y = value)) +
geom_line() +
coord_calendar(rows = mixtime::weeks(1L), cols = NULL)
)
breaks <- built$layout$panel_params[[1]]$cell_breaks
# Six interior boundaries between the seven days of a week; the window's own
# edges belong to the row, not to a cell.
expect_equal(breaks, seq_len(6L) / 7)
})
test_that("coord_calendar rejects non-duration granules", {
# `cols`/`blocks` are captured unevaluated and only resolved once the
# axis's calendar is known (see `eval_granule()`), so the error now
# surfaces at build time rather than at construction.
df <- calendar_data()
p <- ggplot(df, aes(x = time, y = value)) + geom_line()
expect_error(ggplot_build(p + coord_calendar(cols = 1:3)), "duration")
expect_error(ggplot_build(p + coord_calendar(blocks = "1 month")), "duration")
})
#' A `cal_sym454` time vector, for exercising calendar-relative granule
#' resolution against something other than the Gregorian default.
#' @noRd
sym454_data <- function() {
x <- 0:41
data.frame(
time = mixtime::linear_time(
as.Date("2025-04-07") + x,
chronon = mixtime::cal_sym454$day(1L),
calendar = mixtime::cal_sym454
),
value = sin(x / 7 * 2 * pi) + x / 40
)
}
#' The granules a built calendar resolved for one of its panels
#'
#' The resolved granules are per-panel state rather than input, so they travel
#' with the panel's cuts (see `calendar_compute_cuts()`) rather than being
#' parked on the coord. This resolves them exactly as
#' `CoordCalendar$compute_cuts()` does -- from the coord's own granule
#' arguments and the calendar of the axis the build actually trained.
#' @noRd
built_granules <- function(built, panel = 1L) {
coord <- built$plot$coordinates
scales <- if (identical(coord$time, "x")) {
built$layout$panel_scales_x
} else {
built$layout$panel_scales_y
}
calendar_resolve_granules(
coord$granule_specs(),
time_scale_calendar(scales[[panel]])
)
}
test_that("a bare granule token resolves against the axis's own calendar", {
# A plain `Date`/`POSIXct` axis (no `scale_x_mixtime()`) resolves bare
# tokens against the Gregorian calendar, exactly as the durations they
# replace as defaults did.
built <- ggplot_build(
ggplot(calendar_data(), aes(x = time, y = value)) +
geom_line() +
coord_calendar()
)
granules <- built_granules(built)
expect_equal(granules$cells, mixtime::cal_gregorian$day(1L))
expect_equal(granules$rows, mixtime::cal_gregorian$day(7L))
expect_equal(granules$panes, mixtime::cal_gregorian$month(1L))
expect_equal(granules$cols, mixtime::cal_gregorian$quarter(1L))
# A `scale_x_mixtime()` axis using a different calendar resolves the same
# tokens against *that* calendar instead -- `panes`/`cols` here are the
# `symmetry454` month and quarter, not the Gregorian ones. Suppresses an
# unrelated warning from folding this short a span of data onto
# `symmetry454` months, from mixtime's own cycle arithmetic rather than
# anything under test here.
built_sym454 <- suppressWarnings(ggplot_build(
ggplot(sym454_data(), aes(x = time, y = value)) +
geom_line() +
scale_x_mixtime() +
coord_calendar(cols = NULL)
))
sym454 <- built_granules(built_sym454)
expect_s3_class(sym454$panes, "mixtime::tu_sym454_month")
expect_false(identical(sym454$panes, granules$panes))
})
test_that("a namespace-qualified granule or duration bypasses the calendar mask", {
# `week` is not a Gregorian granule (see the next test), but an explicit
# `cal_isoweek$week(1L)` -- or a plain duration -- is unaffected by the
# calendar the axis itself uses, and means exactly what it says.
built <- ggplot_build(
ggplot(calendar_data(), aes(x = time, y = value)) +
geom_line() +
coord_calendar(
rows = mixtime::days(1L),
panes = mixtime::cal_isoweek$week(1L),
cols = NULL
)
)
expect_equal(built_granules(built)$panes, mixtime::cal_isoweek$week(1L))
})
test_that("a defaulted rows/cols falls back when the calendar lacks the granule", {
# `cal_sym454` has no `quarter` granule, unlike Gregorian -- left at its
# default, `cols` (`quarter(1L)`) quietly falls back to the duration it
# names (`month(3L)`) instead of erroring, the same way a defaulted `panes`
# gives way rather than erroring when it doesn't fit (see
# `CoordCalendar$granule_specs()`).
built <- suppressWarnings(ggplot_build(
ggplot(sym454_data(), aes(x = time, y = value)) +
geom_line() +
scale_x_mixtime() +
coord_calendar()
))
granules <- built_granules(built)
expect_equal(granules$cols, mixtime::cal_sym454$month(3L))
# `week` *is* one of `cal_sym454`'s own granules, so the default `rows`
# resolves directly to it rather than falling back to `day(7L)`.
expect_equal(granules$rows, mixtime::cal_sym454$week(1L))
})
test_that("a granule token missing from the calendar errors with a hint", {
# The fallback above only applies to a defaulted `rows`/`cols` -- a granule
# the user asked for by name still errors when their axis's calendar
# cannot resolve it, exactly as any other unresolvable granule does.
# `cal_gregorian` has no `week` granule, and `cal_sym454` (see
# `sym454_data()`) has no `quarter` granule.
expect_error(
ggplot_build(
ggplot(calendar_data(), aes(x = time, y = value)) +
geom_line() +
coord_calendar(rows = week(1L))
),
"no.*week.*granule"
)
expect_error(
suppressWarnings(ggplot_build(
ggplot(sym454_data(), aes(x = time, y = value)) +
geom_line() +
scale_x_mixtime() +
coord_calendar(cols = quarter(1L))
)),
"no.*quarter.*granule"
)
})
test_that("coord_calendar rejects spacing that is not a fraction", {
expect_error(coord_calendar(col_spacing = -1), "non-negative")
expect_error(coord_calendar(pane_spacing = c(1, 2)), "single")
expect_error(coord_calendar(col_spacing = "wide"), "non-negative")
# `rel()` reads naturally for a fraction of a tile, so it is accepted.
expect_equal(coord_calendar(col_spacing = rel(0.25))$col_spacing, 0.25)
})
test_that("panes must sit between rows and cols in coarseness", {
df <- calendar_data()
p <- ggplot(df, aes(x = time, y = value)) + geom_line()
expect_error(
ggplot_build(
p + coord_calendar(rows = mixtime::weeks(1L), panes = mixtime::days(1L))
),
"coarser than"
)
expect_error(
ggplot_build(
p +
coord_calendar(
rows = mixtime::days(1L),
panes = mixtime::years(1L),
cols = mixtime::months(1L)
)
),
"must not be coarser"
)
# Equal to `cols` is allowed, since a pane may span a whole column.
expect_no_error(ggplot_build(
p +
coord_calendar(
rows = mixtime::weeks(1L),
panes = mixtime::months(1L),
cols = mixtime::months(1L)
)
))
})
test_that("a defaulted panes value gives way where it does not fit", {
df <- calendar_data()
p <- ggplot(df, aes(x = time, y = value)) + geom_line()
pane_cuts_of <- function(plot) {
ggplot_build(plot)$layout$panel_params[[1]]$pane_cuts
}
# The monthly default is dropped rather than erroring, both where `rows` is
# no finer than a month and where `cols` is finer than one.
expect_null(pane_cuts_of(p + coord_calendar(rows = mixtime::months(1L))))
expect_null(pane_cuts_of(
p + coord_calendar(rows = mixtime::days(1L), cols = mixtime::weeks(1L))
))
# Where it does fit, the default panes the calendar by month.
expect_false(is.null(pane_cuts_of(
p + coord_calendar(rows = mixtime::weeks(1L))
)))
})
test_that("an explicit panes matching the default behaves like the default", {
df <- calendar_data()
p <- ggplot(df, aes(x = time, y = value)) + geom_line()
pane_cuts_of <- function(plot) {
ggplot_build(plot)$layout$panel_params[[1]]$pane_cuts
}
# Passing the default value explicitly used to hit the error path
# (`pane_default <- missing(panes)` cannot tell this from a genuine
# request), where leaving `panes` unset quietly drops it. Both must now
# behave identically: no error, and no panes drawn.
expect_no_error(ggplot_build(
p +
coord_calendar(panes = mixtime::months(1L), rows = mixtime::months(1L))
))
expect_null(pane_cuts_of(
p + coord_calendar(panes = mixtime::months(1L), rows = mixtime::months(1L))
))
expect_identical(
pane_cuts_of(
p +
coord_calendar(panes = mixtime::months(1L), rows = mixtime::months(1L))
),
pane_cuts_of(p + coord_calendar(rows = mixtime::months(1L)))
)
# A wrapper (or `do.call()`) forwarding the default value loses `missing()`
# information; it must give way the same as leaving `panes` unset, not
# error.
wrapped <- do.call(
coord_calendar,
list(panes = mixtime::months(1L), rows = mixtime::months(1L))
)
expect_no_error(ggplot_build(p + wrapped))
expect_null(
ggplot_build(p + wrapped)$layout$panel_params[[1]]$pane_cuts
)
# A genuinely explicit, incompatible `panes` must still error -- the
# resolved-value comparison must not accidentally treat every `panes` as
# defaulted.
expect_error(
ggplot_build(
p + coord_calendar(rows = mixtime::weeks(1L), panes = mixtime::days(1L))
),
"coarser than"
)
})
test_that("a granule that fails to cut warns and is dropped, rather than vanishing silently", {
# `mixtime::loc_altitude()` is not a time unit at all, so cutting any time
# range at it always fails -- standing in for a granule the underlying
# cutting machinery cannot make sense of against a particular axis (mixtime
# gives that failure no condition class of its own to catch selectively,
# see `calendar_axis_cuts()`).
range <- mixtime::yearmonth(c(600L, 611L))
expect_warning(
result <- calendar_axis_cuts(
range,
mixtime::loc_altitude(1L),
name = "cells"
),
"`cells`.*dropped"
)
expect_null(result)
# `fallback` is returned either way -- `time_range` itself for `cols`,
# `NULL` for `panes`/`blocks`/`cells`.
expect_warning(
result <- calendar_axis_cuts(
range,
mixtime::loc_altitude(1L),
fallback = range,
name = "cols"
),
"`cols`"
)
expect_identical(result, range)
# A `NULL` granule is a deliberate "not set", not a cutting failure, so it
# is dropped silently.
expect_no_warning(calendar_axis_cuts(range, NULL))
})
test_that("flipped calendars gap and rule the same boundaries", {
df <- data.frame(time = as.Date("2025-01-06") + 0:167, value = seq_len(168L))
built <- ggplot_build(
ggplot(df, aes(y = time, x = value)) +
geom_path() +
coord_calendar(
time = "y",
rows = mixtime::weeks(1L),
panes = mixtime::months(1L),
cols = mixtime::quarters(1L)
)
)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
bg <- coord$render_bg(params, calendar_theme())
layout <- coord$grid_layout(params)
# Flipped, rows run horizontally: their rules are vertical, so it is `x`
# that sits on the row boundaries and `y` that spans a column.
# Both dimensions are reflected as well as swapped, so that the grid reads
# the same way round as an unflipped one (see `calendar_transposition()`).
row_rule <- calendar_grobs(bg)[["ggtime.calendar.row.line"]]
expect_true(all(as.numeric(row_rule$x) %in% (1 - layout$row$y)))
expect_setequal(
unique(as.numeric(row_rule$y)),
1 - c(layout$col$x, layout$col$x + layout$col$width)
)
# The axis of a flipped calendar repeats down the columns, gaps included.
v <- coord$render_axis_v(params, calendar_theme())[[1]]
expect_equal(length(v$heights), 2L * coord$.grid$n_col - 1L)
})
test_that("granule instances land where the data they name does", {
# The point of the instance tables: a label is placed by the same fold and
# rescale the data goes through, so a cell's label sits exactly where an
# observation at that time is drawn.
df <- data.frame(time = as.Date("2025-01-01") + 0:180, value = 1)
built <- ggplot_build(
ggplot(df, aes(x = time, y = value)) +
geom_point() +
coord_calendar(rows = mixtime::weeks(1L), cols = mixtime::months(1L))
)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
layout <- coord$grid_layout(params)
cells <- params$granule_instances$cell
drawn <- coord$transform(
data.frame(x = as.numeric(cells$time), y = 1),
params
)
expect_equal(
drawn$x,
layout$col$x[cells$col] + layout$col$width[cells$col] * cells$start
)
})
test_that("granule instances describe each column's own times", {
df <- data.frame(time = as.Date("2025-01-01") + 0:180, value = 1)
built <- ggplot_build(
ggplot(df, aes(x = time, y = value)) +
geom_point() +
coord_calendar(
rows = mixtime::weeks(1L),
blocks = mixtime::months(1L),
cols = mixtime::months(1L),
# `row`/`block`/`col` are unlabelled by default, which skips building
# their instance tables entirely; label them (the text is irrelevant
# here) so this test can inspect the tables below.
label_rows = function(x) rep("", length(x)),
label_blocks = function(x) rep("", length(x)),
label_cols = function(x) rep("", length(x))
)
)
params <- built$layout$panel_params[[1]]
rows <- params$granule_instances$row
# Rows tile their column end to end. The first picks up where the column
# itself starts -- a Saturday, part way through the week February opens in --
# and the rest are whole weeks of the shared grid.
feb <- vctrs::vec_slice(rows, rows$col == 2L)
expect_equal(feb$time[1], as.Date("2025-02-01"))
expect_equal(unique(diff(as.numeric(feb$time[-1]))), 7)
expect_equal(feb$start, c(5 / 7, 0, 0, 0, 0))
expect_equal(feb$end, c(1, 1, 1, 1, 5 / 7))
# A column is named by its own start rather than by the shared row grid it
# inherits, which begins at the start of the week the column falls in.
cols <- params$granule_instances$col
expect_equal(
cols$time,
seq(as.Date("2025-01-01"), as.Date("2025-06-01"), by = "1 month")
)
# One block per column here, named from within the month it groups even
# though its first row starts a day or two either side of the boundary.
blocks <- params$granule_instances$block
expect_equal(nrow(blocks), nrow(cols))
expect_equal(
format(blocks$time, "%m"),
format(cols$time, "%m")
)
})
test_that("a granule grouping rows is labelled against the row it starts", {
# A group's label spans the whole of the row it opens rather than the part of
# it the group's own first day takes up, so that it is placed the same way
# whichever weekday the group happens to begin on. Placed at the pane's own
# start instead, a month opening on a Sunday would have its label pushed to
# the far right of the row by the six days belonging to the month before it.
df <- data.frame(time = as.Date("2025-01-01") + 0:180, value = 1)
built <- ggplot_build(
ggplot(df, aes(x = time, y = value)) +
geom_point() +
coord_calendar(
rows = mixtime::weeks(1L),
blocks = mixtime::months(1L),
panes = mixtime::months(1L),
cols = mixtime::quarters(1L),
# `block` is unlabelled by default, which skips its instance table;
# label it (the text is irrelevant here) to inspect it below.
label_blocks = function(x) rep("", length(x))
)
)
params <- built$layout$panel_params[[1]]
panes <- params$granule_instances$pane
# Six panes, opening on weekdays as varied as a Wednesday (1 January) and a
# Saturday (1 March), all spanning the whole of their row.
expect_equal(nrow(panes), 6L)
expect_equal(panes$start, rep(0, 6L))
expect_equal(panes$end, rep(1, 6L))
expect_equal(
params$granule_instances$block[c("start", "end")],
panes[c("start", "end")]
)
# Which lands every label the same distance into the column it opens -- the
# element's own margin, `hjust = 0` justifying it against the column's left
# edge -- rather than however far into the row the month happens to begin.
coord <- built$plot$coordinates
label <- calendar_grobs(
coord$render_fg(params, ggplot2:::plot_theme(built$plot))
)[["ggtime.calendar.pane.text"]]$children[[1]]
# The label's `x` mixes npc with the margin's absolute units, so it takes a
# device to resolve; a null one is enough to convert against.
pdf(NULL)
on.exit(dev.off(), add = TRUE)
at <- grid::convertX(label$x, "npc", valueOnly = TRUE)
layout <- coord$grid_layout(params)
expect_equal(
at - layout$col$x[panes$col],
rep(at[1], nrow(panes))
)
})
test_that("labels are formatted for both mixtime and Date axes", {
labels_of <- function(p) {
built <- ggplot_build(p)
calendar_labels(
built$plot$coordinates$render_fg(
built$layout$panel_params[[1]],
ggplot2:::plot_theme(p)
),
"cell"
)
}
date <- data.frame(time = as.Date("2025-04-07") + 0:13, value = 1)
p <- ggplot(date, aes(x = time, y = value)) +
geom_point() +
coord_calendar(rows = mixtime::weeks(1L), cols = NULL)
# A `Date` axis is wrapped before formatting, rather than losing its labels.
expect_equal(labels_of(p), sprintf("%02d", 7:20))
mixed <- data.frame(time = mixtime::date(date$time), value = 1)
p <- ggplot(mixed, aes(x = time, y = value)) +
geom_point() +
coord_calendar(rows = mixtime::weeks(1L), cols = NULL)
expect_equal(labels_of(p), sprintf("%02d", 7:20))
# Blocks are labelled by the month they group, not the day they start on.
p <- ggplot(date, aes(x = time, y = value)) +
geom_point() +
coord_calendar(
rows = mixtime::weeks(1L),
blocks = mixtime::weeks(1L),
cols = NULL,
label_cells = NULL,
label_blocks = "{cyc(month, year, label = TRUE, abbreviate = TRUE)}"
)
built <- ggplot_build(p)
fg <- built$plot$coordinates$render_fg(
built$layout$panel_params[[1]],
ggplot2:::plot_theme(p)
)
expect_null(calendar_labels(fg, "cell"))
expect_equal(calendar_labels(fg, "block"), c("Apr", "Apr"))
})
test_that("granule tables are only built for granules that are labelled", {
# `cell` and `pane` are labelled by default, `row`/`block`/`col` are not --
# so only the labelled two should have an instance table to show for it; an
# unlabelled granule has nothing to spend that work on (`3.3`).
df <- data.frame(time = as.Date("2025-01-01") + 0:365, value = 1)
built <- ggplot_build(
ggplot(df, aes(x = time, y = value)) + geom_line() + coord_calendar()
)
params <- built$layout$panel_params[[1]]
expect_false(is.null(params$granule_instances$cell))
expect_false(is.null(params$granule_instances$pane))
expect_null(params$granule_instances$row)
expect_null(params$granule_instances$block)
expect_null(params$granule_instances$col)
# Skipping the unlabelled tables must not have skipped drawing the ones
# that are still labelled.
coord <- built$plot$coordinates
fg <- coord$render_fg(params, ggplot2:::plot_theme(built$plot))
expect_false(is.null(calendar_labels(fg, "cell")))
expect_false(is.null(calendar_labels(fg, "pane")))
})
test_that("disabling cell labels does not disable cell gridlines", {
# The cell granule table (labels) and `cell_breaks` (gridlines) both come
# from `self$cells`, but are built separately -- turning off `label_cells`
# must skip only the (expensive) label table, not the gridlines it shares
# a granule with (the bug this guards against is `1.2`).
df <- data.frame(time = as.Date("2025-01-01") + 0:365, value = 1)
built <- ggplot_build(
ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(label_cells = NULL)
)
params <- built$layout$panel_params[[1]]
coord <- built$plot$coordinates
expect_null(params$granule_instances$cell)
expect_false(is.null(params$cell_breaks))
bg <- coord$render_bg(params, calendar_theme())
expect_true("ggtime.calendar.cell.line" %in% calendar_rule_names(bg))
})
test_that("labels can be given as a function of the times", {
df <- data.frame(time = as.Date("2025-04-07") + 0:6, value = 1)
p <- ggplot(df, aes(x = time, y = value)) +
geom_point() +
coord_calendar(
rows = mixtime::weeks(1L),
cols = NULL,
label_cells = function(x) format(x, "%a")
)
built <- ggplot_build(p)
expect_equal(
calendar_labels(
built$plot$coordinates$render_fg(
built$layout$panel_params[[1]],
ggplot2:::plot_theme(p)
),
"cell"
),
format(df$time, "%a")
)
})
test_that("labels are blanked with their granule's text element", {
df <- calendar_data()
p <- ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(rows = mixtime::weeks(1L), cols = NULL)
built <- ggplot_build(p)
params <- built$layout$panel_params[[1]]
coord <- built$plot$coordinates
theme <- ggplot2:::plot_theme(
p +
theme(
ggtime.calendar.cell.text = element_blank(),
ggtime.calendar.pane.text = element_blank()
)
)
expect_equal(calendar_rule_names(coord$render_fg(params, theme)), character())
# The labels follow the theme's own text colour, so ink/paper carries over.
dark <- ggplot2:::plot_theme(p + theme_grey(ink = "white", paper = "black"))
expect_equal(
calendar_element(dark, "cell", "text")@colour,
calc_element("text", dark)@colour
)
})
test_that("coord_calendar rejects labels that are neither format nor function", {
expect_error(coord_calendar(label_cells = 1:3), "format string")
expect_error(coord_calendar(label_blocks = c("a", "b")), "format string")
})
test_that("a label that cannot format the axis errors informatively", {
df <- data.frame(time = as.Date("2025-04-07") + 0:13, value = 1)
p <- ggplot(df, aes(x = time, y = value)) +
geom_point() +
coord_calendar(
rows = mixtime::weeks(1L),
cols = NULL,
label_cells = "{cyc(not_a_granule, month)}"
)
built <- ggplot_build(p)
expect_error(
built$plot$coordinates$render_fg(
built$layout$panel_params[[1]],
ggplot2:::plot_theme(p)
),
"label_cells"
)
})
test_that("repeated axes leave a track for a pane gap", {
# Item 7's guarantee: the axes are built from the same layout as the grid,
# so a gap between rows shifts the axis labels with it.
df <- data.frame(time = as.Date("2025-01-06") + 0:167, value = seq_len(168L))
built <- ggplot_build(
ggplot(df, aes(x = time, y = value)) +
geom_line() +
coord_calendar(
rows = mixtime::weeks(1L),
panes = mixtime::months(1L),
cols = mixtime::quarters(1L)
)
)
coord <- built$plot$coordinates
params <- built$layout$panel_params[[1]]
layout <- coord$grid_layout(params)
v <- coord$render_axis_v(params, calendar_theme())[[1]]
# One track per row, plus one for each gap between panes.
gaps <- sum(diff(coord$.grid$row_pane) != 0L)
expect_equal(length(v$heights), coord$.grid$n_row + gaps)
expect_equal(sum(as.numeric(v$heights)), 1)
# The label sets themselves are as tall as the rows they belong to, and the
# rest of the axis is the filler holding the gaps open.
expect_equal(
sum(abs(as.numeric(v$heights) - layout$row$height[1]) < 1e-9),
coord$.grid$n_row
)
})
test_that("coord_calendar is unsupported for non-cartesian coords", {
# Checked at construction (like `coord_loop()`'s own coord support check),
# not deferred until `ggplot_build()`.
expect_error(
coord_calendar(rows = mixtime::weeks(1L), coord = coord_radial()),
"does not support"
)
})
#' The granule specs of a plain weekly/monthly calendar
#'
#' `CoordCalendar$granule_specs()` in miniature, written out rather than taken
#' from a coord so that the free functions below can be exercised without one.
#' @noRd
test_granule_specs <- function(
cells = quo(mixtime::days(1L)),
rows = quo(week(1L)),
blocks = quo(NULL),
panes = quo(NULL),
cols = quo(mixtime::months(1L))
) {
list(
cells = list(quo = cells),
rows = list(
quo = rows,
is_default = TRUE,
unit = "week",
fallback = quo(day(7L))
),
blocks = list(quo = blocks),
panes = list(quo = panes, default = quo(month(1L))),
cols = list(quo = cols)
)
}
#' A grid environment, as `CoordCalendar$setup_params()` allocates one
#' @noRd
test_grid <- function() {
new_environment(list(
pane_rows = NULL,
n_row = 1L,
n_col = 1L,
row_pane = NULL,
layout = NULL
))
}
test_that("granules resolve against the calendar they are handed", {
# `calendar_resolve_granules()` is a function of its arguments, so the same
# specs resolved against two calendars give two independent answers --
# neither of which is remembered anywhere between the calls.
specs <- test_granule_specs(panes = quo(month(1L)))
greg <- calendar_resolve_granules(specs, mixtime::cal_gregorian)
sym454 <- calendar_resolve_granules(specs, mixtime::cal_sym454)
# `cal_gregorian` has no `week` of its own, so the defaulted `rows` falls
# back to a seven-day duration; `cal_sym454` defines both `week` and `month`
# itself.
expect_equal(greg$rows, mixtime::cal_gregorian$day(7L))
expect_equal(sym454$rows, mixtime::cal_sym454$week(1L))
expect_equal(greg$panes, mixtime::cal_gregorian$month(1L))
expect_s3_class(sym454$panes, "mixtime::tu_sym454_month")
expect_false(identical(greg$panes, sym454$panes))
expect_false(identical(greg$rows, sym454$rows))
# Resolving again gives the first answer back, unaffected by the second.
expect_equal(calendar_resolve_granules(specs, mixtime::cal_gregorian), greg)
# `panes` is compared to its own default once resolved, and the comparison
# is made against the same calendar the granule was resolved with.
expect_true(greg$pane_default)
expect_true(sym454$pane_default)
expect_false(
calendar_resolve_granules(
test_granule_specs(panes = quo(mixtime::days(7L))),
mixtime::cal_gregorian
)$pane_default
)
# A granule that resolves to nothing is recorded as `NULL` rather than
# dropped from the list, so a reader cannot mistake "no blocks" for "not
# resolved yet".
expect_true("blocks" %in% names(greg))
expect_null(greg$blocks)
})
test_that("the axis is cut into a calendar without building a plot", {
# `calendar_compute_cuts()` is the whole of `CoordCalendar$compute_cuts()`
# bar the coord's own input, and needs nothing but a time range and the
# granules to cut it by.
granules <- calendar_resolve_granules(
test_granule_specs(panes = quo(month(1L))),
mixtime::cal_gregorian
)
cuts <- calendar_compute_cuts(
as.Date(c("2025-01-01", "2025-03-01")),
granules
)
# Monthly columns, cut out past both ends of the range as every granule is
# (see `loop_cuts_by_duration()`), so January and February each get one and
# March opens a third.
expect_equal(
cuts$col_cuts,
as.Date(c("2025-01-01", "2025-02-01", "2025-03-01", "2025-04-01"))
)
# One row grid across the whole axis, stepping a week at a time, so a
# weekday is the same position in every column.
expect_true(all(diff(as.numeric(cuts$row_grid)) == 7))
expect_lte(cuts$row_grid[1], cuts$col_cuts[1])
# The window the time axis is drawn against is one row long.
expect_equal(cuts$row_window, cuts$row_grid[1] + c(0, 7))
# The span reaches everything any granule of the calendar covers.
expect_gte(cuts$span[2], cuts$col_cuts[length(cuts$col_cuts)])
# `blocks` was `NULL`, `panes` a month.
expect_null(cuts$block_cuts)
expect_equal(cuts$pane_cuts[1], as.Date("2025-01-01"))
# The granules travel with the cuts, which is what keeps them off the coord.
expect_identical(cuts$granules, granules)
})
test_that("a panel's cuts are attached without building a plot", {
# `calendar_panel_cuts()` is the whole of `CoordCalendar$panel_cuts()` bar
# the coord's own input: given a panel's cuts it fills in the numeric cuts,
# the pieces, the cell gridlines and the labelled granules' instance tables,
# and grows the grid it is handed.
trans <- scales::transform_date()
granules <- calendar_resolve_granules(
test_granule_specs(),
mixtime::cal_gregorian
)
cuts <- calendar_compute_cuts(
as.Date(c("2025-01-01", "2025-03-01")),
granules
)
window <- as.numeric(trans$transform(cuts$row_window))
grid <- test_grid()
params <- calendar_panel_cuts(
list(x = list(rescale = function(v) (v - window[1]) / diff(window))),
cuts,
trans,
time_scale = "x",
label_formats = list(
cell = "{cyc(day, month)}",
row = NULL,
block = NULL,
pane = NULL,
col = NULL
),
grid = grid
)
# The native cuts, reduced to the transformed space cutting happens in.
expect_equal(params$col_cuts, as.numeric(trans$transform(cuts$col_cuts)))
expect_equal(params$row_cuts, as.numeric(trans$transform(cuts$row_grid)))
expect_null(params$block_cuts)
expect_null(params$pane_cuts)
# A piece per row of each column, in column order. A row is cut short
# wherever a month boundary falls inside it, and every piece runs forwards.
expect_setequal(unique(params$pieces$col), 1:3)
expect_true(all(diff(params$pieces$cuts) > 0))
# The grid handed in is grown to fit the panel, by reference.
expect_equal(grid$n_col, 3L)
expect_equal(grid$n_row, sum(grid$pane_rows))
expect_identical(params$grid, grid)
# Six cell boundaries within a seven-day row, as the row's own edges belong
# to the row rather than to a cell.
expect_equal(params$cell_breaks, seq_len(6L) / 7)
# An instance table for the one labelled granule, and none for the rest --
# those are built on demand (see `calendar_instances()`).
expect_false(is.null(params$granule_instances$cell))
expect_null(params$granule_instances$row)
expect_null(params$granule_instances$col)
# One instance per day of the calendar, in order. Cells falling outside the
# calendar are dropped, so it opens at the first column rather than at the
# row grid the cells were cut from.
expect_true(all(diff(as.numeric(params$granule_instances$cell$time)) == 1))
expect_equal(params$granule_instances$cell$time[1], cuts$col_cuts[1])
})
test_that("each panel's cyclical scale labels its own granule", {
# `loop_granule()` is a `CoordLoop` hook, and used to answer from the coord
# itself -- correct only because `compute_cuts()` happened to have run for
# the same panel moments earlier. One coord sets up every panel of a build,
# so answering from the coord means the last panel resolved wins; the
# granules travel with each panel's own cuts instead.
#
# Driven through the hooks directly rather than through a facetted build:
# every panel of a build shares one `time_chronon` (set once on the plot's
# own scale by `ScaleContinuousMixtime$transform_df()`, before the panel
# scales are cloned from it), so even `facet_wrap(scales = "free_x")` over
# data on two calendars gives every panel the same calendar to resolve
# against. The leak is therefore latent rather than reachable from a plot
# today -- and this is what would make it reachable.
coord <- coord_calendar(cols = NULL)
trans <- scales::transform_date()
uncut <- list(
x = list(limits = as.numeric(as.Date(c("2025-01-01", "2025-04-01"))))
)
# Two panels whose axes use different calendars, so their `rows` granules
# differ: `cal_gregorian` has no `week` of its own and falls back to a
# seven-day duration, while `cal_sym454` defines one.
gregorian <- ggplot2::scale_x_date()
sym454 <- scale_x_mixtime()
sym454$time_chronon <- mixtime::cal_sym454$day(1L)
cuts <- lapply(
list(gregorian, sym454),
function(scale) coord$compute_cuts(uncut, trans, scale, NULL)
)
expect_equal(cuts[[1]]$granules$rows, mixtime::cal_gregorian$day(7L))
expect_equal(cuts[[2]]$granules$rows, mixtime::cal_sym454$week(1L))
# Asked after both panels have been cut, each still answers with its own.
expect_equal(coord$loop_granule(cuts[[1]]), cuts[[1]]$granules$rows)
expect_equal(coord$loop_granule(cuts[[2]]), cuts[[2]]$granules$rows)
expect_false(identical(
coord$loop_granule(cuts[[1]]),
coord$loop_granule(cuts[[2]])
))
# And so does the scale each panel is labelled with: a Gregorian axis falls
# back to a seven-day duration, whose positions are numbered, while
# `cal_sym454`'s own week names its days.
days <- as.Date("2025-01-01") + 0:6
labels <- lapply(
cuts,
function(cut) coord$cyclical_scales(gregorian, NULL, cut)$x$labels(days)
)
expect_equal(
labels[[1]],
time_labels_at(days, cycle = cuts[[1]]$granules$rows)
)
expect_equal(
labels[[2]],
time_labels_at(days, cycle = cuts[[2]]$granules$rows)
)
expect_false(identical(labels[[1]], labels[[2]]))
})
test_that("the time axis is broken at every cell of a row", {
axis_labels <- function(p) {
labels <- ggplot_build(p)$layout$panel_params[[1]]$x$get_labels()
labels[!is.na(labels)]
}
# Hourly data, so the axis's own chronon is far finer than the calendar's
# cells: pretty breaks land wherever they like, and would be labelled by the
# second of the week they fall on.
hourly <- data.frame(
time = mixtime::datetime("2015-01-01 00:00:00") + 3600 * (0:200),
value = seq_len(201)
)
p <- ggplot(hourly, aes(x = time, y = value)) + geom_line()
# A break at every cell of the row, named as a position in the row's cycle.
expect_equal(
axis_labels(p + coord_calendar(rows = mixtime::weeks(1L), cols = NULL)),
c("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun", "Mon")
)
# The cells are what is broken at, not the days in particular: a six hour
# cell is one of the four the day holds, numbered within it.
expect_equal(
axis_labels(
p +
coord_calendar(
rows = mixtime::days(1L),
cells = mixtime::hours(6L),
cols = NULL
)
),
c("h00", "h01", "h02", "h03", "h00")
)
# Without cells there is nothing to break at, so the scale's own breaks are
# used (and labelled cyclically, as they were before).
expect_lt(
length(axis_labels(
p + coord_calendar(rows = mixtime::weeks(1L), cells = NULL, cols = NULL)
)),
8L
)
})
test_that("the calendar's breaks give way to the scale's own", {
axis_labels <- function(p) {
labels <- ggplot_build(p)$layout$panel_params[[1]]$x$get_labels()
labels[!is.na(labels)]
}
daily <- data.frame(
time = mixtime::date("2015-01-01") + 0:400,
value = seq_len(401)
)
p <- ggplot(daily, aes(x = time, y = value)) + geom_line()
weekly <- coord_calendar(rows = mixtime::weeks(1L), cols = NULL)
# Breaks the user asked for, whether as values or as a granule of their own.
expect_equal(
axis_labels(
p + weekly + scale_x_mixtime(time_breaks = mixtime::days(2L))
),
c("Fri", "Sun", "Tue", "Thu")
)
expect_length(
axis_labels(
p + weekly + scale_x_mixtime(breaks = mixtime::date("2015-01-01") + 0:1)
),
2L
)
# And labels the user asked for, at the calendar's own breaks.
expect_equal(
setdiff(
axis_labels(
p + weekly + scale_x_mixtime(time_labels = "{cyc(day, month)}")
),
"NA"
),
sprintf("%02d", c(29:31, 1:5))
)
# A row holding more cells than an axis can name keeps the scale's breaks: a
# year of daily cells is 365 of them.
expect_lt(
length(axis_labels(
p + coord_calendar(rows = mixtime::years(1L), cols = NULL)
)),
calendar_max_cell_breaks
)
# An axis with no cycle to name positions in is linear time, whose labels are
# dates rather than positions in a row, so it keeps the scale's breaks too.
expect_match(
axis_labels(p + coord_calendar(rows = NULL, cols = mixtime::months(1L))),
"^[0-9]{4}-[0-9]{2}-[0-9]{2}$"
)
})
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.