3

In Zoom on a Plotly heatmap, the accepted answer provides a way to zoom on a heatmap that:

  • has squared pixels (required)
  • allows any rectangular-shaped zoom (and not stuck on the heatmap's aspect ratio).

This stops working if we have a Plotly.js plot with 2 layers : 1 "heatmap" and 1 "image": in the following snippet, the zooming feature is stuck on the heatmap's original aspect ratio, which I don't want:

enter image description here

How to allow free rectangular shape zoom instead?

const z = Array.from({ length: 50 }, () => Array.from({ length: 20 }, () => Math.floor(Math.random() * 255)));
const z2 = [[[255, 0, 0], [0, 255, 255]], [[0, 0, 255], [255, 0, 255]]];
const data = [{ type: "image", z: z2 }, { type: "heatmap", z: z, opacity: 0.3 }];
const layout = { xaxis: { scaleanchor: "y", scaleratio: 1 } };
Plotly.newPlot("plot", data, layout).then(afterPlot);
function afterPlot(gd) {
    const xrange = gd._fullLayout.xaxis.range;
    const yrange = gd._fullLayout.yaxis.range;
    const xrange_init = [...xrange];
    const yrange_init = [...yrange];
    const zw0 = xrange[1] - xrange[0];
    const zh0 = yrange[1] - yrange[0];
    const r0 = Number((zw0 / zh0).toPrecision(6));
    const update = { "xaxis.range": xrange, "yaxis.range": yrange, "xaxis.scaleanchor": false };
    Plotly.relayout(gd, update);
    gd.on("plotly_relayout", relayoutHandler);
    function relayoutHandler(e) {
        if (e.width || e.height) {
            return unbindAndReset(gd, relayoutHandler);
        }
        if (e["xaxis.autorange"] || e["yaxis.autorange"]) {
            [xrange[0], xrange[1]] = xrange_init;
            [yrange[0], yrange[1]] = yrange_init;
            return Plotly.relayout(gd, update);
        }
        const zw1 = xrange[1] - xrange[0];          
        const zh1 = yrange[1] - yrange[0];
        const r1 = Number((zw1 / zh1).toPrecision(6));
        if (r1 === r0) {
            return;
        }
        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); 
    return Plotly.relayout(gd, { xaxis: { scaleanchor: "y", scaleratio: 1, autorange: true }, yaxis: { autorange: true } }).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) {
    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;
}
<script src="https://cdn.plot.ly/plotly-2.22.0.min.js"></script>
<div id="plot"></div>

Edit: update with 2.26.0:

const z = Array.from({ length: 50 }, () => Array.from({ length: 20 }, () => Math.floor(Math.random() * 255)));
const z2 = [[[255, 0, 0], [0, 255, 255]], [[0, 0, 255], [255, 0, 255]]];
const data = [{ type: "image", z: z2 }, { type: "heatmap", z: z, opacity: 0.3 }];
const layout = { xaxis: { scaleanchor: "y", scaleratio: 1 }, yaxis: { scaleanchor: false } };
Plotly.newPlot("plot", data, layout).then(afterPlot);
function afterPlot(gd) {
    const xrange = gd._fullLayout.xaxis.range;
    const yrange = gd._fullLayout.yaxis.range;
    const xrange_init = [...xrange];
    const yrange_init = [...yrange];
    const zw0 = xrange[1] - xrange[0];
    const zh0 = yrange[1] - yrange[0];
    const r0 = Number((zw0 / zh0).toPrecision(6));
    const update = { "xaxis.range": xrange, "yaxis.range": yrange, "xaxis.scaleanchor": false };
    Plotly.relayout(gd, update);
    gd.on("plotly_relayout", relayoutHandler);
    function relayoutHandler(e) {
        if (e.width || e.height) {
            return unbindAndReset(gd, relayoutHandler);
        }
        if (e["xaxis.autorange"] || e["yaxis.autorange"]) {
            [xrange[0], xrange[1]] = xrange_init;
            [yrange[0], yrange[1]] = yrange_init;
            return Plotly.relayout(gd, update);
        }
        const zw1 = xrange[1] - xrange[0];          
        const zh1 = yrange[1] - yrange[0];
        const r1 = Number((zw1 / zh1).toPrecision(6));
        if (r1 === r0) {
            return;
        }
        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); 
    return Plotly.relayout(gd, { xaxis: { scaleanchor: "y", scaleratio: 1, autorange: true }, yaxis: { autorange: true } }).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) {
    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;
}
<script src="https://cdn.plot.ly/plotly-2.26.0.min.js"></script>
<div id="plot"></div>
Basj
  • 41,386
  • 99
  • 383
  • 673
  • You can't. It's just that you _can't_ display an image trace without the scaleanchor/ratio 1:1 constrain,as mentioned in the doc : "_when an image is displayed in a subplot, its y axis will be reversed, constrained to the domain and it will have the same scale as its x axis (ie. `scaleanchor: 'x,`) in order for pixels to be rendered as squares._". I thought it could be overridden by adding `'yaxis.scaleanchor': false` in the layout updates, but it's not taken into account : the `'yaxis.scaleanchor': 'x'` is somehow hardcoded internally, precisely because an image trace is displayed. – EricLavault Aug 08 '23 at 14:37
  • Thanks @EricLavault. Oops it will be a big problem for me, because in my real situation, 1) I have 2 layers: one image layer, and one heatmap layer. 2) in some cases the width is very small and the height big, like here: https://gget.it/2xg1/test.html So if it's impossible to zoom like you dit it perfectly here https://stackoverflow.com/questions/75792497/zoom-on-a-plotly-heatmap/76142244#76142244 it would be really problematic for my application :) Scratching my head :) – Basj Aug 08 '23 at 14:57
  • Why not using a heatmap instead ? You would need to convert the image data to a single channel array (for example, converting [[ [r,g,b], ...]] to [[ grayscale, ...]], gray values would be mapped to a color according to the colorscale, if you need to preserve rgb colors though, it would be more complicated). – EricLavault Aug 08 '23 at 15:28
  • @EricLavault Yes, the image layer should still be in RGB color, so that's complicated... – Basj Aug 08 '23 at 18:53
  • It depends on the image itself, what is it supposed to represent ? Can you explain a bit what are you trying to achieve ? – EricLavault Aug 09 '23 at 12:50
  • Yes @EricLavault: the image itself is a color photo of the surface of a material, and the heatmap is data collected on each point of this material (on a grid with a given resolution). Sometimes it's useful to only display the heatmap, sometimes it's useful to also display the original photo image (thus the requirement of 2 layers with variable opacity / slider, you probably remember my previous posts). – Basj Aug 09 '23 at 16:14
  • Ok so yes one option is to use a grayscaled image and display it as a heatmap (ie. separate coloraxis with the grayscales 1to1), not ideal but simple. You could also explore the option of using a layout image but it won't have user interactions (hover, pan, zoom, etc. ) so you would have to apply them manually (at least zoom+pan) in sync with those of the heatmap.. Or, it might be just simpler to apply a patch on top of your plotly dist, assuming that there is a simple way to remove that scaleanchor constrain from image traces. – EricLavault Aug 09 '23 at 17:39
  • @EricLavault I'm a bit clueless for now. `One option is to use a grayscaled image and display it as a heatmap`: then we can't have a color image layer? This is not really possible in the application. Side-remark: what prevents your great method from https://stackoverflow.com/a/76142244 to work in the case here where we have 1 image layer + 1 heatmap layer? What exactly makes it non-working? – Basj Aug 13 '23 at 20:45
  • See my first comment, there is a `'yaxis.scaleanchor': 'x'` constrain applied by default to image traces, the code from previous answer _should work_ just by adding `'yaxis.scaleanchor': false` to the `update` object, as for the xaxis, but it doesn't work : it appears that the constrain is _always_ applied on image traces whatever the value of the axes scaleanchor in during layout updates. – EricLavault Aug 14 '23 at 14:25

1 Answers1

2

The documentation states :

By default, when an image is displayed in a subplot, its yaxis will be reversed, constrained to the domain and it will have the same scale as its x axis (ie. scaleanchor: 'x' + scaleratio: 1 ) in order for pixels to be rendered as squares.

What it doesn't mention is we can't override this behavior. There is a 'yaxis.scaleanchor': 'x' constrain applied on image traces whatever the actual value of the axes scaleanchor during layout updates. So, the code from Zoom on a Plotly heatmap answer could work after adding 'yaxis.scaleanchor': false to the update object (as for the xaxis), but it doesn't.

[Update]: I made a PR to fix this which has been merged quickly, thanks to the Plotly team responsiveness. So as of plotly-2.26.0, options 1 and 2 are not needed anymore, setting 'yaxis.scaleanchor': false to remove the yaxis constrain on image trace now works properly. See Option 0 below.

NB. whatever option you choose to circumvent the issue :

  • the original code was not working properly with autorange: 'reversed' which is set by default on the yaxis for image traces, this is now fixed (see the diff and/or the full example code below).
  • the code assumes that the initial constrain is applied by increasing the axis "range" (see constrain), which is the default except for axes containing image traces where the default is to decrease the "domain", so you will need to explicitly set constrain: "range" on the constrained axis.

Option 0 - Plotly version >= 2.26.0 :

The code from Zoom on a Plotly heatmap now works properly for both heatmap and image traces. Just ensure you set yaxis: { scaleanchor: false } in the update object of the afterPlot handler.

const z = Array.from({ length: 50 }, () => Array.from({ length: 20 }, () => Math.floor(Math.random() * 255)));
const z2 = [[[255, 0, 0], [0, 255, 255]], [[0, 0, 255], [255, 0, 255]]];

const data = [{ 
  type: "image", 
  z: z2 
}, { 
  type: "heatmap", 
  z: z, 
  opacity: 0.3 
}];

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

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.26.0.min.js"></script>
<div id="plot"></div>

Option 1 - Layout image :

I was wrong about layout images : "it won't have user interactions (hover, pan, zoom, etc. ) so you would have to apply them manually (at least zoom+pan)".

In fact zooming and panning can be synced with those of the heatmap trace as long as the image is set with xref: 'x' and yref: 'y' (ie. referring to the heatmap axes id). Regarding hover interactions, we can't have hover events from layout images but we can still add some image data to the heatmap trace's via its customdata attribute and show these customdata on hover using hovertemplate.

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

const z2 = [
  [[255, 0, 0, 255], [0, 255, 255, 255], [255, 255, 0, 255]],
  [[0, 0, 255, 255], [255, 0, 255, 255], [0, 255, 0, 255]]
];

const imgH = z2.length;
const imgW = z2[0].length;
const imgURL = imgToDataURL(z2);

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

const layout = {
  xaxis:  {
    constrain: 'range',
    constraintoward: 'center',
    scaleanchor: 'y',
    scaleratio: 1,
    zeroline: false,
    showgrid: false,
  },
  yaxis: {
    showgrid: false,
    zeroline: false,
    autorange: 'reversed'
  },
  images: [{
    source: imgURL,
    layer: 'below',
    xref: 'x',
    yref: 'y',
    x: -0.5,
    y: -0.5,
    sizex: imgW,
    sizey: imgH,
    xanchor: 'left',
    yanchor: 'top'
  }]
};

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

// Expects a 2d array of [r, g, b, a] values
function imgToDataURL(z) {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  canvas.height = z.length;
  canvas.width = z[0].length;

  const imageData = ctx.createImageData(canvas.width, canvas.height);
  const pixels = new Uint8ClampedArray(z.flat(2));
  imageData.data.set(pixels);
  ctx.putImageData(imageData, 0, 0);

  return canvas.toDataURL();
}

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]];
  }
}
.layer-subplot .imagelayer image {
  image-rendering: pixelated;
}
<script src='https://cdn.plot.ly/plotly-2.22.0.min.js'></script>
<div id='plot'></div>

Option 2 - Patch :

The involved code is pretty small and easy to patch. There is this flag, hasImage (defined here), which is used to determine whether or not an image trace is being plotted and thus should have the constrain applied regardless of the actual axes parameters. Set it to false :

        handleOneAxDefaults(axIn, axOut, {
            axIds: axIds,
            layoutOut: layoutOut,
            hasImage: false // axHasImage[axName]
        });

Since this portion of the code is not injected into the global scope (ie. it's not possible to just override it after loading Plotly), you would have to apply the patch on the source code and build your own custom bundle from it (prerequisites: git, node.js, npm), which by the way could also be an opportunity to reduce the bundle size by including only the trace types you need, and finally host it somewhere.

EricLavault
  • 12,130
  • 3
  • 23
  • 45
  • Wonderful, thanks! I'm going to study this tomorrow, thanks a lot already for the answer and the code! PS: for Option 1, do I have to modify/patch the original `plotly.js` distribution? Or can overriding `handleOneAxDefaults` be done *after* `plotly.js` is loaded with ``? – Basj Aug 16 '23 at 06:38
  • Yes you would have to patch and build your own custom plotly.js dist, I just edited the answer to make it clearer. Though I finally made the patch method the option 2 since my suggestion is to prefer using layout image as a "first" option. – EricLavault Aug 16 '23 at 17:24
  • Thank you once again @EricLavault! 1/2: Do you think there is a chance that "Option 2 - Patch" could be merged into the official Plotly.js distribution one day (I can post an issue with a link to your solution), maybe with an additional parameter `forceHasImage`? It would be a great idea to include your fix in the official dist. – Basj Aug 16 '23 at 19:49
  • 2/2: Do you think your `Option 1` will stlil work with the opacity slider between `image` and `heatmap` (see one of my previous questions that you answered)? – Basj Aug 16 '23 at 19:50
  • 1. Maybe not _that_ patch specifically (ie. `hasImage: false`), but yes I think users should be able to remove the scaleanchor constrain applied by default on axes having an image trace, but they can't : setting `scaleanchor: false` on either or both axes has no effect (seems we can change the scaleratio though), which is an issue that could be addressed. 2. Yes absolutely. – EricLavault Aug 17 '23 at 11:22
  • Thanks @EricLavault for the new feature `scaleanchor: false` in 2.26.0! I tried an update, see the edit at the end of this question, I included a runnable snippet, using 2.26.0. I used `const layout = { xaxis: { scaleanchor: "y", scaleratio: 1 }, yaxis: { scaleanchor: false } };`, but problem: it doesn't keep square pixels. Do you see what to modify here? – Basj Aug 29 '23 at 13:13
  • 1
    You need to set `yaxis: { scaleanchor: false }` in the `update` object of the `afterPlot` handler, not in the initial layout settings. See revised answer. – EricLavault Aug 29 '23 at 13:29
  • Thanks @EricLavault. Your updated answer is for Option 1 which already worked before with your workaround of replacing `[{ type: "image", z: z2 }, { type: "heatmap", z: z, opacity: 0.3 }];` with `heatmap` + `images`. – Basj Aug 29 '23 at 14:04
  • I thought that the new `scaleanchor: false` feature would allow to keep my data as `[{ type: "image", z: z2 }, { type: "heatmap", z: z, opacity: 0.3 }];` and "just" replace the `hasImage: false` patch (Option 2) by a modification involving `scaleanchor: false`. Is that possible? Do you think it's possible to keep my code snippet from my question (after `Edit: update with 2.26.0:`) and just add the `scaleanchor: false` feature in it @EricLavault? – Basj Aug 29 '23 at 14:08
  • 1
    Sorry I messed up with option 1.. I rewrote the answer to make things a bit clearer, adding option 0 with full example. The thing to note is that we need to explicitly set `constrain: "range"` on the constrained axis, because axes having an image trace are by default constrained by "domain" (instead of "range" otherwise, another specificity of plot having an image..), and the code that allows to have a "free" zoom box works only with "range" constrain. – EricLavault Aug 29 '23 at 14:51
  • Thanks a lot again @EricLavault. Option 0 is perfect now! – Basj Aug 30 '23 at 08:13