ggtime: Visualizing time with a grammar of temporal graphics

5th August 2025 @ JSM, Nashville TN

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}

  • 🕸️ graph

    {tidygraph} / {ggraph}

  • 🗺️ space

    {sf} / {ggplot2}

  • time

    {mixtime} / {ggtime}

Exploring temporal data

Time is comparatively simple dimension, it is a continuous and ordered variable.

Why do we need specialised tools?

📆 Calendrical time

While time itself is simple, calendrical patterns are complex!

Temporal data/patterns align with calendars.

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

Time series visualisation

ggplot2 already supports temporal data

Simply map a date (Date), datetime (POSIXct), or even time (hms) to any aesthetic. Scales to adjust temporal labels and breaks exist in ggplot2:

  • scale_*_date() for Date,
  • scale_*_datetime() for POSIXct,
  • scale_*_time() for hms.

So why do we need ggtime?

{ggtime} visualises time with calendrical patterns.

  • Visualisation of more calendars and granularities
  • Alignment options for timezones, granularities & cycles
  • Cyclical and calendar layouts for seasonality & holidays

Extending ggplot2 with ggtime

ggplot2 extensions can be categorised into:

🖼️ 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 with ggtime

These extension types are both useful:

🖼️ Plot helpers

Plot helpers focus on what is being plotted.

  • Convenient to use during data analysis
  • Limited customisation options
  • Beginner friendly for developers and users

🎨 Grammar extensions

Grammar extensions are about how something is plotted.

  • Plots require a composition of many elements
  • Very flexible usage and styling
  • More difficult to learn, develop, and use

Common temporal graphics

These ‘visual idioms’ of time series plots can be categorised into two core groups:

  • Linear time plots
  • Circular time plots

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 plot styles highlight different patterns in the data.

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
  • coord: cartesian
Code
library(fpp3)
pbs_scripts <- tsibbledata::PBS |>
  summarise(Scripts = sum(Scripts))
pbs_scripts |>
  autoplot(Scripts)

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)
  • coord: cartesian
Code
pbs_scripts |> 
  model(ETS(Scripts)) |> 
  forecast() |> 
  autoplot(pbs_scripts) +
  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
  • coord: cartesian
Code
pbs_scripts |>
  gg_season(Scripts)

Seasonal sub-series

Season facets to show changes over time.

🖼️ Plot helper

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

🎨 Grammar elements

  • mapping:
    • x: time
    • y: observations
  • coord: cartesian
  • facet: season
Code
pbs_scripts |>
  gg_subseries(Scripts)

Grammar of temporal graphics

{ggtime} re-expresses these common plots with a composable ggplot2-like grammar.

🎯 Goals

  • Decompose temporal graphics into modular elements.
  • Provide grammar extensions, and plot helpers using them.
  • Encourage users to experiment beyond standard plots.
  • Interoperability with other ggplot2 extensions (e.g. ggdist)
  • Overcome current limitations of time series graphics.

Elements of ggplot2’s grammar

The ggplot2 grammar of graphics identifies compositional elements involved in plotting.

Each of these elements are extensible.

Element Description
Data The original data to plot
Aesthetics Map graphical dimensions
Geometries Graphical objects to plot
Scales Transform data to visuals
Positions Adjust placement of values
Statistics Data summary statistics
Facets Split plots into subplots
Coordinates Plotting reference system
Guides Explain aesthetic mappings
Themes Non-data visual style

Element Description
Data The original data to plot
Aesthetics Map graphical dimensions
Geometries Graphical objects to plot
Scales Transform data to visuals
Positions Adjust placement of values
Statistics * Data summary statistics
Facets Split plots into subplots
Coordinates * Plotting reference system
Guides * Explain aesthetic mappings
Themes Non-data visual style

Data

Data for ggplot2 needs to be rectangular.

Most time series datasets look 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

Mixed temporal granularities

Existing time vectors are limited

  • Gregorian calendar only
  • Limited granularity options (date, datetime)
  • Cannot mix temporal granularities

A better solution?

The {mixtime} package works with:

  • Many calendars via {calcal}

    Gregorian, Chinese, Islamic, Hebrew, …

  • Custom calendars / granularities

    Business hours, trading days, trimesters, …

  • Mixed temporal granularities in the same tsibble

    Useful for related series at different frequencies!

Mixed temporal granularities

mixtime vectors works nicely in rectangular data structures for ggplot2.

# A tsibble: 18 x 5 [1Y, 1Q, 1M]
        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.

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 handle the alignment of:

  • granularities (time_align 0-1)
  • cycles (warp and time_warp)

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

Become a ggplot2 extender!

  • Start with making plot helpers using ggplot2

  • Create a new branded theme for your organisation

  • Extend the grammar with a new Geom and Stat

  • See how others have written ggplot extensions

    (there’s plenty of examples out there!)

Unsplash credits

Thanks to these Unsplash contributors for their photos