I'm working with a Plotly figure in R that has 3 subplots sharing the same Y-axis (using subplot() with shareY = TRUE). I've implemented custom hover behavior using onRender() that:
The Issue: The Y values in the annotations update correctly as I move the cursor, but the x values don't change.
What I Want: When hovering over any subplot, I want to see 3 annotations (one per subplot), each showing:
Both values should update dynamically as the cursor moves.
What I'm Getting:
library(plotly)
library(htmlwidgets)
set.seed(123)
mk_day <- function(n) {
base <- as.POSIXct(Sys.Date(), tz = "UTC")
t <- base + sort(sample(0:(24*60*60-1), n))
data.frame(time = t, value = rnorm(n))
}
df1 <- mk_day(60)
df2 <- transform(mk_day(80), value = value * 2 + 3)
df3 <- transform(mk_day(40), value = value * 3 + 6)
p1 <- plot_ly(df1, x = ~value, y = ~time, type = "scatter", mode = "lines+markers", name = "Serie 1")
p2 <- plot_ly(df2, x = ~value, y = ~time, type = "scatter", mode = "lines+markers", name = "Serie 2")
p3 <- plot_ly(df3, x = ~value, y = ~time, type = "scatter", mode = "lines+markers", name = "Serie 3")
fig <- subplot(p1, p2, p3, nrows = 1, shareY = TRUE, titleX = TRUE, titleY = TRUE) |>
layout(
hovermode = "y",
yaxis = list(type = "date", tickformat = "%H:%M", title = "Hora (24h)"),
yaxis2 = list(type = "date", tickformat = "%H:%M"),
yaxis3 = list(type = "date", tickformat = "%H:%M"),
margin = list(t = 60, r = 10, b = 40, l = 60)
)
fig_fixed <- onRender(fig, "
function(el, x) {
var gd = document.getElementById(el.id);
// Map subplots by X-axis only; Y is shared
var xAxes = ['x', 'x2', 'x3'];
// Helper: get x-axis name for a trace (e.g., 'x', 'x2', 'x3')
function xAxisOfTrace(tr) {
if (tr && typeof tr.xaxis === 'string') return tr.xaxis; // 'x', 'x2', 'x3'
return 'x'; // fallback
}
gd.on('plotly_hover', function(evt) {
if (!evt || !evt.points || !evt.points.length) return;
// 1) Y value under the cursor (shared Y coordinates)
var yHover = evt.points[0].y;
// 2) Single horizontal line across ALL subplots (shared Y)
var lineShape = [{
type: 'line',
xref: 'paper', x0: 0, x1: 1,
yref: 'y', y0: yHover, y1: yHover,
line: { width: 2, dash: 'dot' }
}];
// 3) For each subplot (x, x2, x3), find the x whose y is closest to yHover
// We compute ONE annotation per subplot.
var bestByXAxis = {}; // e.g., { x: {x:..., y:..., diff:...}, x2: {...}, x3: {...} }
for (var i = 0; i < gd.data.length; i++) {
var tr = gd.data[i];
if (!tr || !tr.x || !tr.y) continue;
if (!Array.isArray(tr.x) || !Array.isArray(tr.y) || tr.x.length !== tr.y.length) continue;
var xa = xAxisOfTrace(tr); // 'x' | 'x2' | 'x3'
// Find nearest y
var bestIdx = 0, bestDiff = Infinity;
for (var k = 0; k < tr.y.length; k++) {
var d = Math.abs(tr.y[k] - yHover);
if (d < bestDiff) { bestDiff = d; bestIdx = k; }
}
var pick = { x: tr.x[bestIdx], y: tr.y[bestIdx], diff: bestDiff };
if (!bestByXAxis[xa] || pick.diff < bestByXAxis[xa].diff) {
bestByXAxis[xa] = pick;
}
}
// 4) Build dynamic annotations (always yref: 'y' because Y is shared)
var dynAnnotations = [];
xAxes.forEach(function(xa) {
var pick = bestByXAxis[xa];
if (!pick) return;
// If x are dates (POSIXct), text may need formatting on the R side; here we leave raw.
var txt = 'x = ' + pick.x + '<br>y ≈ ' + yHover;
dynAnnotations.push({
xref: xa, // 'x' | 'x2' | 'x3'
yref: 'y', // shared Y axis
x: pick.x,
y: yHover, // lock onto the horizontal guide
text: txt,
showarrow: true,
arrowhead: 2,
ax: 20, ay: -20,
bgcolor: 'rgba(255,255,255,0.85)',
bordercolor: '#333',
borderwidth: 1,
font: { size: 12, color: '#111' },
align: 'left',
name: xa + '_' + Math.random() // Fuerza actualización
});
});
// 5) Replace shapes & annotations (do NOT merge with any base annotations)
Plotly.relayout(gd, { shapes: lineShape, annotations: [] }).then(function() {
Plotly.relayout(gd, { annotations: dynAnnotations });
});
});
gd.on('plotly_unhover', function() {
// Clear all dynamic overlays
Plotly.relayout(gd, { shapes: [], annotations: [] });
});
}
")
fig_fixed
The issue lies here
var d = Math.abs(tr.y[k] - yHover);
a Console Log reveals that both dates are not in a format that allows Math.abs to get the absolute from a difference.
// console.log('tr.y[k]: ' + tr.y[k] + ' | ' + 'yHover: ' + yHover + ' | ' + 'diff: ' + Math.abs(tr.y[k] - yHover));
tr.y[k]: 2025-10-02 07:25:14.000000 | yHover: 2025-10-02 19:03:35 | diff: NaN
Instead, you can transform both to date format, which will fix the issue:
var d = Math.abs(new Date(tr.y[k]) - new Date(yHover));
Also under 5) one relayout call is enough
// 5) Replace shapes & annotations (do NOT merge with any base annotations)
Plotly.relayout(gd, { shapes: lineShape, annotations: dynAnnotations });
Using pick.x as x position of each hover window leads them to "jump" around, you might want to keep them at a fixed position or median of each x-range - For example x: 0 makes it a bit easier on the eyes :)
Full Reprex
library(plotly)
library(htmlwidgets)
set.seed(123)
mk_day <- function(n) {
base <- as.POSIXct(Sys.Date(), tz = "UTC")
t <- base + sort(sample(0:(24*60*60-1), n))
data.frame(time = t, value = rnorm(n))
}
df1 <- mk_day(60)
df2 <- transform(mk_day(80), value = value * 2 + 3)
df3 <- transform(mk_day(40), value = value * 3 + 6)
p1 <- plot_ly(df1, x = ~value, y = ~time, type = "scatter", mode = "lines+markers", name = "Serie 1")
p2 <- plot_ly(df2, x = ~value, y = ~time, type = "scatter", mode = "lines+markers", name = "Serie 2")
p3 <- plot_ly(df3, x = ~value, y = ~time, type = "scatter", mode = "lines+markers", name = "Serie 3")
fig <- subplot(p1, p2, p3, nrows = 1, shareY = TRUE, titleX = TRUE, titleY = TRUE) |>
layout(
hovermode = "y",
yaxis = list(type = "date", tickformat = "%H:%M", title = "Hora (24h)"),
yaxis2 = list(type = "date", tickformat = "%H:%M"),
yaxis3 = list(type = "date", tickformat = "%H:%M"),
margin = list(t = 60, r = 10, b = 40, l = 60)
)
fig |> onRender("
function(el, x) {
var gd = document.getElementById(el.id);
// Map subplots by X-axis only; Y is shared
var xAxes = ['x', 'x2', 'x3'];
// Helper: get x-axis name for a trace (e.g., 'x', 'x2', 'x3')
function xAxisOfTrace(tr) {
if (tr && typeof tr.xaxis === 'string') return tr.xaxis; // 'x', 'x2', 'x3'
return 'x'; // fallback
}
gd.on('plotly_hover', function(evt) {
if (!evt || !evt.points || !evt.points.length) return;
// 1) Y value under the cursor (shared Y coordinates)
var yHover = evt.points[0].y;
console.log(yHover);
// 2) Single horizontal line across ALL subplots (shared Y)
var lineShape = [{
type: 'line',
xref: 'paper', x0: 0, x1: 1,
yref: 'y', y0: yHover, y1: yHover,
line: { width: 2, dash: 'dot' }
}];
// 3) For each subplot (x, x2, x3), find the x whose y is closest to yHover
// We compute ONE annotation per subplot.
var bestByXAxis = {}; // e.g., { x: {x:..., y:..., diff:...}, x2: {...}, x3: {...} }
for (var i = 0; i < gd.data.length; i++) {
var tr = gd.data[i];
if (!tr || !tr.x || !tr.y) continue;
if (!Array.isArray(tr.x) || !Array.isArray(tr.y) || tr.x.length !== tr.y.length) continue;
var xa = xAxisOfTrace(tr); // 'x' | 'x2' | 'x3'
// Find nearest y
var bestIdx = 0, bestDiff = Infinity;
for (var k = 0; k < tr.y.length; k++) {
console.log('tr.y[k]: ' + tr.y[k] + ' | ' + 'yHover: ' + yHover + ' | ' + 'diff: ' + Math.abs(tr.y[k] - yHover));
var d = Math.abs(new Date(tr.y[k]) - new Date(yHover));
if (d < bestDiff) { bestDiff = d; bestIdx = k; }
}
var pick = { x: tr.x[bestIdx], y: tr.y[bestIdx], diff: bestDiff };
if (!bestByXAxis[xa] || pick.diff < bestByXAxis[xa].diff) {
bestByXAxis[xa] = pick;
}
}
// 4) Build dynamic annotations (always yref: 'y' because Y is shared)
var dynAnnotations = [];
xAxes.forEach(function(xa) {
var pick = bestByXAxis[xa];
if (!pick) return;
// If x are dates (POSIXct), text may need formatting on the R side; here we leave raw.
var txt = 'x = ' + pick.x + '<br>y ≈ ' + yHover;
dynAnnotations.push({
xref: xa, // 'x' | 'x2' | 'x3'
yref: 'y', // shared Y axis
x: pick.x,
y: yHover, // lock onto the horizontal guide
text: txt,
showarrow: true,
arrowhead: 2,
ax: 20, ay: -20,
bgcolor: 'rgba(255,255,255,0.85)',
bordercolor: '#333',
borderwidth: 1,
font: { size: 12, color: '#111' },
align: 'left',
name: xa + '_' + Math.random() // Fuerza actualización
});
});
// 5) Replace shapes & annotations (do NOT merge with any base annotations)
Plotly.relayout(gd, { shapes: lineShape, annotations: dynAnnotations });
});
gd.on('plotly_unhover', function() {
// Clear all dynamic overlays
Plotly.relayout(gd, { shapes: [], annotations: [] });
});
}
")
giving