Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b9c7ec4
Implement single axis zoom
lucasgruwez Sep 13, 2025
fba4e59
Add whats-new
lucasgruwez Sep 13, 2025
a93ddba
Tune algo to reduce flicker
lucasgruwez Sep 13, 2025
9dbce1c
Whiskers on single-axis zoom PyQt
lucasgruwez Sep 14, 2025
c379571
Explicitly set black rectangle color
lucasgruwez Sep 14, 2025
961028e
Whiskers on single-axis zoom plot Tk backend
lucasgruwez Sep 14, 2025
5d1c8f8
Ensure whiskers are drawn over rubber band
lucasgruwez Sep 14, 2025
a7ea80c
Whiskers for single axis zoom gtk and wx
lucasgruwez Sep 14, 2025
8dda19a
Update lib/matplotlib/backend_bases.py
lucasgruwez Sep 15, 2025
b4cdbd3
MacOS whisker implementation
lucasgruwez Sep 15, 2025
637de38
Merge branch 'feature/single-axis-zoom' of github.com:lucasgruwez/mat…
lucasgruwez Sep 15, 2025
9383308
Increase whisker size slightly
lucasgruwez Sep 15, 2025
4f6a4eb
WebAgg Implementation
lucasgruwez Sep 15, 2025
847ece6
Minor bufixes
lucasgruwez Sep 15, 2025
8503cf1
GTK fixes
lucasgruwez Sep 15, 2025
a34232e
Merge remote-tracking branch 'upstream/main' into feature/single-axis…
lucasgruwez Aug 19, 2026
63e8956
Fix wx backend lint
lucasgruwez Aug 19, 2026
f945db3
Keep zoom whiskers optional for backends
lucasgruwez Aug 19, 2026
7b452a8
Gate WebAgg zoom whiskers by client support
lucasgruwez Aug 19, 2026
ffbbe58
Document optional zoom whisker API
lucasgruwez Aug 19, 2026
0b45eee
Make zoom whiskers visible on any background
lucasgruwez Aug 19, 2026
5dcad2b
Fix zoom whisker cleanup
lucasgruwez Aug 19, 2026
d7fc312
Coalesce WebAgg zoom overlay draws
lucasgruwez Aug 19, 2026
510091a
Fix zoom whisker documentation references
lucasgruwez Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions doc/api/next_api_changes/development/30554_REC.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Single-axis zoom indicators are optional for backends
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

`~matplotlib.backend_bases.NavigationToolbar2.draw_whiskers` and
`~matplotlib.backend_bases.NavigationToolbar2.remove_whiskers` are optional
backend API methods for drawing the single-axis zoom indicator. Their
default implementations do nothing, so third-party backends continue to zoom
without displaying the indicator.

WebAgg clients must advertise the ``supports_zoom_whiskers`` capability to
receive ``whiskers`` events.
6 changes: 6 additions & 0 deletions doc/release/next_whats_new/single_axis_zoom.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Single Axis Zoom
----------------

Zooming in a single axis (horizontal or vertical) can be done by dragging the
zoom rectangle in one direction only. Backends may optionally mark the active
single-axis region; other backends retain the standard zoom rectangle.
7 changes: 7 additions & 0 deletions galleries/users_explain/figure/backends.rst
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,13 @@ The following backend API versions exist
*hatchcolor*. The presence of the parameter is inferred by introspection, so
that matplotlib 3.11+ will still work with backends implementing API version
1.0.
* - 1.2
- Matplotlib 3.12
- `~matplotlib.backend_bases.NavigationToolbar2.draw_whiskers` and
`~matplotlib.backend_bases.NavigationToolbar2.remove_whiskers` optionally draw
the single-axis zoom
indicator. Backends implementing older versions continue to work without the
indicator.

There is currently no plan to remove support for older API versions.

Expand Down
44 changes: 37 additions & 7 deletions lib/matplotlib/backend_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -2942,6 +2942,10 @@ class NavigationToolbar2:
:meth:`draw_rubberband` (optional)
Draw the zoom to rect "rubberband" rectangle.

:meth:`draw_whiskers` (optional)
Draw the single-axis zoom indicator. Backends that do not override
this method retain the standard rubberband without an indicator.

:meth:`set_message` (optional)
Display message.

Expand Down Expand Up @@ -3014,6 +3018,17 @@ def draw_rubberband(self, event, x0, y0, x1, y1):
def remove_rubberband(self):
"""Remove the rubberband."""

def draw_whiskers(self, event, x0, y0, x1, y1, ws):
"""
Draw line with whiskers to indicate single axis zoom

We expect that ``x0 == x1`` or ``y0 == y1``. Else nothing will draw
*ws* is the whisker size in pixels.
"""

def remove_whiskers(self):
"""Remove the whiskers."""

def home(self, *args):
"""
Restore the original view.
Expand Down Expand Up @@ -3307,7 +3322,20 @@ def drag_zoom(self, event):
elif key == "y":
x1, x2 = ax.bbox.intervalx

# Single-axis zooms by moving less than 15 pixels
if (abs(event.x - start_xy[0]) < 15) and (abs(event.y - start_xy[1]) > 30):
x1, x2 = ax.bbox.intervalx
whisk = (start_xy[0], y1, start_xy[0], y2)
elif (abs(event.y - start_xy[1]) < 15) and (abs(event.x - start_xy[0]) > 30):
y1, y2 = ax.bbox.intervaly
whisk = (x1, start_xy[1], x2, start_xy[1])
else:
whisk = None
self.remove_whiskers()

self.draw_rubberband(event, x1, y1, x2, y2)
if whisk:
self.draw_whiskers(event, *whisk, ws=30)

def release_zoom(self, event):
"""Callback for mouse button release in zoom to rect mode."""
Expand All @@ -3318,6 +3346,7 @@ def release_zoom(self, event):
# by (pressing and) releasing another mouse button.
self.canvas.mpl_disconnect(self._zoom_info.cid)
self.remove_rubberband()
self.remove_whiskers()

start_x, start_y = self._zoom_info.start_xy
direction = "in" if self._zoom_info.button == 1 else "out"
Expand All @@ -3328,12 +3357,6 @@ def release_zoom(self, event):
key = "x"
elif self._zoom_info.cbar == "vertical":
key = "y"
# Ignore single clicks: 5 pixels is a threshold that allows the user to
# "cancel" a zoom action by zooming by less than 5 pixels.
if ((abs(event.x - start_x) < 5 and key != "y") or
(abs(event.y - start_y) < 5 and key != "x")):
self._cleanup_post_zoom()
return

for i, ax in enumerate(self._zoom_info.axes):
# Detect whether this Axes is twinned with an earlier Axes in the
Expand All @@ -3342,8 +3365,14 @@ def release_zoom(self, event):
for prev in self._zoom_info.axes[:i])
twiny = any(ax.get_shared_y_axes().joined(ax, prev)
for prev in self._zoom_info.axes[:i])
# Handle release of single axis zooms
end_x, end_y = event.x, event.y
if (abs(end_x - start_x) < 15) and (abs(end_y - start_y) > 30):
start_x, end_x = ax.bbox.intervalx
if (abs(end_y - start_y) < 15) and (abs(end_x - start_x) > 30):
start_y, end_y = ax.bbox.intervaly
ax._set_view_from_bbox(
(start_x, start_y, event.x, event.y),
(start_x, start_y, end_x, end_y),
direction, key, twinx, twiny)

self._cleanup_post_zoom()
Expand All @@ -3354,6 +3383,7 @@ def _cleanup_post_zoom(self):
# by (pressing and) releasing another mouse button.
self.canvas.mpl_disconnect(self._zoom_info.cid)
self.remove_rubberband()
self.remove_whiskers()
self.canvas.draw_idle()
self._zoom_info = None

Expand Down
4 changes: 4 additions & 0 deletions lib/matplotlib/backend_bases.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,10 @@ class NavigationToolbar2:
self, event: Event, x0: float, y0: float, x1: float, y1: float
) -> None: ...
def remove_rubberband(self) -> None: ...
def draw_whiskers(
self, event: Event, x0: float, y0: float, x1: float, y1: float, ws: float
) -> None: ...
def remove_whiskers(self) -> None: ...
def home(self, *args) -> None: ...
def back(self, *args) -> None: ...
def forward(self, *args) -> None: ...
Expand Down
11 changes: 11 additions & 0 deletions lib/matplotlib/backends/_backend_gtk.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,9 +281,20 @@ def draw_rubberband(self, event, x0, y0, x1, y1):
rect = [int(val) for val in (x0, y0, x1 - x0, y1 - y0)]
self.canvas._draw_rubberband(rect)

def draw_whiskers(self, event, x0, y0, x1, y1, ws=20):
height = self.canvas.figure.bbox.height
y1 = height - y1
y0 = height - y0
x0, y0, x1, y1, ws = [int(val) for val in (x0, y0, x1, y1, ws)]
whisk = (x0, y0, x1, y1)
self.canvas._draw_whiskers(whisk, ws)

def remove_rubberband(self):
self.canvas._draw_rubberband(None)

def remove_whiskers(self):
self.canvas._draw_whiskers(None)

def _update_buttons_checked(self):
for name, active in [("Pan", "PAN"), ("Zoom", "ZOOM")]:
button = self._gtk_ids.get(name)
Expand Down
24 changes: 24 additions & 0 deletions lib/matplotlib/backends/_backend_tk.py
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,8 @@ def full_screen_toggle(self):


class NavigationToolbar2Tk(NavigationToolbar2, tk.Frame):
_whiskers_tag = "_matplotlib_zoom_whiskers"

def __init__(self, canvas, window=None, *, pack_toolbar=True):
"""
Parameters
Expand Down Expand Up @@ -785,6 +787,25 @@ def draw_rubberband(self, event, x0, y0, x1, y1):
self.canvas._tkcanvas.create_rectangle(
x0, y0, x1, y1, outline='white', dash=(3, 3)))

def draw_whiskers(self, event, x0, y0, x1, y1, ws=20):
self.remove_whiskers()
height = self.canvas.figure.bbox.height
y0 = height - y0
y1 = height - y1
lines = [(x0, y0, x1, y1)]
if x1 == x0: # vertical line
lines += [(x0 - ws//2, y0, x0 + ws//2, y0),
(x1 - ws//2, y1, x1 + ws//2, y1)]
elif y1 == y0: # horizontal line
lines += [(x0, y0 - ws//2, x0, y0 + ws//2),
(x1, y1 - ws//2, x1, y1 + ws//2)]
else:
return
for color, width in [("white", 3), ("black", 1)]:
for line in lines:
self.canvas._tkcanvas.create_line(
*line, fill=color, width=width, tags=self._whiskers_tag)

def remove_rubberband(self):
if self.canvas._rubberband_rect_white:
self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_white)
Expand All @@ -793,6 +814,9 @@ def remove_rubberband(self):
self.canvas._tkcanvas.delete(self.canvas._rubberband_rect_black)
self.canvas._rubberband_rect_black = None

def remove_whiskers(self):
self.canvas._tkcanvas.delete(self._whiskers_tag)

def _set_image_for_button(self, button):
"""
Set the image for a button based on its pixel size.
Expand Down
91 changes: 63 additions & 28 deletions lib/matplotlib/backends/backend_gtk3.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ def __init__(self, figure=None):

self._idle_draw_id = 0
self._rubberband_rect = None
self._whiskers = None
self._whisker_size = 20

self.connect('scroll_event', self.scroll_event)
self.connect('button_press_event', self.button_press_event)
Expand Down Expand Up @@ -244,35 +246,68 @@ def _draw_rubberband(self, rect):
# TODO: Only update the rubberband area.
self.queue_draw()

def _post_draw(self, widget, ctx):
if self._rubberband_rect is None:
return
def _draw_whiskers(self, whisk, ws=20):
self._whiskers = whisk # x0, y0, x1, y1
self._whisker_size = ws
self.queue_draw()

x0, y0, w, h = (dim / self.device_pixel_ratio
for dim in self._rubberband_rect)
x1 = x0 + w
y1 = y0 + h

# Draw the lines from x0, y0 towards x1, y1 so that the
# dashes don't "jump" when moving the zoom box.
ctx.move_to(x0, y0)
ctx.line_to(x0, y1)
ctx.move_to(x0, y0)
ctx.line_to(x1, y0)
ctx.move_to(x0, y1)
ctx.line_to(x1, y1)
ctx.move_to(x1, y0)
ctx.line_to(x1, y1)

ctx.set_antialias(1)
ctx.set_line_width(1)
ctx.set_dash((3, 3), 0)
ctx.set_source_rgb(0, 0, 0)
ctx.stroke_preserve()

ctx.set_dash((3, 3), 3)
ctx.set_source_rgb(1, 1, 1)
ctx.stroke()
def _post_draw(self, widget, ctx):
if self._rubberband_rect:

x0, y0, w, h = (dim / self.device_pixel_ratio
for dim in self._rubberband_rect)
x1 = x0 + w
y1 = y0 + h

# Draw the lines from x0, y0 towards x1, y1 so that the
# dashes don't "jump" when moving the zoom box.
ctx.move_to(x0, y0)
ctx.line_to(x0, y1)
ctx.move_to(x0, y0)
ctx.line_to(x1, y0)
ctx.move_to(x0, y1)
ctx.line_to(x1, y1)
ctx.move_to(x1, y0)
ctx.line_to(x1, y1)

ctx.set_antialias(1)
ctx.set_line_width(1)
ctx.set_dash((3, 3), 0)
ctx.set_source_rgb(0, 0, 0)
ctx.stroke_preserve()

ctx.set_dash((3, 3), 3)
ctx.set_source_rgb(1, 1, 1)
ctx.stroke()

if self._whiskers:
x0, y0, x1, y1 = (dim / self.device_pixel_ratio
for dim in self._whiskers)
ws = self._whisker_size / self.device_pixel_ratio

ctx.set_antialias(1)
ctx.set_dash([], 0)

# main line
ctx.move_to(x0, y0)
ctx.line_to(x1, y1)
if x0 == x1: # vertical line
ctx.move_to(x0 - ws//2, y0)
ctx.line_to(x0 + ws//2, y0)
ctx.move_to(x1 - ws//2, y1)
ctx.line_to(x1 + ws//2, y1)
if y0 == y1: # horizontal line
ctx.move_to(x0, y0 - ws//2)
ctx.line_to(x0, y0 + ws//2)
ctx.move_to(x1, y1 - ws//2)
ctx.line_to(x1, y1 + ws//2)

ctx.set_line_width(3)
ctx.set_source_rgb(1, 1, 1)
ctx.stroke_preserve()
ctx.set_line_width(1)
ctx.set_source_rgb(0, 0, 0)
ctx.stroke()

def on_draw_event(self, widget, ctx):
# to be overwritten by GTK3Agg or GTK3Cairo
Expand Down
Loading
Loading