Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Zoom on a Plotly heatmap

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).

like image 954
Basj Avatar asked Oct 20 '25 05:10

Basj


2 Answers

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.

If you need to update the x|y axis range programmatically, you will need to specify them as follows to prevent losing the reference in the code above :

// Instead of 
// Plotly.relayout(gd, {'xaxis.range': [xmin, xmax], 'yaxis.range': [ymin, ymax]});

Plotly.relayout(gd, {
  'xaxis.range[0]': xmin, 'xaxis.range[1]': xmax,
  'yaxis.range[0]': ymin, 'yaxis.range[1]': ymax
});
like image 164
EricLavault Avatar answered Oct 21 '25 19:10

EricLavault


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.

like image 35
Mehdi Avatar answered Oct 21 '25 17:10

Mehdi