Skip to content

Add side aesthetic to control segment attachment point on label bounding box#281

Open
arkriloth0 wants to merge 4 commits into
slowkow:masterfrom
arkriloth0:spider-plot
Open

Add side aesthetic to control segment attachment point on label bounding box#281
arkriloth0 wants to merge 4 commits into
slowkow:masterfrom
arkriloth0:spider-plot

Conversation

@arkriloth0

Copy link
Copy Markdown

Summary

  • Adds a side aesthetic to geom_text_repel and geom_label_repel that
    controls which side of the label bounding box the connecting segment attaches to
  • Accepts numeric (1–4) or character values: "top", "right", "bottom", "left"
  • Defaults to 0 (automatic, existing behaviour unchanged)

Motivation

When labels are nudged to a margin, segments should consistently attach to the
edge of the label that faces the data. Without side, the attachment point is
chosen geometrically — which can result in some segments attaching to the
left/right edge while others attach to the top or bottom, producing an uneven
appearance. Setting side enforces a uniform attachment edge across all labels,
giving a cleaner result.

Example

library(ggplot2)
library(ggrepel)
library(dplyr)

set.seed(42)
n <- 60

df <- data.frame(
  x = runif(n, 0, 100),
  y = runif(n, 10, 30),
  label = NA_character_,
  group = NA_character_
)

labeled <- data.frame(
  x     = c(2, 5, 8, 3, 6, 4,   55, 70, 80),
  y     = c(19, 20, 21, 19.5, 20.5, 20,   22, 19, 24),
  label = c("A1","A2","B1","B2","A3","C1", "D1","D2","D3"),
  group = c("Group A","Group A","Group B","Group B","Group A","Group C",
            "Group D","Group D","Group D")
)

df <- bind_rows(df, labeled)

cutoff <- 20

plot_df <- df %>%
  mutate(
    status = case_when(
      !is.na(label) & x < cutoff  ~ "low",
      !is.na(label) & x >= cutoff ~ "hi",
      TRUE ~ NA_character_
    ),
    nudge_x = case_when(
      status == "low" ~ -5 - x,
      status == "hi"  ~ 115 - x,
      TRUE ~ 0
    ),
    nudge_y = case_when(status == "hi" ~ 3, TRUE ~ 0),
    # side=2 (right): segment exits right edge of label → points toward data on the right
    # side=4 (left):  segment exits left edge of label  → points toward data on the left
    side  = case_when(status == "low" ~ 2, status == "hi" ~ 4, TRUE ~ 0),
    hjust = case_when(status == "low" ~ 1, status == "hi" ~ 0, TRUE ~ 0.5),
    curvature = case_when(
      status == "low" & y >  20 ~  0.01,
      status == "low" & y <= 20 ~ -0.01,
      status == "hi"            ~ -0.01,
      TRUE ~ NA_real_
    )
  )

ggplot(plot_df, aes(x, y, colour = group, label = label)) +
  geom_point(aes(size = ifelse(is.na(group), 1, 2)), alpha = 0.7) +
  geom_text_repel(
    fontface      = "bold",
    size          = 3.2,
    hjust         = plot_df$hjust,
    nudge_x       = plot_df$nudge_x,
    nudge_y       = plot_df$nudge_y,
    side          = plot_df$side,
    segment.curvature = plot_df$curvature,
    segment.ncp   = 10,
    segment.angle = 10,
    segment.size  = 0.2,
    min.segment.length = 0,
    box.padding   = 0.25,
    direction     = "y"
  ) +
  scale_x_continuous("x", expand = expansion(mult = c(0.2, 0.2))) +
  scale_y_continuous("y") +
  scale_size(guide = "none") +
  theme_bw()
\```

@slowkow

slowkow commented Apr 14, 2026

Copy link
Copy Markdown
Owner

Would it be possible to include a screenshot here so we can see what the changes look like in a real plot?

@arkriloth0

arkriloth0 commented Apr 15, 2026

Copy link
Copy Markdown
Author

Demonstration: side parameter in geom_text_repel

library(ggplot2)
library(ggrepel)
library(patchwork)

10 points are clustered at the bottom-right (x ≈ 70–105, y ≈ 5–10).
Labels are nudged right to x = 110 (nudge_x ≈ 5–40) and up by 20 units
(nudge_y = 20), seeding them around y ≈ 25–30 before repulsion.

direction = "y" stacks all labels vertically along x = 110. Each label ends
up 20+ units above its point (large b) while only 5–40 units to its right
(small a), so b/a >> W/H for most labels. This triggers the corner-case in
select_line_connection: without intervention the segment exits the bottom
edge of the label rather than the left edge. Curvature is fixed at 0.01 (slight
bow) for all segments.

side behaviour
0 (default) segments exit the bottom edge of labels — inconsistent
4 left edge forced for every label — consistent
set.seed(23)

label_x <- 110

n <- 10
labeled <- data.frame(
  x = runif(n, 70, 105),
  y = runif(n, 5, 10),
  label = LETTERS[1:n]
)

set.seed(7)
bg <- data.frame(
  x = runif(120, 0, 110),
  y = runif(120, 5, 95)
)

nudge_x_vals <- label_x - labeled$x
curvature_vals <- 0.01
make_plot <- function(side_val) {
  if (side_val == 0) {
    title <- "side = 0  (default)"
    subtitle <- "Labels far above points: segment exits bottom edge — messy"
  } else {
    title <- "side = 4  (left edge forced)"
    subtitle <- "All segments exit the left edge — consistent"
  }

  ggplot(bg, aes(x, y)) +
    geom_point(colour = "grey80", size = 1, alpha = 0.6) +
    geom_point(
      data = labeled,
      aes(x, y),
      colour = "steelblue",
      size = 2.5
    ) +
    geom_text_repel(
      data = labeled,
      aes(x, y, label = label),
      nudge_x = nudge_x_vals,
      nudge_y = 20,
      direction = "y",
      hjust = 0,
      side = side_val,
      min.segment.length = 0,
      segment.size = 0.4,
      segment.colour = "grey30",
      segment.curvature = curvature_vals,
      segment.ncp = 6,
      segment.angle = 20,
      size = 3,
      box.padding = 0.05,
      seed = 42
    ) +
    scale_x_continuous(expand = expansion(mult = c(0.05, 0.35))) +
    scale_y_continuous(expand = expansion(mult = c(0.05, 0.05))) +
    labs(title = title, subtitle = subtitle, x = NULL, y = NULL) +
    theme_bw(base_size = 11) +
    theme(plot.subtitle = element_text(colour = "grey40", size = 9))
}
make_plot(0) + make_plot(4)
image

@slowkow

slowkow commented Apr 15, 2026

Copy link
Copy Markdown
Owner

Very nice! Thank you. I'll look over the code and consider merging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants