Creating figure with tile and range breaks reset range
Software versions
Python version : 3.12.11 (main, Jun 12 2025, 12:44:17) [MSC v.1943 64 bit (AMD64)]
IPython version : (not installed)
Tornado version : 6.5.1
NumPy version : 1.26.4
Bokeh version : 3.7.3
BokehJS static path : C:\Users\eolfat\OneDrive - Echodyne\Desktop\sample data\.venv\Lib\site-packages\bokeh\server\static
node.js version : (not installed)
npm version : (not installed)
jupyter_bokeh version : (not installed)
Operating system : Windows-11-10.0.26100-SP0
Browser name and version
No response
Jupyter notebook / Jupyter Lab version
No response
Expected behavior
In my original code, there's an instance where I have to recreate my map figure and reapply the ranges it had prior to recreating it. This is because the user might have zoomed-in on the plot, and I want the recreated figure to show the same zoomed-in view, while still allowing the user to reset these ranges using Bokeh's reset tool (reset back to whatever ranges the figure had before zooming). My figure is created with Mercator type axes, tiles, and defined ranges.
If you look at the example code provided here: On both plots (No tile, and With tile), zooming/panning then clicking the Recreate button, then clicking the reset tool from the bokeh toolbar should reset the ranges to the original ranges (the view it started with).
Observed behavior
I realized while this series of actions (zoom -> recreate -> reset tool) works as expected on the figures without a tile provider, it does not reset to the original ranges on the plot with tile provider.
Looking at the example code I provided, the "No tile" plot works as expected. Meaning after zooming then clicking Recreate, the zoom range remains preserved with whatever way it was prior to clicking the Recreate button, and clicking the reset tool from the bokeh toolbar properly resets the ranges to the original un-zoomed ranges.
However, this does not work when the plot figure is created with tiles. If you zoom in on the "With tile" plot and then hit Recreate, the ranges do remain preserved but clicking the reset tool won't reset it back to its original ranges. In fact, if you zoom in and out from that state, clicking the reset tool will bring the ranges back to whatever it was AFTER clicking the Recreate button. This means the user has no way of going back to the original un-zoomed ranges.
As the example code shows, the only difference between how the two plots are created is that one is created with tiles. I've noticed this issue happens when the figure is created with explicit x_range and y_range, and tile is added. If tile is added to a plot that does not have explicitly defined x_range and y_range upon creation, it no longer seems to have this issue.
Example code
from bokeh.plotting import figure, curdoc
from bokeh.models import Button, Column, RadioButtonGroup, ResetTool, PanTool, Row, WheelZoomTool
# Global state
current_fig = None
main_layout = None
def create_map(with_tile=False):
"""Create mercator map"""
fig = figure(
title="No tile",
tools=[PanTool(), WheelZoomTool(), ResetTool()],
x_axis_type="mercator",
y_axis_type="mercator",
x_range=(0, 5),
y_range=(0, 5),
)
if with_tile:
fig.title.text="With tile"
fig.add_tile("ESRI WorldGrayCanvas")
fig.scatter([1,2,3,4], [1,2,3,4], size=8, alpha=0.7, color='red')
return fig
def toggle_plot(attr, old, new):
global current_fig, main_layout
current_fig = create_map(new == 1)
main_layout.children[1] = current_fig
def recreate_plot():
global current_fig, main_layout
# Store ranges
zoom_ranges = {
"x_range": (current_fig.x_range),
"y_range": (current_fig.y_range),
}
# Recreate figure
if current_fig.title.text == "No tile":
current_fig = create_map()
else:
current_fig = create_map(True)
# Restore ranges
current_fig.x_range = zoom_ranges["x_range"]
current_fig.y_range = zoom_ranges["y_range"]
main_layout.children[1] = current_fig
# Create widgets
toggle_button = RadioButtonGroup(labels=["No tile", "With tile"], active=0)
toggle_button.on_change('active', toggle_plot)
recreate_button = Button(label="Recreate")
recreate_button.on_click(recreate_plot)
# Initialize
current_fig = create_map()
main_layout = Column(Row(toggle_button, recreate_button), current_fig)
curdoc().add_root(main_layout)
Stack traceback or browser console output
No response
Screenshots
No response
I have a rough grasp of why this is happening. I am not sure it's worth trying to explain all the gory details (range handling is probably the single most complicated corner of the whole library—lots of different concerns all crash together in one complicated place).
But suffice it to say:
- this setup you are using would work with default
DataRange1dranges, however - setting e.g.
x_range=(0, 5)causes aRange1dto be added on the plot, instead of aDataRange1d
So to get the behavior you want, make sure your plots have data ranges. Here is one way:
fig = figure(
title="No tile",
tools=[PanTool(), WheelZoomTool(), ResetTool()],
x_axis_type="mercator",
y_axis_type="mercator",
)
fig.x_range.start = fig.y_range.start = 0
fig.x_range.end = fig.y_range.end = 5
That's because plots add data ranges to themselves by default, and this just updates the start/end on those data ranges. Of course, you could set x_range=DataRange1d(...) explicitly too.
In light of this, I think the only work to do here might be to add documentation support about this. I don't think making the horrendously complicated range interactions even more complex jsut to support this somewhat unusual corner case would be warranted.
cc @bokeh/dev
I see, I guess part of my confusion is why setting the ranges works as expected when there are no tiles involved but as soon as tiles are involved it messes up how it gets reset.
I tried your suggestion of explicitly setting them as x_range=DataRange1d(...) and while it did allow me to preserve the ranges like I wanted, clicking the reset tool now resets the map ranges to a view with completely weird aspect ratios (different than what it was originally rendered with). Are there transformations that happen to the ranges after tiles are added? Do you know if there's a way I could set reset ranges for DataRange1d? I am also doing all this in Panel which I'm thinking might be adding another layer of complexity to how ranges are transformed and maps are rendered.
I see, I guess part of my confusion is why setting the ranges works as expected when there are no tiles involved but as soon as tiles are involved it messes up how it gets reset.
The presence of tile-renderers triggers special codepaths for ensuring aspect-ratio preservation that do not get taken if there are no tile-renderers. I have to suppose something about this is what is preventing the reset from working the way you want. OTOH, unrelated, the presence of data ranges triggers an "update dataranges" path on reset that is not taken with plain ranges. I expect this "update dataranges" path gets the reset "working", except without the aspect preservation, so... not still really working like you'd want.
I am not sure I have any immediate good solution or workaround here, unfortunately. There's a separate option for match_aspect on the plot itself. You could try setting that to True. That might cause the "update dataranges" path to also preserve aspect the way you way want. I say "might" because you are also sharing the ranges between plots and I don't think that combination of sharing and aspect preservation has ever been explicitly considered at all.
Got it, I'll give these suggestions a try. Thank you so much for your fast responses, much appreciated!
Hey @elinaolfat, I ran into this too while working with tile-based Bokeh maps. The issue seems to stem from how BokehJS handles reset bounds when tiles are added to figures with explicitly defined x_range and y_range.
When you recreate the figure and reassign the same range objects, Bokeh doesn’t reinitialize the reset tool’s internal state. So the reset tool ends up restoring the ranges from the post-recreation state, not the original unzoomed view.
✅ Workaround that worked for me: Instead of reusing the same Range1d objects, try creating new ones with the same start/end values when recreating the figure:
`from bokeh.models import Range1d
Capture current range values
x_start = current_fig.x_range.start x_end = current_fig.x_range.end y_start = current_fig.y_range.start y_end = current_fig.y_range.end
Create new Range1d objects
new_x_range = Range1d(start=x_start, end=x_end) new_y_range = Range1d(start=y_start, end=y_end)
Pass them into your create_map function
current_fig = create_map(with_tile=True, initial_x_range=new_x_range, initial_y_range=new_y_range) `
This way, Bokeh treats the figure as fresh and the reset tool works as expected—even with tiles.
Let me know if you want a full code patch. Happy to help!