6

On a Plotly heatmap, sometimes it's useful to have 2 select modes:

  • rectangle selection (already available in the modbar)

  • selection/pinning of a single pixel: I'm trying to do it by recycling the existing "drawcircle" button which I don't need. When clicked, the pixel should be highlighted, or have a coloured disc on top of it (or a red "pin", like Google Maps UI)

Problem: when drawcircle tool is selected in the modbar, the plotly_click event is not fired (so we can't get the coordinates), and plotly_selected doesn't give the initial mouseclick position. (I don't want to make a real circle shape, I only want to use the first click). See also Event Handlers in JavaScript

const z = Array.from({
  length: 50
}, () => Array.from({
  length: 50
}, () => Math.floor(Math.random() * 255)));
const plot = document.querySelector("#plot");
const data = [{
  type: 'heatmap',
  z: z
}];
const layout = {
  'yaxis': {
    'scaleanchor': 'x'
  }
};
const config = {
  modeBarButtons: [
    ["zoom2d"],
    ["zoomIn2d"],
    ["zoomOut2d"],
    ["autoScale2d"],
    ["select2d"],
    ["drawcircle"]
  ],
  displaylogo: false,
  displayModeBar: true
};
Plotly.newPlot('plot', data, layout, config);

plot.on("plotly_selected", (data) => {
  console.log(data);
});
plot.on('plotly_click', (data) => {
  console.log(data);
});
<script src="https://cdn.plot.ly/plotly-2.16.2.min.js"></script>
<div id="plot"></div>

How to have a "select/pin a pixel/point" modebar tool on a Plotly heatmap?

Note: the Python doc is more complete than the JS version here: Add/remove modebar buttons

Basj
  • 41,386
  • 99
  • 383
  • 673
  • You might need to adjust the [`clickmode`](https://plotly.com/javascript/reference/layout/#layout-clickmode). Also you could try to add a custom modebar button with its own icon and handler (see this [post](https://stackoverflow.com/q/74533206/2529954) for a quck exmaple). – EricLavault Aug 17 '23 at 17:35
  • @EricLavault Thanks! I have tried with a custom modebar button (with its own icon+handler), but it seems that it can only trigger an action *when the button is clicked*, and *not* what we need, i.e.: click on button => button is *selected* (in darker gray) + other modebar buttons are unselected (light gray) => we can now click on the plot => another handler decides we can highlight a pixel => etc. Example: see the difference between your `Autoscale (custom)` button in https://stackoverflow.com/a/74670205 and the Zoom button. The former cannot be selected, the latter can. – Basj Aug 17 '23 at 20:17
  • @EricLavault Would you have an example of custom button that can be "selected as the currently active tool" (like Zoom, Pan, Box Select, Lasso Select)? – Basj Aug 17 '23 at 20:18
  • Yes sorry the example was not that relevant, I thought it could be useful to have a custom button that behaves like a switch to enable/disable the required behavior (or even like a rotary switch, for example to change the `clickmode`). The "currently active tool" is determined by the `dragmode`. If I understand correctly you want to have a custom modebar button that imitates the `dragmode: 'select'` ? – EricLavault Aug 18 '23 at 11:07
  • @EricLavault Yes. When we click on the custom button, this button becomes the "selected tool", and then it allows the user to click on the heatmap, and this pins a selected point/pixel of the heatmap (similar, in the principle, to: https://i.insider.com/5c9542680cf9131e9a761712?width=1000&format=jpeg&auto=webp, but with just a coloured point). If the user clicks elsewhere a new point/pixel is selected (and the previous pin is removed). – Basj Aug 18 '23 at 12:09
  • Why not using only the select2d tool for both the rectangle selection and the single pixel selection/click ? ie. by setting `dragmode: 'select', clickmode: event+select`. – EricLavault Aug 18 '23 at 12:39
  • @EricLavault Could be a good idea! So no need for a new button, the user uses the normal selectbox button, the clickmode allows the event to be fired, is that right? Then how would you draw the coloured disc: with a new extra trace, or is there an easier way to display a "point"? I remember that, when using lasso selection, there are some "points" of the shape displayed. – Basj Aug 18 '23 at 13:15
  • Yes the clickmode allows the event to be fired on click and/or select. In order to highlight the selected brick, I would rather add a shape or an annotation than a trace, but the decision would also depend on other things, like if you need to display additional data on hover for the selected brick (easier with a trace), etc. This example shows how to add an arrow pointing to a specific data point using annoations https://community.plotly.com/t/adding-arrow-at-the-end-of-trace-shape-path-how-to/2633 – EricLavault Aug 18 '23 at 13:43

3 Answers3

4

I have implemented annotation on your heatmap to make it behave like a pin as you requested on the second point. This also mentioned by @EricLavault previously on the comment.

const z = Array.from({
    length: 50
  },
  () =>
  Array.from({
      length: 50
    },
    () => Math.floor(Math.random() * 255)
  )
);
colors = ["#111111"];
const plot = document.querySelector("#plot");
const data = [{
  type: "heatmap",
  z: z,
}];
const layout = {
  yaxis: {
    scaleanchor: "x"
  }
};
const config = {
  modeBarButtons: [
    ["zoom2d"],
    ["zoomIn2d"],
    ["zoomOut2d"],
    ["autoScale2d"],
    ["select2d"],
    ["drawcircle"]
  ],
  displaylogo: false,
  displayModeBar: true
};
Plotly.newPlot("plot", data, layout, config);

plot.on("plotly_click", function(data) {
  if (plot._fullLayout.dragmode === 'select') {
    var point = data.points[0],
      newAnnotation = {
        x: point.xaxis.d2l(point.x),
        y: point.yaxis.d2l(point.y),
        arrowhead: 6,
        ax: 0,
        ay: -80,
        bgcolor: "rgba(255, 255, 255, 0.9)",
        font: {
          size: 12
        },
        borderwidth: 3,
        borderpad: 4,
        text: "<i>Clicked Item</i><br>" +
          "<b>x</b>" + point.x +
          "<br><b>y</b>" + point.y +
          "<br><b>z</b>" + point.z
      },
      divid = document.getElementById("plot"),
      newIndex = (divid.layout.annotations || []).length;
    // delete instead if clicked twice
    if (newIndex) {
      var foundCopy = false;
      divid.layout.annotations.forEach(function(ann, sameIndex) {
        if (ann.text === newAnnotation.text) {
          Plotly.relayout("plot", "annotations[" + sameIndex + "]", "remove");
          foundCopy = true;
        }
      });
      if (foundCopy) return;
    }
    Plotly.relayout("plot", "annotations[" + newIndex + "]", newAnnotation);
  }
}).on("plotly_clickannotation", function(event, data) {
  Plotly.relayout("plot", "annotations[" + data.index + "]", "remove");
});
<script src="https://cdn.plot.ly/plotly-2.16.2.min.js"></script>
<div id="plot"></div>
Raymond Natio
  • 556
  • 3
  • 22
  • Thanks, it works great! Minor edit: do you know how to add pins if and only if the currently selected tool is the BoxSelect tool? – Basj Aug 21 '23 at 12:47
  • Nice answer (I would have done nearly the same thing). You can set the currently selected tool via layout `dragmode`, so I guess you could add a condition in the handler that checks if `plot._fullLayout.dragmode === 'select'` and return early if it's not satisfied. Or bind/unbind the click handler when the dragmode changes. – EricLavault Aug 21 '23 at 16:22
  • Sure, I have updated the code snippet as per your request. Thanks @Basj! – Raymond Natio Aug 23 '23 at 16:55
2

Played around with your example and I have seen that your console.log did not provide the data as one would expect, because data is cyclic. However, since data.points has a single element, you can get the coordinates via data.points[0].x, data.points[0].y and data.points[0].z, respectively.

const z = Array.from({
  length: 50
}, () => Array.from({
  length: 50
}, () => Math.floor(Math.random() * 255)));
const plot = document.querySelector("#plot");
const data = [{
  type: 'heatmap',
  z: z
}];
const layout = {
  'yaxis': {
    'scaleanchor': 'x'
  }
};
const config = {
  modeBarButtons: [
    ["zoom2d"],
    ["zoomIn2d"],
    ["zoomOut2d"],
    ["autoScale2d"],
    ["select2d"],
    ["drawcircle"]
  ],
  displaylogo: false,
  displayModeBar: true
};
Plotly.newPlot('plot', data, layout, config);

plot.on("plotly_selected", (data) => {
  console.log(data);
});
plot.on('plotly_click', function(data) {
  console.log({x: data.points[0].x, y: data.points[0].y, z: data.points[0].z});
});
<script src="https://cdn.plot.ly/plotly-2.16.2.min.js"></script>
<div id="plot"></div>
Lajos Arpad
  • 64,414
  • 37
  • 100
  • 175
0

Here is how you can add a "pin pixel" modebar button to a Plotly heatmap plot that selects a single pixel on click:

Define a custom button handler function:

const pinPixelHandler = function(gd) {
  // Get click coordinates
  const point = gd.layout.dragboxes[0].points[0];
  
  // Highlight pixel  
  gd.data[0].z[point[1]][point[0]] = 255;

  // Update plot
  Plotly.relayout(gd, 'data', gd.data);
}

Add a custom "pin" button to the modebar config:

config = {
  modeBarButtonsToAdd: [
    {
      name: 'Pin Pixel', 
      icon: 'pin',  
      callback: pinPixelHandler
    }
  ]
}

Trigger button click on mouse click:

plot.on('plotly_click', () => {
  plot.relayout({
    'dragmode': 'lasso'  
  });
  
  plot.touch({
    dragBoxPosition: { 
      x0: data.x,
      y0: data.y
    }
  });

  plot.fire('plotly_modebarbuttonclicked', {'name': 'Pin Pixel'});
});```

This fakes a button click to call the pinPixelHandler on initial click.
Masoud
  • 146
  • 3
  • 17
  • Looks interesting @Masoud, but I haven't been able to run it. Can you edit the answer and include a runnable snippet (like in my question)? – Basj Aug 29 '23 at 13:26