Matplotlib/Pyplot: How to zoom subplots together AND x-scroll separately?
Asked Answered
A

2

10

I previously asked the question "How to zoom subplots together?", and have been using the excellent answer since then.

I'm now plotting just two sets of time-series data, and I need to continue to zoom as above, but now I need to also pan one plot relative to the other (I'm doing eyeball correlation). The data comes from 2 independent instruments with different start times and different clock settings.

In use, I zoom using the 'Zoom to Rectangle' toolbar button, and I scroll using the "Pan/Zoom" button.

How may I best scroll one plot in X relative to the other? Ideally, I'd also like to capture and display the time difference. I do not need to scroll vertically in Y.

I suspect I may need to stop using the simple "sharex=" "sharey=" method, but am not certain how best to proceed.

Thanks, in advance, to the great StackOverflow community!

-BobC

Asymmetry answered 15/2, 2011 at 1:2 Comment(0)
A
8

I hacked the above solution until it did want I think I want.

# File: ScrollTest.py
# coding: ASCII
"""
Interatively zoom plots together, but permit them to scroll independently.
"""
from matplotlib import pyplot
import sys

def _get_limits( ax ):
    """ Return X and Y limits for the passed axis as [[xlow,xhigh],[ylow,yhigh]]
    """
    return [list(ax.get_xlim()), list(ax.get_ylim())]

def _set_limits( ax, lims ):
    """ Set X and Y limits for the passed axis
    """
    ax.set_xlim(*(lims[0]))
    ax.set_ylim(*(lims[1]))
    return

def pre_zoom( fig ):
    """ Initialize history used by the re_zoom() event handler.
        Call this after plots are configured and before pyplot.show().
    """
    global oxy
    oxy = [_get_limits(ax) for ax in fig.axes]
    # :TODO: Intercept the toolbar Home, Back and Forward buttons.
    return

def re_zoom(event):
    """ Pyplot event handler to zoom all plots together, but permit them to
        scroll independently.  Created to support eyeball correlation.
        Use with 'motion_notify_event' and 'button_release_event'.
    """
    global oxy
    for ax in event.canvas.figure.axes:
        navmode = ax.get_navigate_mode()
        if navmode is not None:
            break
    scrolling = (event.button == 1) and (navmode == "PAN")
    if scrolling:                   # Update history (independent of event type)
        oxy = [_get_limits(ax) for ax in event.canvas.figure.axes]
        return
    if event.name != 'button_release_event':    # Nothing to do!
        return
    # We have a non-scroll 'button_release_event': Were we zooming?
    zooming = (navmode == "ZOOM") or ((event.button == 3) and (navmode == "PAN"))
    if not zooming:                 # Nothing to do!
        oxy = [_get_limits(ax) for ax in event.canvas.figure.axes]  # To be safe
        return
    # We were zooming, but did anything change?  Check for zoom activity.
    changed = None
    zoom = [[0.0,0.0],[0.0,0.0]]    # Zoom from each end of axis (2 values per axis)
    for i, ax in enumerate(event.canvas.figure.axes): # Get the axes
        # Find the plot that changed
        nxy = _get_limits(ax)
        if (oxy[i] != nxy):         # This plot has changed
            changed = i
            # Calculate zoom factors
            for j in [0,1]:         # Iterate over x and y for each axis
                # Indexing: nxy[x/y axis][lo/hi limit]
                #           oxy[plot #][x/y axis][lo/hi limit]
                width = oxy[i][j][1] - oxy[i][j][0]
                # Determine new axis scale factors in a way that correctly
                # handles simultaneous zoom + scroll: Zoom from each end.
                zoom[j] = [(nxy[j][0] - oxy[i][j][0]) / width,  # lo-end zoom
                           (oxy[i][j][1] - nxy[j][1]) / width]  # hi-end zoom
            break                   # No need to look at other axes
    if changed is not None:
        for i, ax in enumerate(event.canvas.figure.axes): # change the scale
            if i == changed:
                continue
            for j in [0,1]:
                width = oxy[i][j][1] - oxy[i][j][0]
                nxy[j] = [oxy[i][j][0] + (width*zoom[j][0]),
                          oxy[i][j][1] - (width*zoom[j][1])]
            _set_limits(ax, nxy)
        event.canvas.draw()         # re-draw the canvas (if required)
        pre_zoom(event.canvas.figure)   # Update history
    return
# End re_zoom()

def main(argv):
    """ Test/demo code for re_zoom() event handler.
    """
    import numpy
    x = numpy.linspace(0,100,1000)      # Create test data
    y = numpy.sin(x)*(1+x)

    fig = pyplot.figure()               # Create plot
    ax1 = pyplot.subplot(211)
    ax1.plot(x,y)
    ax2 = pyplot.subplot(212)
    ax2.plot(x,y)

    pre_zoom( fig )                     # Prepare plot event handler
    pyplot.connect('motion_notify_event', re_zoom)  # for right-click pan/zoom
    pyplot.connect('button_release_event',re_zoom)  # for rectangle-select zoom

    pyplot.show()                       # Show plot and interact with user
# End main()

if __name__ == "__main__":
    # Script is being executed from the command line (not imported)
    main(sys.argv)

# End of file ScrollTest.py
Asymmetry answered 22/2, 2011 at 3:42 Comment(0)
W
6

Ok, here's my stab at it. This works, but there might be a simpler approach. This solution uses some matplotlib event-handling to trigger a new set_xlim() every time it notices the mouse in motion. The trigger event 'motion_notify_event' could be eliminated if dynamic synchronous zooming isn't required.

Bonus: this works for any number of subplots.

from matplotlib import pyplot
import numpy

x = numpy.linspace(0,10,100)
y = numpy.sin(x)*(1+x)

fig = pyplot.figure()
ax1 = pyplot.subplot(121)
ax1.plot(x,y)
ax2 = pyplot.subplot(122)
ax2.plot(x,y)

ax1.old_xlim = ax1.get_xlim()  # store old values so changes
ax2.old_xlim = ax2.get_xlim()  # can be detected

def re_zoom(event):
    zoom = 1.0
    for ax in event.canvas.figure.axes: # get the change in scale
        nx = ax.get_xlim()
        ox = ax.old_xlim
        if ox != nx:                    # of axes that have changed scale
            zoom = (nx[1]-nx[0])/(ox[1]-ox[0])

    for ax in event.canvas.figure.axes: # change the scale
        nx = ax.get_xlim()
        ox = ax.old_xlim
        if ox == nx:                    # of axes that need an update
            mid = (ox[0] + ox[1])/2.0
            dif = zoom*(ox[1] - ox[0])/2.0
            nx = (mid - dif, mid + dif)
            ax.set_xlim(*nx)
        ax.old_xlim = nx
    if zoom != 1.0:
        event.canvas.draw()             # re-draw the canvas (if required)

pyplot.connect('motion_notify_event', re_zoom)  # for right-click pan/zoom
pyplot.connect('button_release_event', re_zoom) # for rectangle-select zoom
pyplot.show()
Weissberg answered 15/2, 2011 at 7:16 Comment(3)
It doesn't do quite what I'd hoped: If I use the 'Zoom to rectangle' tool, the two plots don't zoom together. For example, in the left plot, select the rectangle (0,0) to (4,4), which contains the first hump of the curve. The left plot zooms as expected, but the right plot zooms to show a non-overlapping area.Asymmetry
@ BobC It was my interpretation that you wanted the plots to have independent x-positions, but maintain a shared x-scale. It seems now that you want the x-position to be shared under certain circumstances. So that I can understand: If you, for example, first scroll one plot so that the axes show non-overlapping portions of the graph, then perform a rectangle zoom, what is the expected behavior? Do you want the two plots to snap back to a shared x-position?Weissberg
The zoom math is wrong: Since the zoom-to-rectangle button does a simultaneous scroll & zoom, the math must take that into account, and not assume a center-based zoom. Zoom can also occur simultaneously on both axes, and that must be accounted for too. When a scroll occurs, the next zoom must use accurate data, so the saved history values must be updated with each scroll operation.Asymmetry

© 2022 - 2024 — McMap. All rights reserved.