# Introduction to ggpointless

This vignette guides you through examples of functions from the ggpointless package — an extension of the `ggplot2` package.

    library(ggpointless)
    #> Loading required package: ggplot2

    # set consistent theme for all plots
    cols <- c("#311dfc", "#a84dbd", "#d77e7b", "#f4ae1b")
    theme_set(
      theme_minimal() +
        theme(legend.position = "bottom") +
        theme(geom = element_geom(fill = cols[1])) +
        theme(palette.fill.discrete = c(cols[1], cols[3])) +
        theme(palette.colour.discrete = cols)
      )

## geom_area_fade

`geom_area_fade()` behaves like `geom_area()` but fills each group with a linear gradient that fades from the fill colour to transparent at the baseline at `y = 0`.

    ggplot(economics, aes(date, unemploy)) +
      geom_area_fade()

Use `alpha` to set the starting opacity and `alpha_fade_to` to control at which alpha value the gradient ends.

    ggplot(economics, aes(date, unemploy)) +
      geom_area_fade(alpha = 0.75, alpha_fade_to = 0.1)

The direction can be reversed — transparent at the top, opaque at the baseline — by swapping their values. The outline colour is unaffected from the alpha logic.

    ggplot(economics, aes(date, unemploy)) +
      geom_area_fade(alpha = 0, alpha_fade_to = 1)

### 2D gradient

When `fill` is mapped to a variable inside `aes()`, ggplot2 builds a horizontal colour gradient across each group. `geom_area_fade()` overlays its vertical alpha schedule on top, giving a true two-dimensional gradient: colour varies left-to-right while opacity fades from the data line down to the baseline.

    set.seed(42)
    ggplot(economics, aes(date, unemploy)) +
      geom_area_fade(aes(fill = uempmed), colour = cols[1]) +
      scale_fill_continuous(palette = scales::colour_ramp(cols))

### Device compatibility and the fallback

The 2D gradient depends on Porter-Duff compositing 1, a feature of R’s graphics engine added in R 4.2. When the active graphics device does not support compositing (e.g. `grDevices::pdf()`), `geom_area_fade()` falls back to a single-colour vertical fade: the horizontal colour gradient is lost, only the vertical alpha fade survives, and a one-time message is emitted:

> ! `geom_area_fade()`: the graphics device does not support gradient fills. The `fill` colour gradient is replaced by a single colour. Switch to a device that supports gradients (e.g. `ragg::agg_png()`, `svg()`, `cairo_pdf()`) for the full effect. This message is displayed once per session.

Most modern raster devices — including `ragg::agg_png()` and the Cairo-backed `png()` shipped on Linux and macOS — do support compositing, so the 2D gradient works out of the box. In RStudio, go to Tools > Global Options > Graphics > Backend and select AGG to ensure full support.

### Multiple groups

When your data contains multiple groups those are stacked (`position = "stack"`) and aligned (`stat = "align"`) – just like `geom_area()` does it. By default, the alpha fade scales to the global maximum across all groups (`alpha_scope = "global"`), so equal `|y|` always maps to equal opacity.

    df1 <- data.frame(
      g = c("a", "a", "a", "b", "b", "b"),
      x = c(1, 3, 5, 2, 4, 6),
      y = c(2, 5, 1, 3, 6, 7)
    )

    ggplot(df1, aes(x, y, fill = g)) +
      geom_area_fade()

When groups have very different amplitudes or you may not use the default `position = "stack"` but `stat = "identity"` instead, this can make smaller groups nearly invisible next to dominant groups.

    df_alpha_scope <- data.frame(
      g = c("a", "a", "a", "b", "b", "b"),
      x = c(1, 3, 5, 2, 4, 6),
      y = c(1, 2, 1, 9, 10, 8)
    )
    p <- ggplot(df_alpha_scope, aes(x, y, fill = g))
    p + geom_area_fade(
      alpha_scope = "global", # default
      position = "identity"
    )

Setting `alpha_scope = "group"` lets the algorithm calculate the alpha range for each group separately.

    p <- ggplot(df_alpha_scope, aes(x, y, fill = g))

    # alpha_scope = "group": each group uses the alpha range independently
    p + geom_area_fade(
      alpha_scope = "group",
      position = "identity"
      )

## geom_arch & stat_arch

### Catenary curves and arches

A catenary is the curve formed by a flexible chain or cable hanging freely between two fixed supports. It follows this equation:

\[y = a \cosh\!\left(\frac{x}{a}\right)\]

`geom_arch()` draws these inverted catenary curves between successive points. The shape is controlled by `arch_length` or `arch_height` (vertical rise above the highest endpoint of each segment). By default the arc length is twice the Euclidean distance calculated for each segment.

    ggplot(data.frame(x = 1:2, y = c(0, 0)), aes(x, y)) +
      geom_arch(arch_height = 0.6)

### Controlling arch height with stat_arch

`stat_arch()` exposes the underlying computation. Here it shows the effect of different `arch_height` values between the same two endpoints:

    ggplot(data.frame(x = c(0, 2), y = c(0, 0)), aes(x, y)) +
      stat_arch(arch_height = 0.5, aes(colour = "arch_height = 0.5")) +
      stat_arch(arch_height = 1.5, aes(colour = "arch_height = 1.5")) +
      stat_arch(arch_height = 3.0, aes(colour = "arch_height = 3"))

### The Rice House

A real-world application is the Rice House in Eltham, whose distinctive roofline consists of shallow catenary arched spans rising above vertical columns. The sketch below reconstructs its simplified cross-section:

    rice_house <- data.frame(
      x = c(0, 1.5, 2.5, 3.5, 5),
      y = c(0, 1, 1, 1, 0)
    )

    ggplot(rice_house, aes(x, y)) +
      geom_arch(arch_height = 0.15, linewidth = 2, colour = "#333333") +
      geom_segment(aes(xend = x, yend = 0), colour = "#333333") +
      geom_hline(yintercept = 0, colour = "#4a7c59", linewidth = 3) +
      coord_equal() +
      theme_void() +
      labs(caption = "Rice House, Eltham (simplified catenary cross-section)")

## geom_catenary & stat_catenary

`geom_catenary()` draws the non-inverted, hanging-chain form between successive points.

    set.seed(1)
    ggplot(data.frame(x = 1:6, y = sample(6)), aes(x, y)) +
      geom_catenary() +
      geom_point(size = 3, colour = "#333333")

### chain_length

By default the chain length is twice the Euclidean distance per segment. Pass `chain_length` to set an explicit arc length — longer values make the chain sag more. If the value is shorter than the straight-line distance a warning is issued and a straight line is drawn instead.

    ggplot(data.frame(x = c(0, 1), y = c(1, 1)), aes(x, y)) +
      lapply(c(1.5, 1.75, 2), \(cl) {
        geom_catenary(chain_length = cl)
      }) +
      ylim(0, 1.05) +
      labs(title = "Increasing chain_length adds more sag")

### sag

`sag` sets the vertical drop below the lowest endpoint of each segment, giving you direct control over how much the chain droops:

    df_sag <- data.frame(x = c(0, 2, 4, 6), y = c(1, 1, 1, 1))

    ggplot(df_sag, aes(x, y)) +
      geom_catenary(sag = c(0.1, 0.5, 1.2)) +
      geom_point(size = 3, colour = "#333333") +
      labs(title = "sag = 0.1, 0.5, 1.2 (left to right)")

### combine chain_length and sag

`sag` sets the vertical drop below the lowest endpoint of each segment, giving you direct control over how much the chain droops:

    df_sag <- data.frame(x = c(0, 2, 4, 6), y = c(1, 1, 1, 1))

    ggplot(df_sag, aes(x, y)) +
      geom_catenary(
        chain_length = c(2, NA, 3),
        sag = c(0.1, 0.5, NA)
      ) +
      geom_point(size = 3, colour = "#333333") +
      labs(title = "sag wins over chain_length") +
      ylim(c(-.25, 1))
    #> Both `sag` and `chain_length` supplied for 1 segment; using `sag`.
    #> This message is displayed once every 8 hours.

## geom_chaikin & stat_chaikin

Chaikin’s corner-cutting algorithm smooths a polygonal path by iteratively replacing each corner with two new points placed at a given ratio along the adjacent edges. With `ratio = 0.25` (the default) and `iterations = 5` the path converges to a smooth B-spline approximation.

    set.seed(42)
    dat <- data.frame(x = seq.int(10), y = sample(15:30, 10))

    ggplot(dat, aes(x, y)) +
      geom_line(linetype = "dashed", colour = "#333333") +
      geom_chaikin(colour = cols[1])

### Effect of ratio

`ratio` controls how aggressively corners are cut. At `ratio = 0.5` the new points coincide with the edge midpoints, producing the maximum rounding possible in a single pass.

    triangle <- data.frame(x = c(0, 0.5, 1), y = c(0, 1, 0))

    ggplot(triangle, aes(x, y)) +
      geom_polygon(fill = NA, colour = "grey70", linetype = "dashed") +
      geom_chaikin(ratio = 0.10, mode = "closed", aes(colour = "ratio = 0.10")) +
      geom_chaikin(ratio = 0.25, mode = "closed", aes(colour = "ratio = 0.25")) +
      geom_chaikin(ratio = 0.50, mode = "closed", aes(colour = "ratio = 0.50")) +
      coord_equal()

### Effect of iterations

Each iteration halves the sharpness of every corner. The plot below shows iterations = 3, applied to a five-pointed star. See the package website for an animated version.

The source script that generates the animation is `inst/scripts/gen_chaikin_gif.R`.

### Smoothing a closed polygon with stat_chaikin

`stat_chaikin()` can be combined with any geom. Pass `geom = "polygon"` to smooth the boundary of a filled shape:

    # An irregular hexagon
    hex <- data.frame(
      x = c(0.0, 0.4, 1.0, 1.2, 0.9, 0.1),
      y = c(0.2, 1.0, 0.9, 0.3, -0.2, 0.0)
    )

    ggplot(hex, aes(x, y)) +
      geom_polygon(fill = "grey95", colour = "#333333", linetype = "dashed") +
      stat_chaikin(geom = "polygon", mode = "closed", fill = cols[1], colour = NA) +
      coord_equal() +
      labs(title = "Original polygon (dashed) with smoothed fill (purple)")

## geom_fourier & stat_fourier

`geom_fourier()` fits a truncated discrete Fourier transform to the supplied x/y observations and renders the reconstructed curve. Fewer harmonics produce a smoother, low-frequency summary; retaining all harmonics reproduces the original signal exactly (up to interpolation artefacts).

    set.seed(42)
    n <- 150
    df_f <- data.frame(
      x = seq(0, 2 * pi, length.out = n),
      y = sin(seq(0, 2 * pi, length.out = n)) +
        0.4 * sin(3 * seq(0, 2 * pi, length.out = n)) +
        rnorm(n, sd = 0.25)
    )

    ggplot(df_f, aes(x, y)) +
      geom_point(alpha = 0.25) +
      geom_fourier(aes(colour = "n_harmonics = 1"), n_harmonics = 1) +
      geom_fourier(aes(colour = "n_harmonics = 3"), n_harmonics = 3)

### Detrending

A linear drift in the data will dominate the low-frequency coefficients, preventing the Fourier series from capturing the periodic structure. Set `detrend = "lm"` (or `"loess"`) to subtract the trend before the transform; it is added back afterwards so the output remains on the original scale.

    set.seed(3)
    x_d <- seq(0, 4 * pi, length.out = 100)
    df_d <- data.frame(
      x = x_d,
      y = sin(x_d) + x_d * 0.4 + rnorm(100, sd = 0.2)
    )

    ggplot(df_d, aes(x, y)) +
      geom_point(alpha = 0.35) +
      geom_fourier(aes(colour = "as is"),
        n_harmonics = 3
      ) +
      geom_fourier(
        aes(colour = "detrend = 'lm'"),
        n_harmonics = 3,
        detrend = "lm") +
      labs(
        title = "geom_fourier() w/wo detrending",
        x = NULL, y = NULL
      )

### Irregular spacing

The Fourier transform (via `stats::fft()`) assumes that observations are evenly spaced in time. If this assumption is violated, the Fourier curve will not pass through every data point:

    df_gap <- data.frame(
      x = c(1:10, 19:20),
      y = sin(seq_len(12))
      )
    ggplot(df_gap, aes(x, y)) +
      geom_fourier()
    #> Warning: Highly irregular x-spacing detected (coefficient of variation = 1.4). The
    #> uniform-grid interpolation may introduce artefacts.

The last example is certainly somewhat exaggerated, but irregular data is relevant when you work directly with calendar data, for example with monthly time series, since months are known to have between 28 and 31 days and therefore consecutive observations do not have the same interval between them.

## geom_lexis & stat_lexis

`geom_lexis()` draws a 45° lifeline for each observation from its start to its end. The required aesthetics are `x` and `xend`; `y` and `yend` are calculated by `stat_lexis()` and represent the cumulative duration.

    df_l <- data.frame(
      key  = c("A", "B", "B", "C", "D"),
      x    = c(0, 1, 6, 5, 6),
      xend = c(5, 4, 10, 8, 10)
    )

    p <- ggplot(df_l, aes(x = x, xend = xend, colour = key)) +
      coord_equal()

    p + geom_lexis()

When there is a gap between two events of the same cohort, a horizontal dotted segment bridges the gap. Set `gap_filler = FALSE` to hide it.

    p + geom_lexis(gap_filler = FALSE)

### Using after_stat(type) and custom point styling

The computed variable `type` takes the value `"solid"` for 45° lifelines and `"dotted"` for horizontal gap-fillers. Map it to `linetype` to make the distinction explicit, and use `point_colour` to style the endpoint dot independently of the lifeline colour.

    p +
      stat_lexis(
        aes(linetype = after_stat(type)),
        point_colour = "#333333",
        shape        = 21,
        fill         = "white",
        size         = 2.5,
        stroke       = 0.8
      ) +
      scale_linetype_identity()

### Date and POSIXct classes

`geom_lexis()` works with Date and POSIXct objects as well as numerics. The y-axis shows duration in the native unit of the scale (days for Date, seconds for POSIXct).

    df_dates <- data.frame(
      key   = c("A", "B"),
      start = c(2019, 2021),
      end   = c(2022, 2022)
    )
    df_dates[, c("start", "end")] <- lapply(
      df_dates[, c("start", "end")],
      \(i) as.Date(paste0(i, "-01-01"))
    )

    ggplot(df_dates, aes(x = start, xend = end, group = key)) +
      geom_lexis() +
      scale_y_continuous(
        breaks = 0:3 * 365.25,
        labels = \(i) paste0(floor(i / 365.25), " yr")
      ) +
      coord_fixed() +
      labs(y = "Duration")

## geom_point_glow

`geom_point_glow()` is a drop-in replacement for `geom_point()` that adds a radial gradient glow behind each point using `grid::radialGradient()`. By default, alpha, colour and size inherit their values from `geom_point()`.

    # Basic usage
    ggplot(mtcars, aes(wt, mpg, colour = factor(cyl))) +
       geom_point_glow()

You can control the alpha, colour, and size of the gradient with these arguments:

- `glow_alpha`
- `glow_colour`
- `glow_size`

    # Customizing glow parameters (fixed for all points)
    ggplot(mtcars, aes(wt, mpg, colour = factor(cyl))) +
      geom_point_glow(
        glow_alpha = 0.25,
        glow_colour = "#0833F5",
        glow_size = 5
        )

### Big Dipper

The constellation Big Dipper, drawn with star positions in right ascension and declination.

    # source: https://de.wikipedia.org/wiki/Gro%C3%9Fer_B%C3%A4r#Sterne
    # colours, coordinates all approximations of course
    big_dipper <- data.frame(
      star = c(
        "Megrez",
        "Dubhe",
        "Merak",
        "Phecda",
        "Megrez",
        "Alioth",
        "Mizar",
        "Alkaid"
      ),
      ra_h = c(12.257, 11.062, 11.031, 11.897, 12.257, 12.900, 13.399, 13.792),
      dec_d = c(57.03, 61.75, 56.38, 53.70, 57.03, 55.96, 54.93, 49.31),
      mag = c(3.32, 1.81, 2.34, 2.41, 3.32, 1.77, 2.23, 1.86),
      colour = c("#CFDDFF", "#FFDBBF", "#C7D9FF", "#C8D9FF", "#CFDDFF", "#C7D9FF", "#CBDBFF", "#BAD0FF")
    )

    big_dipper$x <- -big_dipper$ra_h
    big_dipper$y <- big_dipper$dec_d

    # Linear size mapping: brighter (lower mag) → larger point
    mag_to_size <- \(m) pmax(0.7, (5.5 - m) * 1.0)

    ggplot(big_dipper, aes(x = x, y = y)) +
      geom_path(colour = "#F5F5F5", linewidth = 0.6, alpha = .6) +
      geom_point_glow(
        data = big_dipper[-1L, ], # don't plot Megrez a second time
        aes(x = -ra_h, y = dec_d, size = mag_to_size(mag), colour = colour,
            alpha = mag),
        shape = 8,
        glow_alpha = 0.75
      ) +
      scale_alpha_continuous(range = c(1, 0.4), guide = "none") +
      scale_size_identity() +
      scale_colour_identity() +
      geom_text(
        aes(x = -ra_h, y = dec_d, label = star),
        colour = "#bbccdd",
        vjust = -1.5,
        size = 2.5,
        check_overlap = TRUE
      ) +
      scale_x_continuous(
        breaks = seq(-14, -8, by = 1),
        labels = \(x) paste0(abs(x), "h")
      ) +
      scale_y_continuous(labels = \(x) paste0(x, "°")) +
      labs(title = "Big Dipper", x = NULL, y = NULL) +
      coord_cartesian(clip = 'off') +
      theme(
        panel.background = element_rect(fill = grid::linearGradient(colours = c("#1D2180", "#081849")), colour = NA),
        plot.background = element_rect(fill = grid::linearGradient(colours = c("#1D2180", "#081849")), colour = NA),
        panel.grid = element_blank(),
        text = element_text(colour = "#344B73"),
        plot.title = element_text(size = 18, face = "bold")
      )

## geom_pointless & stat_pointless

`geom_pointless()` highlights selected observations — first, last, minimum, maximum, or all of them — by default with points. Its name reflects that it is not particularly useful on its own, but in conjunction with `geom_line()` and friends, it adds useful context at a glance.

    x <- seq(-pi, pi, length.out = 100)
    df1 <- data.frame(var1 = x, var2 = rowSums(outer(x, 1:5, \(x, y) sin(x * y))))

    p <- ggplot(df1, aes(x = var1, y = var2)) +
      geom_line()

    p + geom_pointless(location = "all", size = 3)

Map the computed variable `location` to `colour` to distinguish the four roles:

    p +
      geom_pointless(
        aes(colour = after_stat(location)),
        location = "all",
        size = 3
      ) +
      theme(legend.position = "bottom")

### Order and orientation

Locations are determined in data order. This matters when e.g. the path crosses itself:

    x <- seq(5, -1, length.out = 1000) * pi
    spiral <- data.frame(var1 = sin(x) * 1:1000, var2 = cos(x) * 1:1000)

    p_spi <- ggplot(spiral) +
      geom_path() +
      coord_equal(xlim = c(-1000, 1000), ylim = c(-1000, 1000))

    p_spi +
      aes(x = var1, y = var2) +
      geom_pointless(aes(colour = after_stat(location)), location = "all", size = 3) +
      labs(subtitle = "orientation = 'x'")

    p_spi +
      aes(y = var1, x = var2) +
      geom_pointless(aes(colour = after_stat(location)), location = "all", size = 3) +
      labs(subtitle = "orientation = 'y'")

When `location = "all"`, points are drawn bottom to top in the order: `"maximum"` < `"minimum"` < `"last"` < `"first"`. Passing an explicit vector lets you override this:

    df2 <- data.frame(x = 1:2, y = 1:2)
    p2 <- ggplot(df2, aes(x, y)) +
      geom_path() +
      coord_equal()

    p2 + geom_pointless(aes(colour = after_stat(location)),
      location = c("first", "last", "minimum", "maximum"), size = 4
    ) +
      labs(subtitle = "first on top")

    p2 + geom_pointless(aes(colour = after_stat(location)),
      location = c("maximum", "minimum", "last", "first"), size = 4
    ) +
      labs(subtitle = "maximum on top")

`geom_pointless()` respects `ggplot2`’s group structure and works naturally with facets:

    ggplot(
      subset(economics_long, variable %in% c("psavert", "unemploy")),
      aes(x = date, y = value)
    ) +
      geom_line(colour = "#333333") +
      geom_pointless(
        aes(colour = after_stat(location)),
        location = c("minimum", "maximum"),
        size = 3
      ) +
      stat_pointless(
        geom = "text",
        aes(label = after_stat(y)),
        location = c("minimum", "maximum"),
        hjust = -.55) +
      facet_wrap(vars(variable), ncol = 1, scales = "free_y") +
      theme(legend.position = "bottom") +
      labs(x = NULL, y = NULL, colour = NULL)

Here it adds horizontal reference lines at the minimum and maximum:

    set.seed(42)
    df3 <- data.frame(x = 1:10, y = sample(10))

    ggplot(df3, aes(x, y)) +
      geom_line() +
      stat_pointless(
        aes(yintercept = y, colour = after_stat(location)),
        location = c("minimum", "maximum"),
        geom = "hline"
      ) +
      theme(legend.position = "bottom")

1. See: https://www.stat.auckland.ac.nz/~paul/Reports/GraphicsEngine/compositing/compositing.html↩︎