TITLE: Shiny app to interactively place ggrepel labels DATE: 2025-12-15 AUTHOR: John L. Godlee ==================================================================== 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.](https://johngodlee.xyz/img_full/ggrepel_shiny/scrot.png) ```r # 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(rvlabel_(x))||length(rvlabel_x) != nrow(df)) { rvlabel_(x) < −dfx rvlabel_(y) < −dfy } }) # 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_positionsx − dfx, nudge_y = label_positionsy − dfy) + xlim(%s, %s) + ylim(%s, %s)', paste(sprintf("%.2f", df$x), collapse = ", "), paste(sprintf("%.2f", df$y), collapse = ", "), labels, x_coords, y_coords, inputx_(m)in, inputx_max, inputy_(m)in, inputy_max) }) } shinyApp(ui = ui, server = server)