在 Zoom on a Plotly heatmap 中,接受的答案提供了一种缩放热图的方法:
如果我们有一个包含 2 层的 Plotly.js 图:1 个“热图”和 1 个“图像”,这将停止工作:在下面的代码片段中,缩放功能卡在热图的原始长宽比上,我不这样做想要:
如何允许自由矩形缩放?
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>
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>
文档指出:
默认情况下,当图像显示在子图中时,其 y 轴将反转, 受限于域,它将具有与其 x 相同的比例 轴(即它没有提到的是
scaleanchor: 'x'
+scaleratio: 1
)以便将像素渲染为正方形。
我们无法覆盖此行为。无论布局更新期间轴缩放锚点的实际值如何,都会对图像轨迹应用 'yaxis.scaleanchor': 'x'
约束。因此,在将
'yaxis.scaleanchor': false
添加到 update
对象(对于 x 轴)后,来自Zoom on a Plotly heatmap
答案的代码可以工作,但事实并非如此。
[更新]:我做了一个 PR 来修复这个问题,由于 Plotly 团队的积极响应,该问题已被快速合并。因此,从plotly-2.26.0 开始,不再需要选项 1 和 2,设置 'yaxis.scaleanchor': false
来删除图像跟踪上的 y 轴约束现在可以正常工作。请参阅下面的选项 0。注意。无论您选择何种选项来规避该问题:
autorange: 'reversed'
正常工作,现已修复(请参阅下面的diff 和/或完整示例代码)。
constrain
),这是默认值 除了包含图像轨迹的轴,其中默认值是减小“域”,因此您需要在约束轴上显式设置 constrain: "range"
。
选项 0 - Plotly 版本 >= 2.26.0 :
来自Zoom on a Plotly heatmap的代码现在可以正常用于热图和图像轨迹。只需确保在 yaxis: { scaleanchor: false }
处理程序的
update
对象中设置
afterPlot
即可。
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>
选项 1 - 布局图像 :
我对布局图像的看法是错误的:“它不会有用户交互(悬停、平移、缩放等),因此您必须手动应用它们(至少是缩放+平移)”。
事实上,只要图像设置为xref: 'x'
和
yref: 'y'
(即指热图轴 id),缩放和平移就可以与热图轨迹的缩放和平移同步。关于悬停交互,我们无法从布局图像中获得悬停事件,但我们仍然可以通过其
customdata
属性将一些图像数据添加到热图跟踪中,并使用
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>
选项 2 - 补丁 :
涉及的代码非常小并且易于修补。有一个标志,hasImage
(定义于
here),用于确定是否正在绘制图像轨迹,因此无论实际轴参数如何,都应该应用约束。将其设置为
false
: handleOneAxDefaults(axIn, axOut, {
axIds: axIds,
layoutOut: layoutOut,
hasImage: false // axHasImage[axName]
});
由于这部分代码未注入全局范围(即加载 Plotly 后不可能覆盖它),因此您必须在源代码上应用补丁并从中构建您自己的自定义包(先决条件:git、node.js、npm),顺便说一句,这也可能是一个通过仅包含您需要的跟踪类型来减少包大小的机会,并最终将其托管在某个地方。