rshinyplotly

How to interactively set and drag vertical lines (time markers) in a Plotly chart in Shiny?


I would like to display a Plotly graphic in a Shiny dashboard that shows the power output during an ergometry test over time. I’d like to manually set 3 time markers (in seconds):

  1. unloaded paddling
  2. ramp test
  3. recovery

These time markers should be shown as vertical lines in the plot.

Currently, the user has to a) select a marker from a selectInput("Set Marker") dropdown, and b) click exactly on a data point in the plot in order to move the selected marker.

Ideally, I’d like users to be able to click on a vertical line in the plot and drag it left or right to adjust its position — but I haven’t been able to implement that so far.

It would also be great if users didn’t have to click precisely on a data point to set the X value (Time), but could instead click anywhere on the plot background.

Additionally, it would be nice to show the current mouse position's X value in seconds and/or mm:ss format as the user moves the mouse.

Maybe someone has another idea or approach for this?

I would really appreciate your thoughts, ideas, and support!

library(shiny)
library(plotly)

# Deine exakten Beispieldaten
data <- data.frame(
  Seconds = c(2, 5, 8, 11, 14, 17, 21, 25, 28, 32, 35, 38, 41, 44, 47, 50, 52, 55, 58, 62, 63, 67, 69, 71, 73, 76, 79, 81, 83, 
              87, 90, 93, 97, 99, 102, 105, 108, 110, 112, 115, 117, 119, 121, 124, 127, 130, 133, 136, 139, 142, 145, 148, 
              151, 154, 157, 160, 163, 167, 170, 173, 175, 178, 181, 183, 186, 189, 191, 194, 197, 200, 202, 205, 208, 
              210, 213, 216, 218, 221, 223, 226, 228, 231, 233, 236, 239, 241, 243, 246, 249, 252, 255, 257, 260, 263, 265, 268, 271, 
              274, 276, 278, 281, 284, 287, 290, 292, 295, 297, 300, 302, 305, 308, 310, 313, 315, 318, 322, 324, 327, 330, 333, 335, 338, 
              341, 343, 345, 348, 351, 353, 357, 359, 362, 364, 367, 369, 372, 375, 377, 380, 383, 385, 388, 391, 394, 397, 400, 403, 406, 409, 
              411, 414, 417, 419, 422, 425, 428, 430, 433, 435, 438, 440, 443, 445, 448, 451, 453, 455, 458, 461, 464, 466, 469, 472, 474, 476, 
              479, 482, 485, 488, 491, 493, 496, 498, 501, 503, 506, 509, 512, 515, 518, 523, 527, 529, 532, 534, 538, 540, 543, 546, 548, 551, 
              553, 556, 558, 561, 564, 566, 569, 571, 574, 577, 579, 582, 584, 587, 589, 592, 594, 597, 599, 602, 604, 607, 609, 611, 614, 616, 619, 
              621, 623, 626, 628, 631, 633, 636, 639, 641, 644, 647, 649, 651, 654, 657, 659, 662, 664, 667, 670, 672, 674, 677, 680, 681, 684, 687, 
              689, 692, 695, 697, 699, 702, 705, 707, 710, 712, 715, 717, 719, 722, 724, 727, 729, 731, 734, 736, 738, 741, 743, 746, 749, 751, 754, 
              759, 762, 764, 767, 769, 773, 775, 778, 781, 783, 785, 788, 790, 793, 795, 798, 800, 802, 804, 807, 810, 812, 814, 817, 819, 821, 824, 
              827, 829, 832, 835, 838, 841, 844, 847, 850, 852, 856, 858, 861, 864, 866, 869, 871, 873, 875, 878, 880, 882, 884, 887, 889, 892, 894, 
              896, 898, 900, 903, 905, 907, 910, 912, 914, 916, 918, 920, 923, 925, 927, 930, 932, 934, 937, 939, 941, 944, 946, 948, 950, 952, 954, 
              956, 958, 960, 962, 964, 966, 968, 970, 973, 975, 977, 980, 982, 984, 986, 989, 991, 993, 995, 997, 1000, 1002, 1004, 1006, 1008, 1010, 
              1012, 1014, 1017, 1019, 1021, 1023, 1025, 1027, 1029, 1031, 1033, 1035, 1037, 1039, 1041, 1043, 1045, 1047, 1049, 1051, 1053, 1055, 1057, 
              1059, 1061, 1063, 1065, 1067, 1068, 1071, 1072, 1074, 1075, 1077, 1079, 1081, 1083, 1085, 1086, 1088, 1090, 1092, 1094, 1095, 1097, 1099, 
              1101, 1103, 1105, 1107, 1109, 1111, 1112, 1114, 1116, 1118, 1120, 1122, 1123, 1125, 1126, 1128, 1130, 1132, 1133, 1135, 1137, 1138, 1140, 
              1142, 1143, 1145, 1147, 1148, 1150, 1152, 1154, 1155, 1157, 1158, 1160, 1162, 1163, 1165, 1167, 1168, 1170, 1171, 1173, 1175, 1176, 1178, 
              1179, 1181, 1182, 1184, 1185, 1187, 1188, 1190, 1191, 1192, 1194, 1195, 1197, 1198, 1200, 1201, 1203, 1204, 1206, 1208, 1209, 1211, 1213, 
              1214, 1216, 1218, 1220, 1221, 1223, 1225, 1226, 1228, 1231, 1232, 1234, 1236, 1237, 1239, 1241, 1242, 1244, 1246, 1247, 1249, 1250, 1252, 
              1254, 1255, 1257, 1258, 1260, 1262, 1263, 1265, 1266, 1268, 1270, 1271, 1273, 1275, 1277, 1278, 1280, 1282, 1284, 1285, 1287, 1289, 1291, 
              1294, 1296, 1298, 1300, 1302, 1303, 1305, 1306, 1308, 1309, 1311, 1313, 1314, 1316, 1318, 1321, 1323, 1325, 1327, 1328, 1330, 1332, 1333, 
              1335, 1336, 1338, 1340, 1341, 1343, 1345, 1346, 1348, 1349, 1351, 1353, 1354, 1356, 1358, 1359, 1361, 1362, 1364, 1366, 1367, 1369, 1371, 
              1372, 1374, 1376, 1378, 1379, 1381, 1383, 1385, 1386, 1388, 1390, 1391, 1393, 1395, 1396, 1398, 1399, 1401, 1403, 1405, 1406, 1408, 1410, 
              1411, 1413, 1415, 1416, 1418, 1421, 1423, 1425, 1427, 1429, 1430, 1432, 1433, 1435, 1437, 1438, 1440, 1442, 1443, 1445, 1447, 1448, 1450, 
              1452, 1453, 1455, 1457, 1458, 1460, 1462, 1463, 1465, 1467, 1468, 1470, 1472, 1474, 1475, 1477, 1479, 1480, 1482, 1484, 1485, 1487, 1489, 
              1490, 1492, 1494, 1495, 1497, 1499, 1501, 1502, 1504),
  Power = c(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 
            0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 
            0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 
            75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 75, 
            75, 75, 75, 75, 77, 77, 80, 80, 82, 82, 82, 82, 85, 85, 87, 90, 90, 92, 95, 95, 95, 97, 97, 100, 100, 102, 102, 105, 105, 107, 107, 107, 107, 110, 
            110, 112, 112, 115, 117, 117, 120, 120, 120, 120, 122, 122, 125, 125, 127, 127, 127, 130, 130, 132, 132, 132, 132, 135, 135, 137, 137, 140, 140, 
            142, 142, 145, 145, 145, 147, 147, 150, 150, 150, 152, 155, 155, 157, 157, 157, 157, 157, 160, 162, 162, 165, 165, 167, 167, 170, 170, 170, 170, 
            172, 172, 175, 175, 175, 177, 177, 180, 180, 182, 182, 182, 182, 185, 185, 187, 187, 190, 192, 195, 195, 195, 195, 197, 197, 200, 200, 202, 202, 
            205, 205, 207, 207, 207, 207, 210, 210, 212, 212, 215, 215, 217, 217, 217, 220, 220, 220, 222, 222, 225, 225, 227, 230, 230, 232, 232, 232, 232, 
            235, 235, 237, 237, 240, 240, 242, 242, 245, 245, 245, 245, 247, 247, 247, 250, 250, 252, 252, 255, 255, 255, 257, 257, 257, 257, 260, 260, 262, 
            262, 265, 265, 265, 267, 267, 270, 270, 270, 270, 272, 272, 272, 275, 275, 275, 277, 277, 280, 280, 282, 282, 282, 282, 282, 285, 285, 287, 287, 
            290, 290, 292, 292, 292, 295, 295, 295, 295, 297, 297, 297, 300, 300, 302, 302, 302, 305, 305, 307, 307, 307, 307, 307, 310, 310, 310, 312, 312, 
            315, 315, 315, 317, 317, 320, 320, 320, 320, 320, 320, 322, 322, 322, 325, 325, 327, 327, 327, 330, 330, 330, 332, 332, 332, 332, 332, 335, 335, 
            335, 337, 337, 337, 340, 340, 342, 342, 342, 345, 345, 345, 345, 345, 345, 347, 347, 347, 350, 350, 350, 352, 352, 352, 355, 355, 355, 357, 357, 
            357, 357, 357, 357, 360, 360, 360, 362, 362, 362, 365, 365, 365, 367, 367, 367, 367, 370, 370, 370, 370, 370, 370, 372, 372, 372, 375, 375, 375, 
            375, 250, 250, 250, 125, 125, 125, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 
            50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 
            50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 
            50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 
            50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50, 50))

# Marker-Farben
marker_colors <- list(
  unloaded = "blue",
  ramp = "orange",
  recovery = "red"
)

ui <- fluidPage(
  titlePanel("Set Time Markers"),
  
  sidebarLayout(
    sidebarPanel(
      numericInput("marker_unloaded", "Start unloaded paddling (s)", value = 30, min = 0),
      numericInput("marker_ramp", "Start ramp test (s)", value = 120, min = 0),
      numericInput("marker_recovery", "Start recovery (s)", value = 360, min = 0),
      
      selectInput("selected_marker", "Set Marker:",
                  choices = c("Unloaded paddling" = "unloaded",
                              "Ramp test" = "ramp",
                              "Recovery" = "recovery")),
      
      verbatimTextOutput("click_time")
    ),
    
    mainPanel(
      plotlyOutput("phase_plot", height = "500px")
    )
  )
)

server <- function(input, output, session) {
  phase_markers <- reactiveVal(list(
    unloaded = 30,
    ramp = 120,
    recovery = 360
  ))
  
  observe({
    click_data <- plotly::event_data("plotly_click", source = "phaseplot")
    req(click_data)
    
    output$click_time <- renderText({
      paste0("click at: ", round(click_data$x, 1), " seconds")
    })
    
    current <- phase_markers()
    current[[input$selected_marker]] <- round(click_data$x)
    phase_markers(current)
    
    updateNumericInput(session, paste0("marker_", input$selected_marker), value = round(click_data$x))
  })
  
  output$phase_plot <- renderPlotly({
    markers <- phase_markers()
    max_power <- max(data$Power, na.rm = TRUE)
    
    shapes <- lapply(names(markers), function(name) {
      list(
        type = "line",
        x0 = markers[[name]],
        x1 = markers[[name]],
        y0 = 0,
        y1 = max_power,
        line = list(color = marker_colors[[name]], width = 2, dash = "dash")
      )
    })
    
    annotations <- lapply(names(markers), function(name) {
      list(
        x = markers[[name]],
        y = max_power,
        text = paste0(markers[[name]], "s (", format(.POSIXct(markers[[name]], tz = "UTC"), "%M:%S"), ")"),
        showarrow = TRUE,
        arrowhead = 2,
        ax = 0,
        ay = -40,
        font = list(color = marker_colors[[name]], size = 12)
      )
    })
    
    plot_ly(data, x = ~Seconds, y = ~Power, type = "scatter", mode = "markers",
            marker = list(color = "darkgreen", size = 6),
            source = "phaseplot") %>%
      layout(
        dragmode = FALSE,
        shapes = shapes,
        annotations = annotations,
        xaxis = list(
          title = "Time (mm:ss)",
          tickvals = seq(0, max(data$Seconds), by = 120),
          ticktext = format(.POSIXct(seq(0, max(data$Seconds), by = 120), tz = "UTC"), "%M:%S"),
          color = "black",
          tickfont = list(size = 12, color = "black"),
          linecolor = "black",
          linewidth = 1
        ),
        yaxis = list(
          title = "Power (Watt)",
          color = "black",
          tickfont = list(size = 12, color = "black"),
          linecolor = "black",
          linewidth = 1
        )
      )
  })
}

shinyApp(ui, server)

Solution

    1. make the lines movable using this
    2. observe the "plotly_relayout" event and set the marker lines accordingly.
    3. for the hoverposition you could use hovermode = "x unified" which shows the current x/y values
    4. You can observe the hover_position and show the current x as HH:MM - however I could not make it, so that x is displayed during the drag-operation. Plotly just has no event onDrag. One could add event listeners to the g.paths that draw the line on the plotly svg, but even so one could only get the screen-x-position which does not give the plot-x. For this one would need to transform cx to x using the screen resolution and I deemed this to be too complicated.

    Edit:

    Combine it all and you get out

    Code

    library(shiny)
    library(plotly)
    
    
    data <- data.frame(
      Seconds = 1:1504,
      Power = 1:1504)
    
    # Marker-colors
    marker_colors <- list(
      unloaded = "blue",
      ramp = "orange",
      recovery = "red"
    )
    
    ui <- fluidPage(
      titlePanel("Set Time Markers"),
      sidebarLayout(
        sidebarPanel(
          numericInput("marker_unloaded", "Start unloaded paddling (s)", value = 30, min = 0),
          numericInput("marker_ramp", "Start ramp test (s)", value = 120, min = 0),
          numericInput("marker_recovery", "Start recovery (s)", value = 360, min = 0),
          verbatimTextOutput("click_time")
        ),
        mainPanel(
          plotlyOutput("phase_plot", height = "500px")
        )
      )
    )
    
    server <- function(input, output, session) {
      phase_markers <- reactiveVal(list(
        unloaded = 30,
        ramp = 120,
        recovery = 360
      ))
      
      # observe mouse hover
      
      observeEvent(plotly::event_data("plotly_hover", source = "phaseplot"), {
        ev <- as.data.frame(plotly::event_data("plotly_hover", source = "phaseplot"))
        output$click_time <- renderText({
          paste0(ev$x, " sec (", format(.POSIXct(ev$x, tz = "UTC"), "%M:%S"), ")")
        })
    
      })
      
      observeEvent(input$manual_marker_move, {
        req(input$manual_marker_move)
        output$click_time <- renderText({
          paste0(input$manual_marker_move$position, " sec (", format(.POSIXct(input$manual_marker_move$position, tz = "UTC"), "%M:%S"), ")")
        })
      })
      
      # observe line move
      
      observeEvent(plotly::event_data("plotly_relayout", source = "phaseplot"), {
        print(plotly::event_data("plotly_relayout", source = "phaseplot"))
        relayout_data <- as.data.frame(plotly::event_data("plotly_relayout", source = "phaseplot"))
        req(relayout_data)
        name <- colnames(relayout_data)[1]
        if(grepl("shapes", name)){
          movedMarker <- dplyr::case_when(
            grepl("shapes.2", name) ~ "recovery",
            grepl("shapes.1", name) ~ "ramp",
            .default = "unloaded"
          )
          
          xpos <- round(relayout_data[[1]][1])
          
          current <- phase_markers()
          current[[movedMarker]] <- xpos
          phase_markers(current)
          
          updateNumericInput(session, paste0("marker_", movedMarker), value =xpos)
          
        }
        
      })
      
      
      # Also observe numeric inputs
      observe({
        markers <- list(
          unloaded = input$marker_unloaded,
          ramp = input$marker_ramp,
          recovery = input$marker_recovery
        )
        phase_markers(markers)
      })
    
      output$phase_plot <- renderPlotly({
        markers <- phase_markers()
        max_power <- max(data$Power, na.rm = TRUE)
        
        shapes <- lapply(names(markers), function(name) {
          list(
            type = "line",
            x0 = markers[[name]],
            x1 = markers[[name]],
            y0 = 0,
            y1 = max_power,
            line = list(color = marker_colors[[name]], width = 2, dash = "dash")
          )
        })
        
        annotations <- lapply(names(markers), function(name) {
          list(
            x = markers[[name]],
            y = max_power,
            text = paste0(markers[[name]], "s (", format(.POSIXct(markers[[name]], tz = "UTC"), "%M:%S"), ")"),
            showarrow = TRUE,
            arrowhead = 2,
            ax = 0,
            ay = -40,
            font = list(color = marker_colors[[name]], size = 12)
          )
        })
        
        plot_ly(data, x = ~Seconds, y = ~Power, type = "scatter", mode = "markers",
                marker = list(color = "darkgreen", size = 6),
                source = "phaseplot") %>%
          layout(
            dragmode = FALSE,
            hovermode = "x unified", # for x
            shapes = shapes,
            annotations = annotations,
            xaxis = list(
              title = "Time (mm:ss)",
              tickvals = seq(0, max(data$Seconds), by = 120),
              ticktext = format(.POSIXct(seq(0, max(data$Seconds), by = 120), tz = "UTC"), "%M:%S"),
              color = "black",
              tickfont = list(size = 12, color = "black"),
              linecolor = "black",
              linewidth = 1
            ),
            yaxis = list(
              title = "Power (Watt)",
              color = "black",
              tickfont = list(size = 12, color = "black"),
              linecolor = "black",
              linewidth = 1
            )
          ) %>%
          config(edits = list(shapePosition = TRUE))|> # https://stackoverflow.com/a/56773037/28479453
          
          htmlwidgets::onRender("
        function(el, x) {
        
          if (window.shapeObserver) {
            window.shapeObserver.disconnect();
            console.log('Disconnected previous observer');
          }
          
          // Function to convert from SVG coordinates to data coordinates
          function svgToDataX(svgX) {
            // Get plot area dimensions and position
            const svgContainer = el.querySelector('div.svg-container');
            const computedStyle = window.getComputedStyle(svgContainer);
            const plotWidth = parseFloat(computedStyle.width);
            const xWidth = el.querySelector('rect.nsewdrag').getAttribute('width');
            const offset = (plotWidth - xWidth) - 9.8; // lots of tears for this formula
            svgX = svgX - offset;
            const xaxis = x.layout.xaxis;
            let factor =  (xaxis.range[1]-xaxis.range[0]) / xWidth;
            return Math.round(svgX * factor + xaxis.range[0]);
          }
          
          // Create a mutation observer to watch for changes to the SVG
          window.shapeObserver = new MutationObserver(function(mutations) {
            // Use a debounce mechanism to prevent too many updates
            if (window.observerTimeout) {
              clearTimeout(window.observerTimeout);
            }
            
            window.observerTimeout = setTimeout(function() {
              for (const mutation of mutations) {
                if (mutation.type === 'attributes' && mutation.attributeName === 'd') {
                  // Extract position from path data
                  const pathData = mutation.target.getAttribute('d');
                  
                  const match = pathData.match(/M(\\d+\\.?\\d*),/);
                  
                  if (match) {
                    const xPos = parseFloat(match[1]);
                    // Figure out which marker this is
                    const index = mutation.target.getAttribute('data-index');
                    const xData = svgToDataX(xPos);
                    if (index !== null) {
                      
                      console.log('Marker ' + index + ' moved to pixel Xpos: ' + xPos + ', data Xpos: ' + xData);
                      // Send to Shiny
                      Shiny.setInputValue('manual_marker_move', {
                        index: parseInt(index),
                        position: xData
                      });
                    }
                  }
                }
              }
            }, 50);
          });
          
          // Start observing 
          setTimeout(() => {
            const paths = el.querySelectorAll('g[class=\"shapelayer\"] path');
            paths.forEach(path => {
              window.shapeObserver.observe(path, { attributes: true });
            });
          }, 100); // adjust delay
        }
      ")
      })
    }
    
    shinyApp(ui, server)