Label arrangement with ggpointgrid

Posted on May 18, 2026 by Clemens Schmid | ⌛ 11 min read

Introduction

Labelling observations in R’s ggplot2 scatter plots is not as easy as I would like it to be. Even in simple plots I often run into the issue that labels overlap, hide information, or are just placed in a visually unpleasant arrangement.

The most powerful tool I know to avoid or at least mitigate these problems is the ggrepel package. It uses a clever, non-deterministic algorithm to repel labels away from each other, away from data points, and away from edges of the plotting area. I often rely on its geom_label_repel() to achieve better label placement.

There is still room for other approaches, though. I recently realised that my own little ggplot extension ggpointgrid has some potential to help with label placement, and in version 2 I implemented the necessary changes to make this functionality conveniently available.

Mechanism

ggpointgrid includes an algorithm to map a set of positions in 2D space to their closest counterpart in a second set of positions. I originally developed this to reposition scatter plot points on regular grids in the plotting coordinate system. Similar to geom_jitter() the rearranged points are not placed exactly where they ought to be, but reasonably close. Most importantly they are all individually visible: Not a single point hidden by another one through overplotting.

So some weeks ago I was working on a world map plot where I wanted to add text labels for observations in the gaps between continental land masses. To my knowlegde ggrepel does not easily allow to do this. The label placement area can be limited with xlim and ylim, and label positions can be fixed along one axis with nudge_x and nudge_y, but that quickly becomes tedious for complex geometries. While pondering how to achieve what I wanted without resorting to manual figure editing, it dawned on me that ggpointgrid’s point repositioning algorithm could be of use here: After all, labels should also be placed close to the original observations, and they should also strictly not overlap.

Of course using this algorithm requires some preparation of the potential label locations. But in certain plotting situation this is desirable: precise, deterministic control over the set of potential target positions – for example around land masses on a map – without performing the final arrangement manually.

Examples

To demonstrate cases where ggpointgrid can shine I prepared three example plots below. They show how the functions in the package can be used to create label arrangements that I would personally find hard to realise without it.

Labels on a diagonal line

Example plot 1: Labels on a line
Code for this figure
library(magrittr)
library(ggplot2)

# prepare mtcars dataset with id column
dat <- mtcars %>% tibble::rownames_to_column("id")

# fit linear trend line to
# hp = Gross horsepower and mpg = Miles/(US) gallon
fit <- lm(hp~mpg, data = dat)
pred <- tibble::tibble(
  mpg = seq(8, 35, length.out = 2 * nrow(dat)),
  hp = predict(fit, newdata = data.frame(mpg))
)

# shift trend line to work as a target grid for the label arrangement
pred_shifted <- pred
pred_shifted$mpg <- pred_shifted$mpg - 5
pred_shifted$hp <- pred_shifted$hp - 40

# compute data range and orthogonal angle to trend line
# to set the plot ratio and label angle
b <- coef(fit)[["mpg"]]
x_range <- diff(range(c(dat$mpg, pred$mpg, pred_shifted$mpg)))
y_range <- diff(range(c(dat$hp, pred$hp, pred_shifted$hp)))
orth_angle <- atan(b * x_range / y_range) * 180 / pi + 90

# assemble plot
ggplot(data = dat, aes(x = mpg, y = hp)) +
  geom_point(data = pred_shifted, colour = "grey", size = 1) +
  geom_line(data = pred, colour = "red") +
  geom_segmentgrid(
    # the grid defines where labels could be placed
    grid_xy = as.matrix(pred_shifted),
    # hp and mpg are scaled very differently and scale_xy
    # unifies that for the point-grid distance calculation
    scale_xy = TRUE,
    colour = "grey", linetype = "dashed"
  ) +
  geom_point() +
  geom_textgrid(
    aes(label = id),
    # the same arguments as for geom_segmentgrid to get the same label arrangement
    grid_xy = as.matrix(pred_shifted),
    scale_xy = TRUE,
    hjust = 1.05, angle = orth_angle, size = 3
  ) +
  coord_cartesian(clip = FALSE, ratio = x_range/y_range) +
  theme_bw()

In this plot the labels are arranged parallel to a linear trend line fitted to the data points. The fitted line is plotted in red, and the desired label positions derived from it are plotted as grey dots (for illustrative purposes). They mark the set of positions we allow ggpointgrid to consider for label placement. Among these it automatically selects “optimal”, close positions for each point. geom_segmentgrid() draws the dashed grey connections between observations and grid points, and geom_textgrid() the respective text labels. The most complicated part of the code is the automatic definition of an aestetically pleasing axis ratio, and a label angle orthogonal to the grid line.

Labels around a spatial polygon

Example plot 2: Labels around a polygon
Code for this figure
library(magrittr)
library(ggplot2)

# download spatial data
# germany border
temp <- tempfile()
download.file("https://daten.gdz.bkg.bund.de/produkte/vg/nuts5000/aktuell/nuts5000_12-31.utm32s.gpkg.zip", temp)
germany <- sf::st_read(grepv(".gpkg$", unzip(temp, exdir = tempdir()))) %>% sf::st_union()
crs <- sf::st_crs(germany)
# airports around the world
temp <- tempfile()
download.file("https://naciscdn.org/naturalearth/10m/cultural/ne_10m_airports.zip", temp)
airports_world <- sf::st_read(grepv(".shp", unzip(temp, exdir = tempdir())))
airports_germany <- airports_world %>%
  sf::st_transform(crs) %>%
  sf::st_intersection(germany)
airports_df <- airports_germany %>%
  dplyr::mutate(
    x = sf::st_coordinates(.)[,1],
    y = sf::st_coordinates(.)[,2]
  ) %>% sf::st_drop_geometry()

# prepare label grid on a buffer line around the germany polygon
germany_buffer_points <- germany %>%
  # line in 60km distance to the polygon
  sf::st_buffer(dist = 60000) %>%
  sf::st_boundary() %>%
  # 20 equidistant grid points along this line
  sf::st_line_sample(n = 20)

# assemble plot
ggplot() +
  geom_sf(data = germany, fill = "white", colour = "black") +
  geom_sf(data = germany_buffer_points, colour = "grey", size = 1) +
  ggpointgrid::geom_segmentgrid(
    data = airports_df, aes(x, y),
    grid_xy = sf::st_coordinates(germany_buffer_points),
  ) +
  geom_point(data = airports_df, aes(x, y)) +
  ggpointgrid::geom_labelgrid(
    data = airports_df, aes(x, y, label = abbrev),
    grid_xy = sf::st_coordinates(germany_buffer_points)) +
  coord_sf(clip = "off") +
  theme_bw() +
  theme(axis.title = element_blank())

In this plot the labels are arranged around a spatial polygon. A buffer constructed with the sf package defines an outline from which grid points – again plotted in grey – are derived. ggpointgrid then assigns the observations in the polygon to the closest grid points and draws labels.

Labels on a grid around a complex point cloud

Example plot 3: Labels on a grid around a point cloud
Code for this figure
library(magrittr)
library(ggplot2)

# make random, annulus-shaped point cloud
set.seed(124)
n <- 1000
r1 <- 1
r2 <- 2
theta <- runif(n, 0, 2*pi)
r <- sqrt(runif(n, r1^2, r2^2))
annulus <- tibble::tibble(
  x = r * cos(theta), y = r * sin(theta),
  label = 1:n
)

# define polygons around the point cloud using sf
points <- sf::st_multipoint(as.matrix(annulus[,c(1,2)]))
outline <- points %>% sf::st_convex_hull() %>% sf::st_buffer(dist = 1)
annulus_polygon <- points %>% sf::st_buffer(dist = 0.2)
# these are the polygons around the point cloud, excluding the area
# immediately around the points themselves
annulus_polygon_inv <- sf::st_difference(outline, annulus_polygon)

# select 100 random samples for labelling
annulus_sample <- annulus %>% dplyr::slice_sample(n = 100)

# assemble plot
ggplot() +
  #geom_sf(data = annulus_polygon_inv) +
  ggpointgrid::geom_segmentgrid(
    data = annulus_sample, aes(x, y),
    # define grid resolution
    grid_x = 25L, grid_y = 25L,
    # filter grid to only points within the polygons around the point cloud
    polygons_sf = annulus_polygon_inv,
    colour = "grey"
  ) +
  geom_point(data = annulus, aes(x, y)) +
  ggpointgrid::geom_labelgrid(
    data = annulus_sample, aes(x, y, label = label),
    grid_x = 25L, grid_y = 25L,
    polygons_sf = annulus_polygon_inv,
    size = 3
  ) +
  theme_bw()

In this plot the labels are arranged on a regular grid in the area around a ring-shaped point cloud. Geometric operations implemented with sf allow to define the area around the ring (or any other point cloud). ggpointgrid then directly takes this polygon definition, erects a regular grid within it, and uses it to assign label positions. This allows to draw a large number of non-overlapping labels close to, but not over any observations.

Words of caution

I think the examples above demonstrate that there are cases where ggpointgrid’s label arrangement is useful. But there are also issues with this approach:

  1. The most obvious drawback was already raised above: The target grid requires concious preparation. Although ggpointgrid has features to make this more convenient, e.g. the polygons_sf argument, to easily define an area of interest, a certain degree of experimenting and tweaking is almost always required to achieve a satisfactory result.
  2. The regular arrangement of the target positions sometimes leads to a grid where each label has its own position, but still overlaps partially with neighbouring labels because of their horizontal extent. This gets increasingly worse when the labels are longer. As with any other scatter plot labelling mechanism, shorter labels are easier to arrange well.
  3. ggpointgrid’s arrangement algorithm always prioritizes observations that are closest to a given target grid position. This can lead to suboptimal, unintuitive label placement for more distant observations. Example plot 3 shows this effect for some labels, e.g. 436 and 153 in the bottom right corner. I found it helpful to increase the target grid density to mitigate such cases, but that is not always possible.