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):
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)
"plotly_relayout"
event and set the marker lines accordingly.hovermode = "x unified"
which shows the current x/y valueshover_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:
input$manual_marker_move
showing the currently dragged time while the user moves the linelibrary(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)