Plotly: double-clicking on empty space in a zoomed-in plot selects new point(s) as it resets axes

Viewed 1411

I have a scatterplot in which points are densely clustered. After zooming in on some of them, double-clicking on empty space resets the axes, which is what I want. But in many cases, it also selects a new point -- which isn't what I want. After all, I double-clicked on empty space. I had no intention of selecting a new point.

The problem seems to be twofold. First, each double click is also registered as a single click. Second, the single click is registered at the post-axis-reset coordinates -- not the coordinates that I actually see when I double-click. And the post-axis-reset coordinates map to a plotted point, even though I am clicking on empty space when I am zoomed in. How can I fix the problem?

Here is a minimal illustration. The Plotly figure is generated from R, but that doesn't seem important:

library(plotly)
x <- c(rnorm(3000, 0, 3), rnorm(1000, 0, 0.2))
y <- c(rnorm(3000, 0, 3), rnorm(1000, 0, 0.2))
groups <- rep(c("a", "b", "c", "d"), 1000)
myData <- highlight_key( data.frame(x, y, groups), ~groups )
myPlot <- plot_ly(
  x = ~x, y = ~y,
  color = ~groups,
  data  = myData)
highlight(myPlot, color = "red")

This animated GIF shows the figure that the code creates, and it also illustrates the problem.

The problem has been noted before. But I can't seem to solve it by pre-empting the default click-to-select functionality when double-clicking, or by using any other strategy. A few things that I've tried:

  1. After a plotly_doubleclick event is triggered, alter the stored JSON data so that x > highlight > on is null. Then redraw with Plotly.newPlot().

  2. After a plotly_doubleclick event is triggered, use remove.listener() to disable plotly_click events. But by the time that plotly_doubleclick is triggered, it seems to be too late for this strategy to work: the single-click (plotly_click) event has already been triggered.

  3. Change layout > ordering from "traces first" to "layout first"

  4. Telling the plotly_click event handler to return false when a double-click is detected. (I used this method to make the plotly_click event handler detect whether the click that activated it was part of a double click.) This strategy may work for clicking on legends, but it doesn't seem to work for clicking on the plot itself.

None of this worked. But I think that there must be a solution -- is there?

1 Answers

There is a solution. It entails (a) overriding the default click behavior, and (b) supplementing the default double-click behavior. In both cases, we need to write custom event handlers.

It seems that people tend to distinguish between single and double clicks by introducing some latency to ensure that any given click is neither the first nor the last in a double click. That is reasonable, but when used in an application like this, the latency is notable: after clicking on a plotted point, there is a notable lag before the point is highlighted. The lag arises because the single-click (plotly_click) event handler is waiting to be sure that the click that triggered it isn't part of a double click.

Fortunately, we don't need to introduce that latency in this application. The key is to realize that completely distinguishing between single and double clicks is unnecessary. We need only ensure that the click that triggers plotly_click isn't the second click in a double click. Why we need to check for only this condition, I'm not sure. But it suffices, and we can check this condition without introducing any notable latency into the highlighting process.

Here is code that does the job. In R:

library(plotly)
x <- c(rnorm(3000, 0, 3), rnorm(1000, 0, 0.2))
y <- c(rnorm(3000, 0, 3), rnorm(1000, 0, 0.2))
groups <- rep(c("a", "b", "c", "d"), 1000)
myData <- data.frame(x, y, groups)
myPlot <- plot_ly(
  x = ~x, y = ~y,
  color = ~groups,
  data  = myData)
myPlot$elementId <- "myPlot"
myPlot <- highlight(myPlot, on = NULL, off = "plotly_doubleclick") 
onRender(myPlot, readLines("onRender.js"))

where "onRender.js" is

function singleClickHandler (data, el, COLORS_TRACE, OPACITY_START, OPACITY_DIM) {
  let t0 = Date.now();

  // If the triggering click wasn't the second click in a double click...
  if ((t0 - doubleClickTime) > interval) {
    highlightTrace(data, el, COLORS_TRACE, OPACITY_START, OPACITY_DIM);
  }
}


function highlightTrace (data, el, OPACITY_START, OPACITY_DIM) {
  // We want clicking on a point to "highlight" that point and all other  
  // points in the trace -- by dimming the points in all -other- traces.

  const numTraces = el.data.length;              // total # of traces in plot
  const traceNum  = data.points[0].curveNumber;  // number of clicked trace

  // Initialize array with one element for each trace
  let traceOpacity = new Array(numTraces).fill().map( () => OPACITY_DIM );

  // Set only the clicked-on trace to have normal (relatively high) opacity
  traceOpacity[traceNum] = OPACITY_START;

  // Restyle
  Plotly.restyle("myPlot", { "marker.opacity": traceOpacity } );
}


function onRender (el) {

  // Get opacity of first mark in first trace when figure is first displayed
  const OPACITY_START = el._fullData[0].marker.opacity;
  const OPACITY_DIM   = 0.2;

  // Set timing
  interval = 1000;  // two clicks within 1 second (1000 ms) is a double click
  doubleClickTime = 0;

  // Wrap the singleClickHandler() event handler in onSingleClick(). We do 
  // this so we can pass both event info ("data") and other objects to 
  // singleClickHandler(). 
  var onSingleClick = (data) => singleClickHandler(data, el, OPACITY_START, OPACITY_DIM);
  el.on('plotly_click', onSingleClick);

  el.on('plotly_doubleclick', function (d) {      
    doubleClickTime = Date.now();    
    Plotly.restyle("myPlot", { "marker.opacity": OPACITY_START } );
  });
}


onRender  

Related