【Python】【matplotlib】マウスのドラッグでグラフをパンする

2024-04-03matplotlib,python

はじめに

前のエントリでは、マウスのホイールを使用してグラフを拡大縮小する方法について説明し、実装しました。このエントリでは、さらにグラフをドラッグすることでパン(平行移動)する方法について説明します。

やりたいこと

  1. マウスのグラフをドラッグするとグラフを平行移動する
  2. カーソルキーで押した方向にグラフを平行移動させる

ドラッグ操作・キー操作の受け取り方法

ドラッグ動作の受け取り

ドラッグ操作を受け取るには、'motion_notify_event'を使います。これは、グラフ内でカーソルが移動したときに発生するイベントですが、マウスのボタンの状態も受け取ることができます。これを利用して、左ボタンが押されている状態のときに、ドラッグしていると判定します。

実装例

import matplotlib.pyplot as plt

def on_move(event):
    if event.button == 1:
        print("dragging")

fig, ax = plt.subplots()
fig.canvas.mpl_connect('motion_notify_event', on_move)

event.buttonが押されているマウスのボタンを表します。1が左ボタンです。
この例では、マウスの左ボタンを押しながらドラッグした場合に、コンソールに"dragging"と出力します。

キー操作の受け取り

前のエントリでCtrlキーの受け取りを実装しました。同様に'key_press_event'を使います。

実装例

import matplotlib.pyplot as plt

def on_key_press(event):
    print(event.key)

fig, ax = plt.subplots()
fig.canvas.mpl_connect('key_press_event', on_key_press)

上記の例では、押したキーに対応した文字列をコンソールに出力します。キーを押し続けると、一定間隔で文字列が表示され続けます。

パン操作の実装

上記を応用して、実際に実装してみます。前のエントリで作成したMplZoomHelperクラスに追加で実装し、拡張します。また、おまけ機能として'h'キーを押すことで、最初の位置に戻れるようにします。

import matplotlib.pyplot as plt

class MplZoomHelper:
    def __init__(self, ax):
        self.ax = ax
        self.ctrl_flag = False
        self.zoom_rate = 0.9

        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)
        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)

    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)

    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)
        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)
        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)
        self.ax.figure.canvas.draw()

if __name__ == "__main__":
    fig, axes = plt.subplots(2, 2)
    axes[0, 0].plot([-1, 2, 3], [2, 4, 3])
    axes[1, 1].plot([0, 5, 9], [-2, 1, 10])

    z1 = MplZoomHelper(axes[0, 0])
    z2 = MplZoomHelper(axes[1, 1])
    plt.show()

コンストラクタ

コンストラクタで、追加の変数の初期化を実施します。また、ホームに戻る機能のために、初期表示時の表示範囲を保持しておきます。

on_key_press()

カーソルキーと'h'キーが押されたときの動作を追加しています。カーソルキーは'right', 'left', 'up', 'down'でそれぞれ受け取ることができます。各キーが押されたときの処理に飛びます。

on_home()

'h'キーを押したときの動作です。'h'キーが押されたら、コンストラクタで保持しておいた表示範囲(self.initial_xlim, self.initail_ylim)に戻します。

on_button_press()

マウスのボタンが押されたときの動作です。この時、event.inaxesを見て、登録したグラフ(self.ax)上で発生したイベントかどうか調べます。
ドラッグ開始に備えて、クリックしたときのグラフ座標(event.xdata, event.ydata)と、表示されているx軸、y軸の範囲を記録しておきます。

on_move()

マウスのカーソルが動いているときの動作です。この時、event.inaxesを見て、登録したグラフ(self.ax)上で発生したイベントかどうか調べます。また、event.buttonを見て、1ならば左ボタンによるドラッグ中と判定しています。
グラフ座標(event.xdata, event.ydata)を取得し、クリック開始時からの差分を計算します。その分だけ表示範囲をずらします。

パンした結果は、再描画しないとグラフに反映されません。そのために、self.ax.figure.canvas.draw()を実施します。

on_move_x(), on_move_y()

カーソルキーを押したときの動作です。カーソルキーが押された方向に、表示幅のself.move_rate分だけ平行に移動します。移動後再描画するために、self.ax.figure.canvas.draw()を実施します。

まとめ

matplotlibで表示されたグラフに対して、マウスのドラッグでパン操作を実施する方法です。

  1. mpl_connect()を使って、'button_press_event'を受け取るコールバック関数を登録します。
  2. mpl_connect()を使って、'motion_notify_event'を受け取るコールバック関数を登録します。
  3. button_press_eventのコールバック関数でマウスのクリックイベントを捕まえ、その時のグラフ上の座標を記録します。
  4. motion_notify_eventのコールバック関数でマウスのボタンの押下状態を取得します。押下状態にあれば、ドラッグ中と判断します。
  5. クリック開始時に記録した座標からの差分を計算し、グラフの両端の値を変更します。
  6. 変更した結果を画面に反映するため、再描画します。

matplotlib,python

Posted by izadori