-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Description
Expected behavior:
Axis ranges update smoothly in response to scroll-zooming
Screen.Recording.2025-07-24.at.1.22.11.PM.mov
Observed behavior:
Axis ranges update erratically in response to scroll-zooming, producing particularly egregious flashing of images displayed on figures.
Screen.Recording.2025-07-24.at.1.23.14.PM.mov
Impact
Seemingly any figure that uses scroll zooming can exhibit this erratic behavior. This is of course visually distracting, but it is particulary impactful when callbacks respond to axis range changes, which are erratic. In the worst case scenario, where two figures have their axis ranges synced through callbacks (e.g., an axis range change on one figure changes the axis range on another), this erratic behavior can lead to an infinite loop that renders them useless (not reproduced here given the complexity of those visualizations).
Steps to reproduce (using plotly.py in a jupyter notebook):
import plotly.graph_objects as go
1.) Scroll zooming produces erratic behavior when axis tick labels are long
fig = go.Figure()
fig.layout.yaxis.range = (1e5, 1e5 + 5)
fig.show(config={"scrollZoom": True})
Screen.Recording.2025-07-24.at.1.16.17.PM.mov
2.) This is most visible when an image is present
Examples going forward will have an image added. While each exhibits the problematic behavior without the image, adding the image highlights it.
fig = go.Figure()
# Add images
fig.add_layout_image(
dict(
source="https://images.plot.ly/language-icons/api-home/python-logo.png",
xref="x",
yref="y",
x=0,
y=1e5+5,
sizex=2,
sizey=2,
sizing="stretch",
opacity=0.5,
layer="below")
)
fig.layout.yaxis.range = (1e5, 1e5 + 5)
fig.layout.yaxis.title = "title"
fig.show(config={"scrollZoom": True})
Screen.Recording.2025-07-24.at.1.16.52.PM.mov
3.) It happens any time that axis tick labels get long enough to move the axis title away from the plot
Here, the y axis tick labels are small initially, but upon zooming in enough to produce long y axis tick labels, the behavior reoccurs.
fig = go.FigureWidget()
# Add images
fig.add_layout_image(
dict(
source="https://images.plot.ly/language-icons/api-home/python-logo.png",
xref="x",
yref="y",
x=0,
y=3,
sizex=2,
sizey=2,
sizing="stretch",
opacity=0.5,
layer="below")
)
fig._config = {"scrollZoom": True}
fig.layout.yaxis.title = "title"
fig
Screen.Recording.2025-07-24.at.11.51.10.AM.mov
4.) Setting the margin to not auto expand solves the issue in this case...
fig = go.FigureWidget()
# Add images
fig.add_layout_image(
dict(
source="https://images.plot.ly/language-icons/api-home/python-logo.png",
xref="x",
yref="y",
x=0,
y=3,
sizex=2,
sizey=2,
sizing="stretch",
opacity=0.5,
layer="below")
)
fig._config = {"scrollZoom": True}
fig.layout.yaxis.title = "title"
fig.layout.margin.autoexpand=False
fig
Screen.Recording.2025-07-24.at.11.52.23.AM.mov
5.) ...but setting the margin to not auto expand is not a sufficient solution in all cases
Here, we add a callback that updates the figure title on a y axis range change (i.e., on a scroll zoom). The behavior persists despite the axis tick labels being very short and seems to be coincident with when the title is updated.
fig = go.FigureWidget()
# Add images
fig.add_layout_image(
dict(
source="https://images.plot.ly/language-icons/api-home/python-logo.png",
xref="x",
yref="y",
x=0,
y=3,
sizex=2,
sizey=2,
sizing="stretch",
opacity=0.5,
layer="below")
)
fig._config = {"scrollZoom": True}
def callback(event, *args):
fig.layout.title = str(fig.layout.yaxis.range)
fig.layout.on_change(callback, 'yaxis.range')
fig.layout.margin.autoexpand=False
fig
Screen.Recording.2025-07-24.at.11.54.19.AM.mov
6) Side note: in the example below, shrinking the margins exacerbates the issue...
fig = go.Figure()
# Add images
fig.add_layout_image(
dict(
source="https://images.plot.ly/language-icons/api-home/python-logo.png",
xref="x",
yref="y",
x=0,
y=1e5+5,
sizex=2,
sizey=2,
sizing="stretch",
opacity=0.5,
layer="below")
)
fig.layout.yaxis.range = (1e5, 1e5 + 5)
fig.layout.margin.update(r=0, l=0, t=0, b=0)
fig.show(config={"scrollZoom": True})
Screen.Recording.2025-07-24.at.1.18.34.PM.mov
7.) This isn't just in jupyter notebooks or plotly.py; here's some html with plotly.js you can run to reproduce the issue:
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
<style>
#plot { width: 800px; height: 600px; }
#debug { margin-top: 20px; padding: 10px; background: #f0f0f0; }
</style>
</head>
<body>
<div id="plot"></div>
<div id="debug">
<h3>Debug Info:</h3>
<div id="events"></div>
</div>
<script>
// Your JSON data
const jsonData = {
"data": [],
"layout": {
"template": {
"data": {
"histogram2dcontour": [{"type": "histogram2dcontour", "colorbar": {"outlinewidth": 0, "ticks": ""}, "colorscale": [[0.0, "#0d0887"], [0.1111111111111111, "#46039f"], [0.2222222222222222, "#7201a8"], [0.3333333333333333, "#9c179e"], [0.4444444444444444, "#bd3786"], [0.5555555555555556, "#d8576b"], [0.6666666666666666, "#ed7953"], [0.7777777777777778, "#fb9f3a"], [0.8888888888888888, "#fdca26"], [1.0, "#f0f921"]]}],
"choropleth": [{"type": "choropleth", "colorbar": {"outlinewidth": 0, "ticks": ""}}],
"histogram2d": [{"type": "histogram2d", "colorbar": {"outlinewidth": 0, "ticks": ""}, "colorscale": [[0.0, "#0d0887"], [0.1111111111111111, "#46039f"], [0.2222222222222222, "#7201a8"], [0.3333333333333333, "#9c179e"], [0.4444444444444444, "#bd3786"], [0.5555555555555556, "#d8576b"], [0.6666666666666666, "#ed7953"], [0.7777777777777778, "#fb9f3a"], [0.8888888888888888, "#fdca26"], [1.0, "#f0f921"]]}],
"heatmap": [{"type": "heatmap", "colorbar": {"outlinewidth": 0, "ticks": ""}, "colorscale": [[0.0, "#0d0887"], [0.1111111111111111, "#46039f"], [0.2222222222222222, "#7201a8"], [0.3333333333333333, "#9c179e"], [0.4444444444444444, "#bd3786"], [0.5555555555555556, "#d8576b"], [0.6666666666666666, "#ed7953"], [0.7777777777777778, "#fb9f3a"], [0.8888888888888888, "#fdca26"], [1.0, "#f0f921"]]}],
"contourcarpet": [{"type": "contourcarpet", "colorbar": {"outlinewidth": 0, "ticks": ""}}],
"contour": [{"type": "contour", "colorbar": {"outlinewidth": 0, "ticks": ""}, "colorscale": [[0.0, "#0d0887"], [0.1111111111111111, "#46039f"], [0.2222222222222222, "#7201a8"], [0.3333333333333333, "#9c179e"], [0.4444444444444444, "#bd3786"], [0.5555555555555556, "#d8576b"], [0.6666666666666666, "#ed7953"], [0.7777777777777778, "#fb9f3a"], [0.8888888888888888, "#fdca26"], [1.0, "#f0f921"]]}],
"surface": [{"type": "surface", "colorbar": {"outlinewidth": 0, "ticks": ""}, "colorscale": [[0.0, "#0d0887"], [0.1111111111111111, "#46039f"], [0.2222222222222222, "#7201a8"], [0.3333333333333333, "#9c179e"], [0.4444444444444444, "#bd3786"], [0.5555555555555556, "#d8576b"], [0.6666666666666666, "#ed7953"], [0.7777777777777778, "#fb9f3a"], [0.8888888888888888, "#fdca26"], [1.0, "#f0f921"]]}],
"mesh3d": [{"type": "mesh3d", "colorbar": {"outlinewidth": 0, "ticks": ""}}],
"scatter": [{"fillpattern": {"fillmode": "overlay", "size": 10, "solidity": 0.2}, "type": "scatter"}],
"parcoords": [{"type": "parcoords", "line": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
"scatterpolargl": [{"type": "scatterpolargl", "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
"bar": [{"error_x": {"color": "#2a3f5f"}, "error_y": {"color": "#2a3f5f"}, "marker": {"line": {"color": "#E5ECF6", "width": 0.5}, "pattern": {"fillmode": "overlay", "size": 10, "solidity": 0.2}}, "type": "bar"}],
"scattergeo": [{"type": "scattergeo", "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
"scatterpolar": [{"type": "scatterpolar", "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
"histogram": [{"marker": {"pattern": {"fillmode": "overlay", "size": 10, "solidity": 0.2}}, "type": "histogram"}],
"scattergl": [{"type": "scattergl", "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
"scatter3d": [{"type": "scatter3d", "line": {"colorbar": {"outlinewidth": 0, "ticks": ""}}, "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
"scattermap": [{"type": "scattermap", "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
"scattermapbox": [{"type": "scattermapbox", "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
"scatterternary": [{"type": "scatterternary", "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
"scattercarpet": [{"type": "scattercarpet", "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
"carpet": [{"aaxis": {"endlinecolor": "#2a3f5f", "gridcolor": "white", "linecolor": "white", "minorgridcolor": "white", "startlinecolor": "#2a3f5f"}, "baxis": {"endlinecolor": "#2a3f5f", "gridcolor": "white", "linecolor": "white", "minorgridcolor": "white", "startlinecolor": "#2a3f5f"}, "type": "carpet"}],
"table": [{"cells": {"fill": {"color": "#EBF0F8"}, "line": {"color": "white"}}, "header": {"fill": {"color": "#C8D4E3"}, "line": {"color": "white"}}, "type": "table"}],
"barpolar": [{"marker": {"line": {"color": "#E5ECF6", "width": 0.5}, "pattern": {"fillmode": "overlay", "size": 10, "solidity": 0.2}}, "type": "barpolar"}],
"pie": [{"automargin": true, "type": "pie"}]
},
"layout": {
"autotypenumbers": "strict",
"colorway": ["#636efa", "#EF553B", "#00cc96", "#ab63fa", "#FFA15A", "#19d3f3", "#FF6692", "#B6E880", "#FF97FF", "#FECB52"],
"font": {"color": "#2a3f5f"},
"hovermode": "closest",
"hoverlabel": {"align": "left"},
"paper_bgcolor": "white",
"plot_bgcolor": "#E5ECF6",
"polar": {"bgcolor": "#E5ECF6", "angularaxis": {"gridcolor": "white", "linecolor": "white", "ticks": ""}, "radialaxis": {"gridcolor": "white", "linecolor": "white", "ticks": ""}},
"ternary": {"bgcolor": "#E5ECF6", "aaxis": {"gridcolor": "white", "linecolor": "white", "ticks": ""}, "baxis": {"gridcolor": "white", "linecolor": "white", "ticks": ""}, "caxis": {"gridcolor": "white", "linecolor": "white", "ticks": ""}},
"coloraxis": {"colorbar": {"outlinewidth": 0, "ticks": ""}},
"colorscale": {
"sequential": [[0.0, "#0d0887"], [0.1111111111111111, "#46039f"], [0.2222222222222222, "#7201a8"], [0.3333333333333333, "#9c179e"], [0.4444444444444444, "#bd3786"], [0.5555555555555556, "#d8576b"], [0.6666666666666666, "#ed7953"], [0.7777777777777778, "#fb9f3a"], [0.8888888888888888, "#fdca26"], [1.0, "#f0f921"]],
"sequentialminus": [[0.0, "#0d0887"], [0.1111111111111111, "#46039f"], [0.2222222222222222, "#7201a8"], [0.3333333333333333, "#9c179e"], [0.4444444444444444, "#bd3786"], [0.5555555555555556, "#d8576b"], [0.6666666666666666, "#ed7953"], [0.7777777777777778, "#fb9f3a"], [0.8888888888888888, "#fdca26"], [1.0, "#f0f921"]],
"diverging": [[0, "#8e0152"], [0.1, "#c51b7d"], [0.2, "#de77ae"], [0.3, "#f1b6da"], [0.4, "#fde0ef"], [0.5, "#f7f7f7"], [0.6, "#e6f5d0"], [0.7, "#b8e186"], [0.8, "#7fbc41"], [0.9, "#4d9221"], [1, "#276419"]]
},
"xaxis": {"gridcolor": "white", "linecolor": "white", "ticks": "", "title": {"standoff": 15}, "zerolinecolor": "white", "automargin": true, "zerolinewidth": 2},
"yaxis": {"gridcolor": "white", "linecolor": "white", "ticks": "", "title": {"standoff": 15}, "zerolinecolor": "white", "automargin": true, "zerolinewidth": 2},
"scene": {
"xaxis": {"backgroundcolor": "#E5ECF6", "gridcolor": "white", "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white", "gridwidth": 2},
"yaxis": {"backgroundcolor": "#E5ECF6", "gridcolor": "white", "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white", "gridwidth": 2},
"zaxis": {"backgroundcolor": "#E5ECF6", "gridcolor": "white", "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white", "gridwidth": 2}
},
"shapedefaults": {"line": {"color": "#2a3f5f"}},
"annotationdefaults": {"arrowcolor": "#2a3f5f", "arrowhead": 0, "arrowwidth": 1},
"geo": {"bgcolor": "white", "landcolor": "#E5ECF6", "subunitcolor": "white", "showland": true, "showlakes": true, "lakecolor": "white"},
"title": {"x": 0.05},
"mapbox": {"style": "light"}
}
},
"images": [{"layer": "below", "opacity": 0.5, "sizex": 2, "sizey": 2, "sizing": "stretch", "source": "https://images.plot.ly/language-icons/api-home/python-logo.png", "x": 0, "xref": "x", "y": 100000.0, "yref": "y"}],
"yaxis": {"range": [99995.0, 100000.0], "title": {"text": "title"}}
}
};
// Create the plot
Plotly.newPlot('plot', jsonData.data, jsonData.layout, {scrollZoom: true});
// Comprehensive event logging
const plotDiv = document.getElementById('plot');
plotDiv.on('plotly_relayout', function(eventData) {
logEvent('Relayout', eventData);
});
plotDiv.on('plotly_zoom', function(eventData) {
logEvent('Zoom', eventData);
});
plotDiv.on('plotly_pan', function(eventData) {
logEvent('Pan', eventData);
});
function logEvent(type, data) {
const eventsDiv = document.getElementById('events');
const timestamp = new Date().toLocaleTimeString();
eventsDiv.innerHTML += `<p><strong>${timestamp} - ${type}:</strong> ${JSON.stringify(data)}</p>`;
eventsDiv.scrollTop = eventsDiv.scrollHeight;
}
</script>
</body>
</html>
We're doing some really cool work syncing multiple figures while displaying images, for which zoom-scrolling is the best way to interact with the figure. However, the behavior described above is proving to be very limiting, and I suspect it is impacting others as well.
Happy to provide more context or test out potential solutions :)