Search code examples
javascriptplotlyplotly-dashheatmapplotly.js

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


Solution

  • 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
    });