Displaying an Enlarged Portion of a Graph on Another Graph in Matplotlib

2024-06-11matplotlib,Python

Introduction

I’ve tried using two graphs in matplotlib, where one displays the entire view and the other displays an enlarged portion. Additionally, I’ve enabled the ability to freely change the zoomed-in area. Here are my notes.

What I Want to Implement

  1. Use two graphs, displaying the overall view on one and an enlarged portion on the other.
  2. Display the zoomed-in area on the overall graph.
  3. Enable dynamic adjustment of the zoomed-in area with the mouse.

One Graph Displays the Overall View, While the Other Displays an Enlarged Portion

This is simple. Just plot the same data on both graphs and change the display range of one.

import matplotlib.pyplot as plt
import numpy as np

fig, axes = plt.subplots(2, 1)

x = np.arange(0.0, 10.0, 0.01)
y = np.sin(2 * x) + 0.1

axes[0].plot(x, y)
axes[1].plot(x, y)

axes[1].set_xlim(0.5, 6)
axes[1].set_ylim(0, 1.25)

plt.show()

Displaying the Zoomed-in Area on the Overall Graph

You can display a rectangle corresponding to the zoomed-in area on the overview graph. To do this, create a Rectangle object and add it to the overall graph using add_patch().

import matplotlib.patches as patches

xlim = axes[1].get_xlim()
ylim = axes[1].get_ylim()

rect = patches.Rectangle([xlim[0], ylim[0]], xlim[1] - xlim[0], ylim[1] - ylim[0])
axes[0].add_patch(rect)

To Dynamically Change the Zoomed-in Area with the Mouse

To use mpl_connect() to capture mouse events, enabling the movement of the rectangle in response to dragging and simultaneously changing the display range of the zoomed-in graph.

For information on how to use mpl_connect(), please refer to the following articles.


x_on_click = 0
y_on_click = 0
box_x = 0
box_y = 0
press = False

def on_button_down(event):
    global axes, x_on_click, y_on_click, box_x, box_y, press, rect

    if event.inaxes != axes[0]:
        return

    if event.button != 1:
        return

    contains, attrd = rect.contains(event)

    if not contains:
        return

    press = True
    x_on_click = event.xdata
    y_on_click = event.ydata
    box_x = rect.get_x()
    box_y = rect.get_y()

def on_button_up(event):
    global press
    press = False

def on_mouse_move(event):
    global fig, axes, x_on_click, y_on_click, box_x, box_y, press, rect

    if not press or event.inaxes != axes[0]:
        return

    dx = event.xdata - x_on_click
    dy = event.ydata - y_on_click
    new_x = box_x + dx
    new_y = box_y + dy
    rect.set_x(new_x)
    rect.set_y(new_y)
    axes[1].set_xlim([new_x, new_x + rect.get_width()])
    axes[1].set_ylim([new_y, new_y + rect.get_height()])
    fig.canvas.draw()

fig.canvas.mpl_connect('button_press_event', on_button_down)
fig.canvas.mpl_connect('button_release_event', on_button_up)
fig.canvas.mpl_connect('motion_notify_event', on_mouse_move)

You can determine if the rectangle is clicked using Rectangle.contains(). If the rectangle is clicked, set a flag to True, and if the button is released, set the flag to False.

Implemented Code

This is a bit long, but here is the actual code. Considering ease of use, I implemented it as a class. Additionally, since the above code does not allow changing the width and height of the rectangle indicating the zoom range, I made it possible to change the width and height of the rectangle as well. For this, I used an extended version of the MplZoomHelper class created in the previous article to handle this functionality.

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np

class MplZoomHelper:
    def __init__(self, ax: plt.Axes, rect: patches.Rectangle = None):
        self.ax = ax
        self.ctrl_flag = False
        self.zoom_rate = 0.9
        self.rect = rect

        self.move_rate = 0.05
        self.x_on_click = 0
        self.y_on_click = 0
        self.xlim_on_click = ax.get_xlim()
        self.ylim_on_click = ax.get_ylim()

        self.initial_xlim = ax.get_xlim()
        self.initial_ylim = ax.get_ylim()

        ax.figure.canvas.mpl_connect('key_press_event', self.on_key_press)
        ax.figure.canvas.mpl_connect('key_release_event', self.on_key_release)
        ax.figure.canvas.mpl_connect('scroll_event', self.on_scroll)
        ax.figure.canvas.mpl_connect('button_press_event', self.on_button_press)
        ax.figure.canvas.mpl_connect('motion_notify_event', self.on_move)

    def on_key_press(self, event):
        if event.inaxes is not self.ax:
            return

        if event.key == 'control':
            self.ctrl_flag = True
        elif event.key == 'h':
            self.on_home(event)
        elif event.key == 'right':
            self.on_move_x(event, 1)
        elif event.key == 'left':
            self.on_move_x(event, -1)
        elif event.key == 'up':
            self.on_move_y(event, 1)
        elif event.key == 'down':
            self.on_move_y(event, -1)

    def on_key_release(self, event):
        if event.inaxes is not self.ax:
            return

        if event.key == 'control':
            self.ctrl_flag = False

    def on_home(self, event):
        self.ax.set_xlim(self.initial_xlim)
        self.ax.set_ylim(self.initial_ylim)

        if self.rect is not None:
            self.rect.set_xy([self.initial_xlim[0], self.initial_ylim[0]])
            self.rect.set_width(self.initial_xlim[1] - self.initial_xlim[0])
            self.rect.set_height(self.initial_ylim[1] - self.initial_ylim[0])

        self.ax.figure.canvas.draw()

    def on_scroll(self, event):
        if event.inaxes is not self.ax:
            return

        if self.ctrl_flag:
            self.on_scroll_y(event)
        else:
            self.on_scroll_x(event)

        self.ax.figure.canvas.draw()

    def on_scroll_x(self, event):
        x_pos = event.xdata
        min_value, max_value = self.ax.get_xlim()

        if event.button == 'up':
            new_min_value = x_pos - (x_pos - min_value) * self.zoom_rate
            new_max_value = (max_value - x_pos) * self.zoom_rate + x_pos
        elif event.button == 'down':
            new_min_value = x_pos - (x_pos - min_value) / self.zoom_rate
            new_max_value = (max_value - x_pos) / self.zoom_rate + x_pos

        self.ax.set_xlim(new_min_value, new_max_value)

        if self.rect is not None:
            self.rect.set_x(new_min_value)
            self.rect.set_width(new_max_value - new_min_value)

    def on_scroll_y(self, event):
        y_pos = event.ydata
        min_value, max_value = self.ax.get_ylim()

        if event.button == 'up':
            new_min_value = y_pos - (y_pos - min_value) * self.zoom_rate
            new_max_value = (max_value - y_pos) * self.zoom_rate + y_pos
        elif event.button == 'down':
            new_min_value = y_pos - (y_pos - min_value) / self.zoom_rate
            new_max_value = (max_value - y_pos) / self.zoom_rate + y_pos

        self.ax.set_ylim(new_min_value, new_max_value)

        if self.rect is not None:
            self.rect.set_y(new_min_value)
            self.rect.set_height(new_max_value - new_min_value)

    def on_button_press(self, event):
        if event.inaxes is not self.ax:
            return

        if event.button != 1:
            return

        self.x_on_click = event.xdata
        self.y_on_click = event.ydata
        self.xlim_on_click = self.ax.get_xlim()
        self.ylim_on_click = self.ax.get_ylim()

    def on_move(self, event):
        if event.inaxes is not self.ax:
            return

        if event.button != 1:
            return

        dx = event.xdata - self.x_on_click
        dy = event.ydata - self.y_on_click
        self.xlim_on_click -= dx
        self.ylim_on_click -= dy
        self.ax.set_xlim(self.xlim_on_click)
        self.ax.set_ylim(self.ylim_on_click)

        if self.rect is not None:
            self.rect.set_x(self.xlim_on_click[0])
            self.rect.set_y(self.ylim_on_click[0])

        self.ax.figure.canvas.draw()

    def on_move_x(self, event, dir):
        min_value, max_value = self.ax.get_xlim()
        width = max_value - min_value
        dx = width * self.move_rate * dir
        self.ax.set_xlim(min_value + dx, max_value + dx)

        if self.rect is not None:
            self.rect.set_x(min_value + dx)

        self.ax.figure.canvas.draw()

    def on_move_y(self, event, dir):
        min_value, max_value = self.ax.get_ylim()
        width = max_value - min_value
        dy = width * self.move_rate * dir
        self.ax.set_ylim(min_value + dy, max_value + dy)

        if self.rect is not None:
            self.rect.set_y(min_value + dy)

        self.ax.figure.canvas.draw()

class MplZoomPlot:
    def __init__(self, ax: plt.Axes, sub_ax: plt.Axes):
        self.ax = ax
        self.sub_ax = sub_ax

        xlim = ax.get_xlim()
        ylim = ax.get_ylim()
        self.sub_ax.set_xlim(xlim)
        self.sub_ax.set_ylim(ylim)
        self.x_on_click = 0
        self.y_on_click = 0
        self.box_x = 0
        self.box_y = 0
        self.press = False

        self.rect = patches.Rectangle([xlim[0], ylim[0]], xlim[1] - xlim[0], ylim[1] - ylim[0])
        self.rect.set_facecolor('grey')
        self.rect.set_edgecolor('grey')
        self.rect.set_alpha(0.5)
        self.ax.add_patch(self.rect)

        self.rect.axes.figure.canvas.mpl_connect('button_press_event', self.on_button_down)
        self.rect.axes.figure.canvas.mpl_connect('button_release_event', self.on_button_up)
        self.rect.axes.figure.canvas.mpl_connect('motion_notify_event', self.on_mouse_move)

        self.zoom = MplZoomHelper(self.sub_ax, self.rect)

    def plot(self, x, y, *args, **kwargs):
        self.ax.plot(x, y, *args, **kwargs)
        self.sub_ax.plot(x, y, *args, **kwargs)

    def set_sub_ax_xlim(self, xmin, xmax):
        self.rect.set_x(xmin)
        self.rect.set_width(xmax - xmin)
        self.sub_ax.set_xlim(xmin, xmax)

    def set_sub_ax_ylim(self, ymin, ymax):
        self.rect.set_y(ymin)
        self.rect.set_height(ymax - ymin)
        self.sub_ax.set_ylim(ymin, ymax)

    def on_button_down(self, event):
        if event.inaxes != self.ax:
            return

        if event.button != 1:
            return

        contains, attrd = self.rect.contains(event)

        if not contains:
            return

        self.press = True
        self.x_on_click = event.xdata
        self.y_on_click = event.ydata
        self.box_x = self.rect.get_x()
        self.box_y = self.rect.get_y()

    def on_button_up(self, event):
        self.press = False
        self.ax.figure.canvas.draw()

    def on_mouse_move(self, event):
        if not self.press or event.inaxes != self.ax:
            return

        dx = event.xdata - self.x_on_click
        dy = event.ydata - self.y_on_click
        new_x = self.box_x + dx
        new_y = self.box_y + dy
        self.rect.set_x(new_x)
        self.rect.set_y(new_y)
        self.sub_ax.set_xlim([new_x, new_x + self.rect.get_width()])
        self.sub_ax.set_ylim([new_y, new_y + self.rect.get_height()])
        self.ax.figure.canvas.draw()

if __name__ == "__main__":
    fig, axes = plt.subplots(2, 1)

    x = np.arange(0.0, 10.0, 0.01)
    y = np.sin(2 * x) + 0.1

    zp = MplZoomPlot(axes[1], axes[0])
    zp.plot(x, y)
    zp.set_sub_ax_xlim(0, 1)
    zp.set_sub_ax_ylim(0, 1.25)

    plt.show()

Here is a GIF animation showing the actual graph displayed using the above code.

Zoomed graph synchronized with the overall graph

The range displayed in the gray rectangle within the lower graph is shown in the upper graph. When you drag this gray rectangle with the mouse, the display range of the upper graph changes correspondingly. Additionally, moving the mouse wheel within the upper graph changes the size of the gray rectangle in the X direction. Moving the mouse wheel while holding down the Ctrl key changes the size of the gray rectangle in the Y direction. Furthermore, pressing the 'h' key returns the graph to its initial position.

Conclusion

I presented code that uses two graphs: one displaying the overall view and the other displaying an enlarged portion, with the ability to freely change the enlarged display range using the mouse. Although the code is somewhat complex, it has been implemented as a class for general use, making it easier to use.

This code should be effectively useful in cases where you need to enlarge and observe specific portions, such as spectra.

matplotlib,python

Posted by izadori