javascriptrplotlyvisualizationhtmlwidgets

Plotly R: Annotations showing fixed X values on hover despite recalculating closest points across subplots


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:

  1. Draws a horizontal dashed line across all subplots at the cursor's Y position
  2. For each subplot, finds the closest point to the hovered Y value
  3. Displays an annotation showing the X and Y values of that closest point

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:

Reproducible Example

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


Solution

  • 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

    res Increased font size for the video