Shiny app to interactively place ggrepel labels

2025-12-15

I wrote an R Shiny app which allows you to interactively choose the location of labels placed using ggrepel::geom_label_repel() on a ggplot object.

The aim of the app is to streamline the process of deliberately placing labels when the automated label placement algorithm doesn’t provide an acceptable solution.

Run the app by placing the code below in a file called app.R, then run from R using shiny::runApp("./app.R").

Screenshot of the app running in a web browser.
# Shiny app to interactively place ggrepel points
# John L. Godlee (johngodlee@gmail.com)
# Last updated: 2025-12-07

# Packages
library(shiny)
library(ggplot2)
library(ggrepel)

# UI
ui <- fluidPage(
  titlePanel("Interactive ggrepel Label Positioner"),
  
  fluidRow(
    column(3,
      wellPanel(
        h4("Upload Data"),
        fileInput("csv_file", "Choose CSV File",
                  accept = c("text/csv", 
                           "text/comma-separated-values,text/plain", 
                           ".csv")),
        p("CSV must have columns: x, y, label"),
        hr(),
        h4("Instructions"),
        p("Click on a label to select it, then click where you want to move it."),
        hr(),
        h4("Plot Limits"),
        numericInput("x_min", "X Min:", value = 0, step = 0.5),
        numericInput("x_max", "X Max:", value = 10, step = 0.5),
        numericInput("y_min", "Y Min:", value = 0, step = 0.5),
        numericInput("y_max", "Y Max:", value = 10, step = 0.5)
      )
    ),
    
    column(9,
      plotOutput("ggplot", 
        width = "700px", 
        height = "500px",
        click = "plot_click"),
      hr(),
      h3("Generated R Code"),
      verbatimTextOutput("r_code"),
      p("Copy and paste this code into your R script.")
    )
  )
)

# SERVER
server <- function(input, output, session) {
  
  # Default data
  default_data <- data.frame(
    x = c(3, 6, 4.5, 7.5, 2),
    y = c(4.5, 7.5, 3, 6, 8),
    label = c("Point A", "Point B", "Point C", "Point D", "Point E"),
    stringsAsFactors = FALSE
  )
  
  # Reactive data from file upload
  uploaded_data <- reactive({
    req(input$csv_file)
    
    tryCatch({
      df <- read.csv(input$csv_file$datapath, stringsAsFactors = FALSE)
      
      # Check for required columns
      if(!all(c("x", "y", "label") %in% names(df))) {
        showNotification("CSV must contain columns: x, y, label", type = "error")
        return(default_data)
      }
      
      # Convert to proper types
      df$x <- as.numeric(df$x)
      df$y <- as.numeric(df$y)
      df$label <- as.character(df$label)
      
      # Remove rows with NA values
      df <- df[complete.cases(df[c("x", "y", "label")]), ]
      
      if(nrow(df) == 0) {
        showNotification("No valid data rows found", type = "error")
        return(default_data)
      }
      
      showNotification(paste("Loaded", nrow(df), "data points"), type = "message")
      return(df)
      
    }, error = function(e) {
      showNotification(paste("Error reading file:", e$message), type = "error")
      return(default_data)
    })
  })
  
  # Use uploaded data if available, otherwise use default
  current_data <- reactive({
    if(!is.null(input$csv_file)) {
      uploaded_data()
    } else {
      default_data
    }
  })
  
  # Reactive values
  rv <- reactiveValues(
    label_x = NULL,
    label_y = NULL,
    selected_label = NULL
  )
  
  # Initialize label positions when data changes
  observe({
    df <- current_data()
    if(is.null(rv$label_x) || length(rv$label_x) != nrow(df)) {
      rv$label_x <- df$x
      rv$label_y <- df$y
    }
  })
  
  # Auto-update axis limits when new data is loaded
  observe({
    df <- current_data()
    if(!is.null(input$csv_file)) {
      x_range <- range(df$x, na.rm = TRUE)
      y_range <- range(df$y, na.rm = TRUE)
      x_padding <- diff(x_range) * 0.1
      y_padding <- diff(y_range) * 0.1
      
      updateNumericInput(session, "x_min", value = floor(x_range[1] - x_padding))
      updateNumericInput(session, "x_max", value = ceiling(x_range[2] + x_padding))
      updateNumericInput(session, "y_min", value = floor(y_range[1] - y_padding))
      updateNumericInput(session, "y_max", value = ceiling(y_range[2] + y_padding))
    }
  })
  
  # Handle plot clicks
  observeEvent(input$plot_click, {
    click <- input$plot_click
    
    if(is.null(rv$selected_label)) {
      # Select the nearest label
      distances <- sqrt((rv$label_x - click$x)^2 + (rv$label_y - click$y)^2)
      if(min(distances) < 1.5) {
        rv$selected_label <- which.min(distances)
      }
    } else {
      # Move the selected label to click position
      rv$label_x[rv$selected_label] <- click$x
      rv$label_y[rv$selected_label] <- click$y
      rv$selected_label <- NULL
    }
  })
  
  # Render the ggplot
  output$ggplot <- renderPlot({
    df <- current_data()
    req(rv$label_x)
    
    # Create data frame for label positions
    label_df <- data.frame(
      x = rv$label_x,
      y = rv$label_y,
      label = df$label,
      selected = sapply(1:nrow(df), function(i) {
        !is.null(rv$selected_label) && i == rv$selected_label 
      })
    )
    
    # Create the plot
    p <- ggplot(df, aes(x = x, y = y)) +
      # Add connection lines from points to labels
      geom_segment(
        data = data.frame(
          x = df$x,
          y = df$y,
          xend = rv$label_x,
          yend = rv$label_y),
        aes(x = x, y = y, xend = xend, yend = yend),
        linetype = "dashed", color = "black") +
      # Add data points
      geom_point() +
      # Add labels (showing final result)
      geom_label(
        data = label_df,
        aes(x = x, y = y, label = label, fill = selected)) +
      scale_fill_manual(values = c("FALSE" = "white", "TRUE" = "#dbeafe"), 
        guide = "none") +
      # Styling
      coord_cartesian(
        xlim = c(input$x_min, input$x_max),
        ylim = c(input$y_min, input$y_max),
        clip = "off")
    
    p
  })
  
  # Generate R code
  output$r_code <- renderText({
    df <- current_data()
    req(rv$label_x)
    
    labels <- paste0('"', df$label, '"', collapse = ", ")
    x_coords <- paste0("    ", sprintf("%.2f", rv$label_x), collapse = ",\n")
    y_coords <- paste0("    ", sprintf("%.2f", rv$label_y), collapse = ",\n")
    
    sprintf('
library(ggplot2)
library(ggrepel)

# Your data
df <- data.frame(
  x = c(%s),
  y = c(%s),
  label = c(%s)
)

# Custom label positions from dragging
label_positions <- data.frame(
  x = c(
%s),
  y = c(
%s))

# Create the plot
ggplot(df, aes(x = x, y = y)) +
  geom_point() +
  geom_label_repel(
    aes(label = label),
    nudge_x = label_positions$x - df$x,
    nudge_y = label_positions$y - df$y) +
  xlim(%s, %s) +
  ylim(%s, %s)',
    paste(sprintf("%.2f", df$x), collapse = ", "),
    paste(sprintf("%.2f", df$y), collapse = ", "),
    labels,
    x_coords,
    y_coords,
    input$x_min,
    input$x_max,
    input$y_min,
    input$y_max)
  })
}

shinyApp(ui = ui, server = server)