4

Currently there are 2 "zooming" behaviours in Plotly.JS heatmaps:

  1. Here you can take any rectangular shape for the zoom (click, drag and drop). But then the pixels are not square, which is not ok for some applications (the aspect ratio is not preserved, and sometimes it should be preserved):

        const z = Array.from({length: 500}, () => Array.from({length: 100}, () => Math.floor(Math.random() * 255)));
        Plotly.newPlot('plot', [{type: 'heatmap', z: z}], {});
        <script src="https://cdn.plot.ly/plotly-2.16.2.min.js"></script>
        <div id="plot"></div>
  2. Here the pixels are square thanks to {'yaxis': {'scaleanchor': 'x'}}, but then you can zoom only with a certain aspect ratio rectangular shape, which is sometimes a limiting factor for the UX/UI:

        const z = Array.from({length: 500}, () => Array.from({length: 100}, () => Math.floor(Math.random() * 255)));
        Plotly.newPlot('plot', [{type: 'heatmap', z: z}], {'yaxis': {'scaleanchor': 'x'}});
        <script src="https://cdn.plot.ly/plotly-2.16.2.min.js"></script>
        <div id="plot"></div>

Question: How to have both, i.e. you can draw a rectangle selection zoom of any shape? and keep square-shape pixels? The zoomed object should be centered in the plot (with horizontal or vertical white space if needed).

DobbyTheElf
  • 604
  • 6
  • 21
Basj
  • 41,386
  • 99
  • 383
  • 673

2 Answers2

2

One way to do that is to initially set a scaleanchor constraint with the desired scaleratio, so that once the figure is plotted, we can compute the constrained zoom range ratio that produces the desired pixel to unit scaleratio without too much hassle.

Then, we can remove the constraint and attach a plotly_relayout event handler that will do the adjustments when necessary. Since those adjusments are precisely made by calling Plotly.relayout(), we prevent infinite loops with condition blocks and by considering only a reasonable amount of significant digits to compare the range ratios.

If the ratio after relayout don't match the target (contrained) ratio, we adjust it by expanding one of the axis range (rather than shrinking the other), keeping the user-created zoom window centered relative to the adjusted range.

const z = Array.from({length: 500}, () => Array.from({length: 100}, () => Math.floor(Math.random() * 255)));

const data = [{
  type: 'heatmap',
  z: z
}];

const layout = {
  xaxis: {
    constrain: 'range',
    constraintoward: 'center',
    scaleanchor: "y",
    scaleratio: 1
  }
};

Plotly.newPlot('plot', data, layout).then(afterPlot);

function afterPlot(gd) {
  // Reference each axis range
  const xrange = gd._fullLayout.xaxis.range;
  const yrange = gd._fullLayout.yaxis.range;

  // Needed when resetting scale
  const xrange_init = [...xrange];
  const yrange_init = [...yrange];

  // Compute the actual zoom range ratio that produces the desired pixel to unit scaleratio
  const zw0 = Math.abs(xrange[1] - xrange[0]);
  const zh0 = Math.abs(yrange[1] - yrange[0]);
  const r0 = Number((zw0 / zh0).toPrecision(6));

  // Now we can remove the scaleanchor constraint
  // Nb. the update object references gd._fullLayout.<x|y>axis.range
  const update = {
    'xaxis.range': xrange,
    'yaxis.range': yrange,
    'xaxis.scaleanchor': false,
    'yaxis.scaleanchor': false
  };

  Plotly.relayout(gd, update);

  // Attach the handler that will do the adjustments after relayout if needed
  gd.on('plotly_relayout', relayoutHandler);

  function relayoutHandler(e) {
    if (e.width || e.height) {
      // The layout aspect ratio probably changed, need to reapply the initial
      // scaleanchor constraint and reset variables
      return unbindAndReset(gd, relayoutHandler);
    }

    if (e['xaxis.autorange'] || e['yaxis.autorange']) {
      // Reset zoom range (dblclick or "autoscale" btn click)
      [xrange[0], xrange[1]] = xrange_init;
      [yrange[0], yrange[1]] = yrange_init;
      return Plotly.relayout(gd, update);
    }

    // Compute zoom range ratio after relayout
    const zw1 = Math.abs(xrange[1] - xrange[0]);
    const zh1 = Math.abs(yrange[1] - yrange[0]);
    const r1 = Number((zw1 / zh1).toPrecision(6));

    if (r1 === r0) {
      return; // nothing to do
    }

    // ratios don't match, expand one of the axis range as necessary

    const [xmin, xmax] = getExtremes(gd, 0, 'x');
    const [ymin, ymax] = getExtremes(gd, 0, 'y');

    if (r1 > r0) {
      const extra = (zh1 * r1/r0 - zh1) / 2;
      expandAxisRange(yrange, extra, ymin, ymax);
    }
    if (r1 < r0) {
      const extra = (zw1 * r0/r1 - zw1) / 2;
      expandAxisRange(xrange, extra, xmin, xmax);
    }

    Plotly.relayout(gd, update);
  }
}

function unbindAndReset(gd, handler) {
  gd.removeListener('plotly_relayout', handler);

  // Careful here if you want to reuse the original `layout` (eg. could be
  // that you set specific ranges initially) because it has been passed by
  // reference to newPlot() and been modified since then.
  const _layout = {
    xaxis: {scaleanchor: 'y', scaleratio: 1, autorange: true},
    yaxis: {autorange: true}
  };

  return Plotly.relayout(gd, _layout).then(afterPlot);
}

function getExtremes(gd, traceIndex, axisId) {
  const extremes = gd._fullData[traceIndex]._extremes[axisId];
  return [extremes.min[0].val, extremes.max[0].val];
}

function expandAxisRange(range, extra, min, max) {
  const reversed = range[0] > range[1];
  if (reversed) {
    [range[0], range[1]] = [range[1], range[0]];
  }
  
  let shift = 0;
  if (range[0] - extra < min) {
    const out = min - (range[0] - extra);
    const room = max - (range[1] + extra);
    shift = out <= room ? out : (out + room) / 2;
  }
  else if (range[1] + extra > max) {
    const out = range[1] + extra - max;
    const room = range[0] - extra - min;
    shift = out <= room ? -out : -(out + room) / 2;
  }

  range[0] = range[0] - extra + shift;
  range[1] = range[1] + extra + shift;

  if (reversed) {
    [range[0], range[1]] = [range[1], range[0]];
  }
}
<script src="https://cdn.plot.ly/plotly-2.22.0.min.js"></script>
<div id="plot"></div>

Nb. In the handler, except when checking if the user just reset the scale, we use references to gd._fullLayout.<x|y>axis.range rather than checking what contains e (the passed-in event object), because the references are always up-to-date and their structure never change, unlike the event parameter that only reflects what was updated. Also, because the update object itself refers these references, it allows to be a bit less verbose and just call Plotly.relayout(gd, update) after modifying the ranges.

EricLavault
  • 12,130
  • 3
  • 23
  • 45
  • Thanks! I submitted an issue about including your feature in Plotly. https://github.com/plotly/plotly.js/issues/6586 – Basj May 02 '23 at 09:23
1

You can use the layout.xaxis and layout.yaxis properties with the scaleanchor and scaleratio attributes.

Here's an example code snippet:

    const z = Array.from({length: 500}, () => Array.from({length: 100}, () => Math.floor(Math.random() * 255)));
    
    Plotly.newPlot('plot', [{type: 'heatmap', z: z}], {
      margin: {t: 50}, // Add some top margin to center the heatmap
      xaxis: { // Set the x-axis properties
        scaleanchor: 'y', // Set the scale anchor to y-axis
        scaleratio: 1, // Set the scale ratio to 1 for square pixels
      },
      yaxis: { // Set the y-axis properties
        scaleanchor: 'x', // Set the scale anchor to x-axis
        scaleratio: 1, // Set the scale ratio to 1 for square pixels
      },
      dragmode: 'select', // Enable rectangular selection zoom
    });
    
    // Update the plot when a zoom event occurs
    document.getElementById('plot').on('plotly_selected', function(eventData) {
      const xRange = eventData.range.x;
      const yRange = eventData.range.y;
    
      Plotly.relayout('plot', {
        'xaxis.range': xRange, // Update the x-axis range
        'yaxis.range': yRange, // Update the y-axis range
      });
    });
<script src="https://cdn.plot.ly/plotly-2.16.2.min.js"></script>
<div id="plot"></div>

First define the z data for the heatmap and create the plot using Plotly.newPlot. We set the xaxis and yaxis properties with the scaleanchor attribute set to the opposite axis, and the scaleratio attribute set to 1 to ensure square pixels.

We also set the dragmode property to 'select' to enable rectangular selection zoom.

Finally, we add an event listener to the plotly_selected event that updates the xaxis.range and yaxis.range properties to the selected zoom range using Plotly.relayout. This ensures that the zoomed object is centered in the plot with horizontal or vertical white space if needed.

I hope this helps.

Basj
  • 41,386
  • 99
  • 383
  • 673
Mehdi
  • 385
  • 1
  • 12
  • This works, but it recycles the rectangle selection mode (`dragmode`) into a zoom mode. Unfortunately in my application, I already use the "rectangle selection mode" for another application. Do you have an idea how to circumvent this problem? – Basj May 02 '23 at 08:49
  • Do you think there is a way to have 2 rectangular "selection mode"? One for my normal selection mode, and one for replacing the zoom feature. – Basj May 02 '23 at 09:01
  • It's possible to have multiple drag modes in Plotly, but rectangular selection is not a built-in in drag mode, so it's not possible to use it for both your normal selection mode and the zoom feature simultaneously. How about using a different dragmode? Instead of 'select', you can use another dragmode that doesn't conflict with your existing code. For example, you could use 'pan' to allow the user to drag the plot around, or 'zoom' to allow the user to zoom in and out using the mouse scroll wheel. – Mehdi May 02 '23 at 09:10
  • Thanks @mehdi, do you see a way to modify your answer to use the standard 'zoom'? – Basj May 02 '23 at 09:13
  • @Basj my apology, I have tested it Using 'zoom' as the dragmode but actually it did not work the way I thought it would. in this case, the only way would be to create a custom event listener to do the job – Mehdi May 03 '23 at 01:28