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

Exploring semantic variables

Several types of data require unique care.

  • 🍀 uncertainty

    {distributional} 🔄 {ggdist}

    … and {ggdibbler} 🤩

  • 🕸️ graph

    {tidygraph} 🔄 {ggraph}

  • 🗺️ space

    {sf} 🔄 {ggplot2}

  • time

    {mixtime} 🔄 {ggtime}

Exploring temporal data

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.

library(ggplot2)
aus_production_tbl |>
  ggplot(aes(x = Quarter, y = Beer)) +
  geom_line()

So why create ggtime?

📆 Calendrical time

Calendrical patterns are complex! Complexity causes errors!

  • Calender systems (Gregorian, Islamic, Chinese, Jewish, …)
  • Timezones (local/civil time, absolute time)
  • Granularities (hourly, daily, weekly, monthly, annual, …)
  • Corrections (leap years, or even leap seconds)
  • Seasonality (weekdays/weekends, financial years, holidays)

Time series data

Most time series data starts like this:

library(tibble)
readr::read_csv("data/tourism.csv")
# 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

Tidy time series data 🧹

A tsibble is tibble for time series.

library(tsibble)
tourism
# 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

Mixed temporal vectors

Existing time vectors are limited

  • Gregorian calendar only
  • Limited granularity options
  • Cannot mix temporal granularities

A better solution?

The {mixtime} package works with:

  • Any temporal classes

  • Custom calendars / granularities

  • Mixed temporal granularities in the same tsibble

library(mixtime)
now <- Sys.time()
today <- Sys.Date()
c(
  year(now), yearquarter(now), yearmonth(now), 
  yearweek(now), today, now
)
<mixtime[6]>
[1] 2025                2025 Q3            
[3] 2025 Aug            2025 W32           
[5] 2025-08-09          2025-08-09 10:21:44

Mixed temporal vectors

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  

Mixed temporal vectors

Temporal aggregation in tsibble.

library(mixtime)
tsibble(
  Time = c(year(2020:2021), yearquarter("2020 Q1") + 0:3, yearmonth("2020 Jan") + 0:11),
  Region = "Melbourne", State = "Victoria", Purpose = "Holiday",
  Trips = ..., # Omitted for brevity
  index = Time, key = c(Region, State, Purpose)
)
# 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.

Extending ggplot2

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()

Extending ggplot2

Both types serve different needs:

🖼️ Plot helpers

Plot helpers focus on what is being plotted.

  • Single function convenient for data analysis
  • Limited customisation options
  • Beginner friendly for users

🎨 Grammar extensions

Grammar extensions are about how something is plotted.

  • Composition of elements for data visualisation
  • Very flexible usage and styling
  • More complex to learn and use

Plot helpers for time series

{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.

Time plots

Maps time to the x/y-axis.

They reveal trends, seasons, cycles, outliers, and more.

🖼️ Plot helper

<tsibble> |>
  autoplot()

🎨 Grammar elements

  • mapping:
    • x: time
    • y: observations
library(ggtime)
tsibbledata::aus_production |>
  autoplot(Beer)

Forecast plots

These are simply time plots.

They just happen to show forecasts, or uncertain values.

🖼️ Plot helper

<fable> |> 
  autoplot(<tsibble>)

🎨 Grammar elements

  • mapping:
    • x: time
    • y: observations
    • ydist: forecast (ggtime ❤️ ggdist)
library(fable)
tsibbledata::aus_production |> 
  model(ETS(Beer)) |> 
  forecast() |> 
  autoplot(tsibbledata::aus_production) +
  theme(legend.position = "bottom")

Seasonal plots

Loops seasons to align years.

🖼️ Plot helper

<tsibble> |>
  gg_season(period = "year")

🎨 Grammar elements

  • mapping:
    • x: season (e.g. hms)
    • y: observations
    • colour: time
tsibbledata::aus_production |>
  gg_season(Beer, period = "year")

Seasonal sub-series

Seasonal facets shows changes in seasons.

🖼️ Plot helper

<tsibble> |>
  gg_subseries(period = "year")

🎨 Grammar elements

  • mapping:
    • x: time
    • y: observations
  • facet: season
tsibbledata::aus_production |>
  gg_subseries(Beer, period = "year")

Seasonal sub-series

Remove the trend to clearly see seasonal changes.

🖼️ Plot helper

<dcmp> |>
  gg_subseries(period = "year")

🎨 Grammar elements

  • mapping:
    • x: time
    • y: observations
  • facet: season
tsibbledata::aus_production |>
  model(STL(Beer)) |>
  components() |>
  gg_subseries(season_year, period = "year")

Grammar extensions

{ggtime} decomposes these plot helpers into grammatical elements for {ggplot2}.

Composable grammars

  • Combine elements to create new plots.
  • Customise the style and structure.
  • Works well with other ggplot extensions.

Geometries

  • geom_time_line()

    A time-aware version of geom_line(). Shows timezone offsets with dashed lines from the [x/y]_time_offset aesthetic.

    Code
    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.

Code
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")

Positions

The x/y position of time is timezone adjusted:

  • position_time_absolute() positions time at its exact global location.

    Code
    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.

    Code
    pedestrian |> 
      filter(Sensor == "Southern Cross Station") |> 
      filter(year(Date_Time) == 2015) |> 
      ggplot(aes(x = Time, 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")

Scales

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().

Scales

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)

Granularity alignment

{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.

Granularity alignment

{ggtime} center aligns granularities.

📅 Aligning temporal granularities

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

Time warping

{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")

Facets & Coordinates

Two calendar-plot approaches:

  • facet_calendar()
  • coord_calendar()

📅 Calendar layouts for temporal graphics

Calendars are useful for plotting long time series, they:

  • use a familiar layout for quickly identifying dates.
  • reveal short annual patterns (e.g. holidays and events).
  • have a better aspect-ratio for dense time axis.
  • more effectively use vertical space.

Calendars are not limited to the standard weekly layout, but are hierarchical in nature over any calendar structure.

Calendar facets

Facets separate each day (or calendar period).

Calendar coordinates

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

Facets & Coordinates

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 circular time periods

Looping circular time periods

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 circular time periods

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 circular time periods

Non-cartesian coordinates

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

Thanks for your time!

Closing remarks

  • Stay tuned! Follow the progress on GitHub.

  • Combine semantic variables together with tibble.

    tibble(
      time = <mixtime>,
      geometry = <sf>,
      prediction = <distributional>
    )

Unsplash credits

Thanks to these Unsplash contributors for their photos