Matplotlibでインタラクティブにプロットしてみる

# はじめに
行数のやたら長い(数万とか)テキストファイルを行区切りで部分的に読み込んでプロット表示するツールをMatplotlibで作ってみた。
* Matplotlibで適当な表示ツールを自作する場合の参考サンプルにできるように、色々な要素を含めたツールにしたかった意図もある

# 欲しい機能
## 表示したい行をGUIで指定できるようにする
* 前方移動・後方移動をボタンやスライダ操作で可能に
## プロットの横軸幅を変えられるようにする
* 起動時指定で構わない
## プロットのマルチ表示(例えば、3x2=6個表示)
* ハードコーディングで構わない

# 今回の悩み所
## テキストファイルの行単位のシーク方法
* 短いファイルならreadlinesで全読み込みするのが定石と思うが、今回はそれが難しい場合を想定しているので、シークしてはつど読み込む方法で考える
* ->今回はisliceを利用してみる

# プロット描画について
* GridSpecを利用して描画エリアを分割し、各分割エリアに複数のプロットエリアやGUIボタン・スライダのウィジェットを配置している。以下に描画レイアウトと描画オブジェクト(ax[])の関係を示す
![描画レイアウトdraw_design_plan.001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918174/ddd95b18-a820-4835-024d-4ef45f69945a.png)

# 使い方
```
zsh$ python3.11 sample0.py --input_filename test01.txt --data_range=100 --start_view 2000
```
* sample0.pyは実装したコードのファイル名
* test01.txtはテスト用のテキストファイル名
* --data_rangeで1プロットあたりの表示データ個数を指定
* --start_viewでプロットを開始する位置(ファイル行番号)を指定

# テスト用テキストファイルについて
* 以下のコマンドでファイルを生成
```
zsh$ for n in $(seq 1 10000); do echo "$n $RANDOM" ;done > test01.txt
```
* 先頭10行を表示。この形式で10000行続いている
```
1 18556
2 26281
3 28682
4 12843
5 25054
6 25672
7 22364
8 25576
9 15367
10 30762
```

# アプリのスクリーンショット
* GUIのボタンやスライダ表示が素朴というか、かなりシンプル
![スクリーンショットサンプル ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/918174/31283761-c682-7869-2e8b-a6fe16beb735.png)

# 実装したpythonコード
* 動作確認はMacOS 14.2.1（23C71）on M1+python3.11(brew)にて行っている
* スライダの横幅を決めるためにファイルの行数を取得している。Unix系はwcコマンドを使って取得しているが、Windowsではmmapを使って取得している
* (2024/01/04 ソースコードに誤りがあったので修正しています)
```
import os
import sys
import time
import mmap
import signal
import termios
import platform
import argparse
import subprocess
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.widgets as wg
from itertools import islice
from matplotlib import rcParams

# Ctrl+Cで実行を止めるおまじない
signal.signal(signal.SIGINT, signal.SIG_DFL)

# 引数パーサーの初期化(未使用)
parser = argparse.ArgumentParser(description='data viewer')

# parser.add_argumentで受け取る引数を追加していく
parser.add_argument('--input_filename', help='入力データファイル名' )
parser.add_argument('--data_range',     help='1プロットの時間表示範囲', default='16')
parser.add_argument('--start_view',     help='プロット表示開始位置',    default='0')
parser.add_argument('--plot_number',    help='表示プロット個数',        default='6')

# 引数のパース
args = parser.parse_args()

# 引数の取得
i_filename    = args.input_filename
i_data_range  = int(args.data_range)
i_start_view  = int(args.start_view)
i_plot_number = int(args.plot_number)

# グローバル変数
plot_width = i_data_range
data_index = i_start_view
sli_a = 0
plot_max_limit = 0


## プロット出力の前準備
fig = plt.figure()

### GridSpecを使って描画領域を縦13x横3の39分割して各グラフを割り当てる
gs = fig.add_gridspec( 13, 3, wspace=0.2, hspace=0.5, left=0.06, right=0.94, top=0.92, bottom=0.08 )

# ax[0]-ax[5]はプロット描画用
# ax[6]は文字表示用
# ax[7]はスライダ描画用
# ax[8]はボタン(RWD)表示用
# ax[9]はボタン(QUIT)表示用
# ax[10]はボタン(FWD)表示用
ax = [ 
        fig.add_subplot( gs[ 0: 5, 0:1] ),
        fig.add_subplot( gs[ 0: 5, 1:2] ),
        fig.add_subplot( gs[ 0: 5, 2:3] ),
        fig.add_subplot( gs[ 5:10, 0:1] ),
        fig.add_subplot( gs[ 5:10, 1:2] ),
        fig.add_subplot( gs[ 5:10, 2:3] ),
        fig.add_subplot( gs[10:11, 0:3] ),
        fig.add_subplot( gs[11:12, 0:3] ),
        fig.add_subplot( gs[12:13, 0:1] ),
        fig.add_subplot( gs[12:13, 1:2] ),
        fig.add_subplot( gs[12:13, 2:3] ),
     ]

# プロットするaxの番号リスト
plot_list = [ 0, 1, 2, 3, 4, 5 ]

# プロット環境の初期化
def init_plot():
    # スタイルをggplot風に設定
    plt.style.use('ggplot')
    # グラフを横長にする
    rcParams['figure.figsize'] = 16,9
    # グラフフォントの設定
    rcParams['font.family'    ] = 'sans-serif'
    rcParams['font.sans-serif'] = ['VL PGothic', 'Takao', 'IPAexGothic', 'IPAPGothic', 'Noto Sans CJK JP']
    rcParams['font.size'      ] = 16          # フォントサイズを設定 default : 16
    rcParams['xtick.labelsize'] = 10          # 横軸のフォントサイズ
    rcParams['ytick.labelsize'] = 10          # 縦軸のフォントサイズ


# ファイル行数の取得
def get_line_number( fname ):
    un = platform.uname()
    print(un.system, flush=True)
    fl = 0
    if un.system == 'Windows': 
        with open( fname, "r+b" ) as f:
            buf = mmap.mmap(f.fileno(), 0)
            readline = buf.readline
            fl = 0
            while readline(): fl += 1
    elif un.system == 'Linux' or un.system == 'Darwin':
        cmd = f'cat {fname} | wc -l'
        fl = int(subprocess.check_output(cmd,shell=True))
    return fl


# isliceを用いたプロットデータの取得
def get_plot_data( base_index, n ):
    # isliceは切り出す度にファイルを都度open/closeすること
    inp = int(base_index + n * plot_width)
    with open(i_filename, 'r') as fin: # 3000-line
        lines = islice(fin, inp, inp+plot_width)
        data_x = []
        data_y = []
        # 切り出した行データからプロット用のデータを抽出
        for dat in lines:
            #dat = (dat.strip(' ')).split('\t')
            dat = dat.split(' ')
            # print(dat, flush=True)
            data_x.append(int(dat[0])) 
            data_y.append(int(dat[1])) 
    return data_x,data_y


# 描画更新関数
def update_plot( index, sli_a ):
    # プロット更新(すべてのaxを更新)
    for n in plot_list :
        # 更新プロットデータの取得
        data_x,data_y = get_plot_data( index, n )
        dx_max = max(data_x)
        dx_min = min(data_x)
        ax[n].cla()
        ax[n].set_xlim( dx_min, dx_max )
        ax[n].set_ylim( 0, 32768 )
        ax[n].grid( True )
        ax[n].plot( data_x, data_y, ls='', marker='o', color='green', markersize=10 )
        fig.canvas.draw()
        renderer = fig.canvas.renderer   
        ax[n].draw(renderer)

    # スライダ描画の更新
    ax[7].cla()
    sli_a = wg.Slider(ax[7], 'time', 0, plot_max_limit, valinit = index, valstep = plot_width)
    fig.canvas.draw()
    renderer = fig.canvas.renderer
    ax[7].draw(renderer)

    # テキスト表示更新
    ax[6].cla()

    # 軸全部を消去
    ax[6].axis('off')
    ax[6].text( 0.5, 0.1, f'plot_start:{index:7d}', fontsize='large', ha='center', va='center', backgroundcolor='wheat' )
    fig.canvas.draw()
    renderer = fig.canvas.renderer
    ax[6].draw(renderer)

    #plt.grid( True )
    #plt.draw()  # こっちの方が反応が速い


# FWDボタン押下時のハンドラ関数 
def fwd_action( event ):
    global data_index
    global sli_a
    data_index += plot_width
    if data_index > plot_max_limit:
        print(f'max-limit : {data_index}', flush=True)
        data_index = plot_max_limit
    update_plot( int(data_index), sli_a )


# RWDボタン押下時のハンドラ関数 
def rwd_action( event ):
    global data_index
    global sli_a
    data_index -= plot_width
    if data_index < 0:
        print(f'min-limit : {data_index}', flush=True)
        data_index = 0
    update_plot( int(data_index), sli_a )


# QUIT操作時のハンドラ関数 
def quit_action( event ):
    sys.exit() 


# スライダ操作時のハンドラ関数 
def sli_action( val ):
    global data_index
    global sli_a
    data_index = sli_a.val
    update_plot( int(data_index), sli_a )


# メイン関数
def main():
    global data_index
    global plot_max_limit
    global sli_a

    # データインデックスのオーバーランを抑制するための制限値
    fl = get_line_number( i_filename )
    plot_max_limit = fl - plot_width * len( plot_list )

    # プロット体裁の初期化
    init_plot()

    # 初期プロット描画
    for n in plot_list :
        # プロットデータの取得
        data_x,data_y = get_plot_data( data_index, n )
        dx_max = max(data_x)
        dx_min = min(data_x)
        ax[n].cla()
        ax[n].set_xlim( dx_min, dx_max )
        ax[n].set_ylim( 0, 32768 )
        ax[n].grid( True )
        ax[n].plot( data_x, data_y, ls='', marker='o', color='green', markersize=10 )

    # スライダ
    sli_a = wg.Slider(ax[7], 'time', 0, plot_max_limit, valinit = data_index, valstep = plot_width)

    # テキスト表示エリア
    ax[6].axis('off')
    ax[6].text( 0.5, 0.1, f'plot_start:{data_index:7d}', fontsize='large', ha='center', va='center' , backgroundcolor='wheat' )

    # プロット領域の軸を消去
    for n in [ 8, 9, 10 ]:
        ax[n].spines['top'   ].set_visible(False)
        ax[n].spines['bottom'].set_visible(False)
        ax[n].spines['left'  ].set_visible(False)
        ax[n].spines['right' ].set_visible(False)

    # ax[8]-ax[10]にボタンオブジェクトを生成
    button_r = wg.Button( ax[8] , 'RWD'  , color='cyan', hovercolor='yellow' ) # バッファアドレスをデクリメント
    button_q = wg.Button( ax[9] , 'QUIT' , color='cyan', hovercolor='yellow' ) # 表示終了
    button_f = wg.Button( ax[10], 'FWD'  , color='cyan', hovercolor='yellow' ) # バッファアドレスをインクリメント
 
    # 生成したボタンにアクション(メソッド)をアサイン
    button_r.on_clicked( rwd_action )
    button_q.on_clicked( quit_action )
    button_f.on_clicked( fwd_action )

    #スライダにアクション(メソッド)をアサイン
    sli_a.on_changed( sli_action )

    # プロット表示
    plt.show()

    # これ以降は、登録したイベントが発生する度にアクションが呼び出され、描画が更新される


#### call main ####
if __name__ == '__main__':
    main()


#EOF#
```

# 参考リンク
* [mmap関係](http://551sornwmc.blog109.fc2.com/blog-entry-387.html "mmap関係")
* [islice関係](https://www.yutaka-note.com/entry/bf_read_text_lines "islice関係") 
* [Matplotlibリファレンス](https://matplotlib.org/stable/api/index.html "Matplotlibリファレンス")
など