
Visualizing time with ggtime’s grammar of temporal graphics
9th August 2025 @ useR!, Durham NC
Mitchell O’Hara-Wild, Monash University
Cynthia Huang, Monash University
Matthew Kay, Northwestern University
Rob Hyndman, Monash University

Several types of data require unique care.
🍀 uncertainty
{distributional} 🔄 {ggdist}
… and {ggdibbler} 🤩
🕸️ graph
{tidygraph} 🔄 {ggraph}
🗺️ space
{sf} 🔄 {ggplot2}
⌛ time
{mixtime} 🔄 {ggtime}
Time is comparatively simple dimension, it is a continuous and ordered variable.
ggplot2 already supports temporal data
Scales in ggplot2 handle temporal labels and breaks:
scale_*_date() for Date,scale_*_datetime() for POSIXct,scale_*_time() for hms.Simply map time to any aesthetic in ggplot2.

📆 Calendrical time
Calendrical patterns are complex! Complexity causes errors!

Most time series data starts like this:
# A tibble: 24,320 × 5
Quarter Region State Purpose Trips
<date> <chr> <chr> <chr> <dbl>
1 1998-01-01 Adelaide South Australia Business 135.
2 1998-04-01 Adelaide South Australia Business 110.
3 1998-07-01 Adelaide South Australia Business 166.
4 1998-10-01 Adelaide South Australia Business 127.
5 1999-01-01 Adelaide South Australia Business 137.
6 1999-04-01 Adelaide South Australia Business 200.
7 1999-07-01 Adelaide South Australia Business 169.
8 1999-10-01 Adelaide South Australia Business 134.
9 2000-01-01 Adelaide South Australia Business 154.
10 2000-04-01 Adelaide South Australia Business 169.
# ℹ 24,310 more rows
A tsibble is tibble for time series.
# A tsibble: 24,320 x 5 [1Q]
# Key: Region, State, Purpose [304]
Quarter Region State Purpose Trips
<qtr> <chr> <chr> <chr> <dbl>
1 1998 Q1 Adelaide South Australia Business 135.
2 1998 Q2 Adelaide South Australia Business 110.
3 1998 Q3 Adelaide South Australia Business 166.
4 1998 Q4 Adelaide South Australia Business 127.
5 1999 Q1 Adelaide South Australia Business 137.
6 1999 Q2 Adelaide South Australia Business 200.
7 1999 Q3 Adelaide South Australia Business 169.
8 1999 Q4 Adelaide South Australia Business 134.
9 2000 Q1 Adelaide South Australia Business 154.
10 2000 Q2 Adelaide South Australia Business 169.
# ℹ 24,310 more rows

Existing time vectors are limited
A better solution?
The {mixtime} package works with:
Any temporal classes
Custom calendars / granularities
Mixed temporal granularities in the same tsibble

Alternative calendars
Non-Gregorian calendars from {calcal} also work.
library(calcal)
mixtime(
as_gregorian(today), as_islamic(today), as_hebrew(today),
as_chinese(today), as_coptic(today), as_mayan(today),
as_persian(today), as_julian(today), as_egyptian(today)
)<mixtime[9]>
[1] 2025-Aug-09 1447-Saf-14 5785-Av-15
[4] 78-42-06*-16 1741-Mes-03 13-00-11-10-14
[7] 1404-Mord-18 2025-Jul-27 2774-Choi-25

Temporal aggregation in tsibble.
# A tsibble: 18 x 5 [1Y, 1Q, 1M]
# Key: Region, State, Purpose [1]
Time Region State Purpose Trips
<mixtime> <chr> <chr> <chr> <dbl>
1 2020 Melbourne Victoria Holiday 7784.
2 2021 Melbourne Victoria Holiday 7543.
3 2020 Q1 Melbourne Victoria Holiday 1880.
4 2020 Q2 Melbourne Victoria Holiday 1965.
5 2020 Q3 Melbourne Victoria Holiday 1929.
6 2020 Q4 Melbourne Victoria Holiday 2010.
7 2020 Jan Melbourne Victoria Holiday 735.
8 2020 Feb Melbourne Victoria Holiday 573.
9 2020 Mar Melbourne Victoria Holiday 571.
10 2020 Apr Melbourne Victoria Holiday 607.
11 2020 May Melbourne Victoria Holiday 747.
12 2020 Jun Melbourne Victoria Holiday 611.
13 2020 Jul Melbourne Victoria Holiday 663.
14 2020 Aug Melbourne Victoria Holiday 632.
15 2020 Sep Melbourne Victoria Holiday 634.
16 2020 Oct Melbourne Victoria Holiday 582.
17 2020 Nov Melbourne Victoria Holiday 622.
18 2020 Dec Melbourne Victoria Holiday 806.


Disclaimer: active development
{mixtime} and {ggtime} require more time to mature before general use.
Two types of ggtime functions:
🖼️ Plot helpers
Functions which are used to quickly create a specific plot.
autoplot() / autolayer()ggtime::gg_season()ggtime::gg_subseries()🎨 Grammar extensions
Functions which add new features to the ggplot2’s grammar.
ggtime::geom_time_line()ggtime::scale_x_mixtime()ggtime::facet_calendar()
Both types serve different needs:
🖼️ Plot helpers
Plot helpers focus on what is being plotted.
🎨 Grammar extensions
Grammar extensions are about how something is plotted.

{ggtime} has two main types of plot helpers:
Linear time plots
autoplot()Circular time plots
gg_season()gg_subseries()Changing how time is plotted
Linear time plots show time continuously on the x/y axis.
Circular time plots loop time over specific calendar units.
These are simply time plots.
They just happen to show forecasts, or uncertain values.
🎨 Grammar elements
Remove the trend to clearly see seasonal changes.
🎨 Grammar elements
{ggtime} decomposes these plot helpers into grammatical elements for {ggplot2}.
Composable grammars
geom_time_line()
A time-aware version of geom_line(). Shows timezone offsets with dashed lines from the [x/y]_time_offset aesthetic.
tz_shift <- as_tibble(tsibbledata::gafa_stock) |>
filter(
(Symbol == "AAPL" & Date <= "2014-01-15") |
(Symbol == "GOOG" & Date <= "2014-01-13")
) |>
mutate(Date = Sys.Date() + hours(c(1:3, 3:9, 1:2, 4:9)), DST = ifelse(Symbol == "AAPL", "DST Ends", "DST Begins")) |>
slice(1:3, 3:12, 12:n()) |>
mutate(
open = duplicated(Open),
closed = c(open[-1], FALSE),
Date = Date + open*3600*((DST=="DST Begins")*2-1)
)
tz_shift |>
ggplot(aes(x = Date, y = Close)) +
geom_path(aes(group = cumsum(open))) +
geom_path(linetype = "dashed", data = filter(tz_shift, open | closed)) +
facet_wrap(vars(DST), ncol = 2, scales = "free_y") +
scale_shape_manual(values = c("TRUE" = 16, "FALSE" = 1)) +
guides(shape = "none")
geom_time_candle()
Shows value changes over time periods (e.g. daily, weekly, …) which are calculated using the stat_candle statistic.
tsibbledata::gafa_stock |>
filter(Symbol == "GOOG") |>
filter(yearmonth(Date) == yearmonth("2014 Jun")) |>
ggplot(aes(x = Date)) +
tidyquant::geom_candlestick(
aes(open = Open, high = High, low = Low, close = Close),
colour_up = "#1FB974", fill_up = "#1FB974", colour_down = "#F4375D", fill_down = "#F4375D"
) +
scale_x_date(date_labels = "%d %b %Y") +
labs(y = "GOOG Stock")

The x/y position of time is timezone adjusted:
position_time_absolute() positions time at its exact global location.
pedestrian |>
filter(Sensor == "Southern Cross Station") |>
filter(year(Date_Time) == 2015) |>
mutate(Date_Time = force_tz(make_datetime(year(Date), month(Date), day(Date), Time), "Australia/Melbourne")) |>
ggplot(aes(x = Date_Time - as.POSIXct(Date), y = Count, group = Date)) +
geom_line(alpha = 0.2) +
theme(axis.text.x = element_blank()) +
labs(x = "Time", title = "Hourly pedestrians passing Southern Cross Station")
Timezone differences (e.g. daylight savings) misaligns seasonal patterns.
position_time_civil() positions time as it appears locally in each timezone.

The scales in ggplot2 provide:
scale_*_date() for Date,scale_*_datetime() for POSIXct,scale_*_time() for hms.Extension packages (e.g. tsibble) add:
scale_*_yearquarter() for yearquarter,scale_*_yearmonth() for yearmonth,scale_*_yearweek() for yearweek.Unified scales for time series
mixtime has many calendars and granularities.
ggtime unifies them all with scale_*_mixtime().
The mixtime scales support temporal labels and breaks, much like ggplot2:
time_labels (strftime, e.g. "%Y %b")time_breaks (duration, e.g. "3 months")These scales also have alignment options:
time_align (numeric, 0-1)time_warp (duration, e.g. "1 month") warp (times, e.g. 2025-01-28){ggtime} aligns mixed granularities.
Imagine Australian births (annual) compared with total births by state (monthly).

📅 Temporal alignment across granularities
When constrained to Date and POSIXct, left alignment is commonly used for less-frequent granularities.
e.g. 2025-01-01 can be 2025, Jan 2025, or Jan 1 2025.
Consequently, plotting is also often left-aligned.

{ggtime} center aligns granularities.

📅 Aligning temporal granularities
Specify alignment of different granularities with scale_x_mixtime(align_time = <0-1>).

{ggtime} defaults to center alignment.
Cycles are repeating patterns with an irregular duration (and shape).

Warping cycles to have the same length as “% of cycle” can help compare cycle shapes.

📅 Temporal alignment across cycles
In scale_x_mixtime(), warp time with
warp (stretch time between specific time points)time_warp (stretch time by duration, e.g. "1 month")
Two calendar-plot approaches:
facet_calendar()coord_calendar()📅 Calendar layouts for temporal graphics
Calendars are useful for plotting long time series, they:
Calendars are not limited to the standard weekly layout, but are hierarchical in nature over any calendar structure.
Facets separate each day (or calendar period).


Each day (or calendar period) shares the same panel.


Season plots loop time over seasonalities.
coord_time_loop()The time loop points can be specified with:
loops (loop over specific time points)time_loops (loop by duration - "1 week")


Looping continuous time
Looping the x-axis over seasonal granularities (e.g. day, week, or year) clearly shows seasonality.
coord_time_loop(time_loops = "1 year")


Looping continuous time
Looping the x-axis over seasonal granularities (e.g. day, week, or year) clearly shows seasonality.
coord_time_loop(time_loops = "1 year")


Non-cartesian coordinates
Looping conceptually applies to other coordinate spaces too. Combining coord_loop() and coord_polar() shows seasonality in polar coordinates.


Thanks to these Unsplash contributors for their photos