【Python】jupyterでplotlyのクリックイベントを起こす




pythonでjupyter notebook上でplotlyを使ってこんなことが出来ます。
![first.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/49e6bf6f-2e6b-41ea-77b6-860a28be9ca3.gif)
plotlyで散布図をクリックしたときに何か起こせないの気持ち悪いなあ、JavaScript使わないとできないの不便だなあ、ということを1年くらい思っていました。Pythonだけでできました。私が知らなかっただけでした。

なかなか便利そうな匂いがしますが、公式のドキュメントにもあまり情報がないみたいです[^doc_example]。ということで、**これを使えるようになることがこの記事の目標です**。
とりあえずコード全体はこちらになります。以下一歩一歩見ていきます。

[^doc_example]: https://plotly.com/python/click-events/ 。こちらはこのドキュメントとほぼ同じ例です。ただし色とデータだけ変えています。


```Python
df = pd.read_csv('./data/articles.csv')
fig = go.FigureWidget(px.scatter(df, x='page_views_count', y='likes_count'))

n = len(df)
scatter = fig.data[0]
colors = ['blue'] * n
scatter.marker.color = colors
scatter.marker.size = [10] * n

def update_point(trace, points, selector):
    c = list(scatter.marker.color)
    s = list(scatter.marker.size)
    for i in points.point_inds:
        c[i] = 'red'
        s[i] = 20
        with fig.batch_update():
            scatter.marker.color = c
            scatter.marker.size = s

scatter.on_click(update_point)
fig
```

# 環境
Windows 11
Python 3.8.5
jupyter 1.0.0[^env]
plotly 5.7.0

[^env]: 以下で述べる通りipywidgetsを用いているので、Google colaboratoryでは使用できないみたいです。jupyter Labでは使用できますが、extensionとかの設定がいるかもしれません (https://plotly.com/python/getting-started/#jupyterlab-support) 。


# 使用データ
~~本記事はdelikaさんの下記イベントに参加しています~~。参加するつもりだったのですが、いつのまにかGWが終わり５月も中旬になってました。おかしいなぁ。
データはイベントで用意されているQiitaの投稿に関するダミーデータを用います[^dataurl]。

https://qiita.com/official-events/30be12dd14c0aad2c1c2

[^dataurl]: https://delika.io/qiita_delika_article_campaign/QiitadelikaDummy


# Widgetについて理解する
plotlyのこの機能はipywidgetsを使っています[^ipywidget]。ipywidgetはこんな感じのことができます

[^ipywidget]: ipywidgetsについての詳細は「[Python: ipywidgets で Jupyter に簡単な UI を作る](https://blog.amedama.jp/entry/ipywidgets-jupyter-ui) 」などが参考になります。


```Python
import ipywidgets as widgets

def on_click_callback(clicked_button):
    print('Clicked')

button = widgets.Button(description='Click me')
button.on_click(on_click_callback)
button
```

![button.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/bae166da-9bac-0136-3ca8-b456b40d3132.gif)


`on_click` というメソッドで、クリックしたときに呼び出されるcallback関数を登録することができます。この例だと、ボタンをclickしたときに `on_click_callback` というコールバック関数が呼び出されるわけです。

plotlyでは、**go.FigureWidget**を使うことでWidgetとして動かせる（＝クリックイベントを起こせる）グラフを作れます。FigureWidgetのTrace（ `fig.data` でアクセスできるScatterなどのgraph_objs。今回の例では`scatter`）が `on_click` というメソッドを持っていて、この例のボタンと同じように機能します。

```Python
fig = go.FigureWidget(px.scatter(df, x='page_views_count', y='likes_count'))
scatter = fig.data[0]

def update_point(trace, points, selector):
    pass

scatter.on_click(update_point)
```


# callback関数を理解する
ではcallback関数を見ていきましょう。なんとなく色とサイズを変えていることはイメージできると思います。
引数から説明していきましょう。

```Python
def update_point(trace, points, selector):
    c = list(scatter.marker.color)
    s = list(scatter.marker.size)
    for i in points.point_inds:
        c[i] = 'red'
        s[i] = 20
        with fig.batch_update():
            scatter.marker.color = c
            scatter.marker.size = s
```

### callbackの引数①：trace
traceは `scatter.on_click(update_point)` の `scatter` に相当するものです[^trace]。

[^trace]: そのため、 `update_point` 関数において `scatter` を `trace` で書き換えても同じ挙動をします。また、 `update_point` を次のように変えてクリックしてもエラーは起きません：`def update_point(trace, points, selector): assert trace == scatter`


### callbackの引数②：points
こちらはクリックされたポイントの情報が入ります。 `update_point` を以下のように書き換えて適当な点をクリックすると
```Python
def update_point_example(trace, points, selector):
    print(points)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/35046347-09e1-9590-8020-52c907ceec78.png)
このような内容が出力されます。これは、398行目をクリックして、そのxの値（page_views_count）が484、yの値（likes_count）が1177ということを示しています[^other_points]。

[^other_points]:  `trace_name` は特に定義してないので空です。 `trace_index` は、 `f` はtraceをひとつしか持ってないので0になってます。それにしても（ダミーデータだからですが）likesの数がPV数を上回るというおそろしい記事ですね。

`update_point` の例だと、このクリックした `points` に該当する点の色とサイズを `point_inds` 経由で指定しています。ちなみに `on_click` だと一度にクリックされるポイントの数は高々ひとつだと思うので、for文で回している理由はあまりないです[^for_reason]。
[^for_reason]: 後述する `on_selection` とかだとこれがありがたいので、このまま使っています。


### callbackの引数③：selector
これは今回の例では使われていませんが、先ほどと同じようにprintして中身を確かめましょう[^selector]。すると、以下が出力されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/4fd10482-5875-c512-527f-bff7e5deb3e7.png)
ctrlやaltなどが出てくるので、もしやと思ってctrlを押しながらクリックすると、 `ctrl=True` に変わります。つまり、押し方よって挙動を変えられるという訳です[^selector_win]。
ちなみに button、buttonsは右クリックをすると値が変わりました。metaはちょっとよく分からなかったです。

[^selector]: `def update_point_example(trace, points, selector): print(selector)`
[^selector_win]: Windowsでやってます。Macだとctrlがcommandとかになるんですかね？


### batch_updateは何をしてるか
複数の処理を一度に行うために `batch_update` を使います。 `batch_update` がない、以下のgifを見比べると分かりやすいです。
<img width="45%" alt="no_batch.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/967bba08-4ce1-011e-4cfd-95b77f527944.gif">
2段階で処理がなされていることが分かります。これをいっぺんに変えるのが `batch_update` の役割です。


----

これで、冒頭に述べた例がどういう仕組みで動いているか理解できたと思います。

**注意点！！**
plotlyでは `fig.show()` と書くことが一般的ですが、FigureWidgetの場合は単に `fig` あるいは、 `display(fig)` とする必要があります（`fig.show()` とすると、クリックしても何も反応しないグラフが表示されます）。

以下、色々なTipsや応用例などをご紹介していきます。





# 複数のtraceがある場合どうすればいいか
plotlyを使っていると、Traceが複数ある図を作るケースも多くあります。たとえば、plotly.expressで `color` を指定すると複数Traceになります。先ほどみたように `on_click` はTraceの中身に対して呼ぶものなので、for文で全部に対して `on_clike` を定義してあげればOKです。

```Python
df['type'] = np.random.randint(0, 2, len(df))
fig = go.FigureWidget(px.histogram(df, x='likes_count', color='type'))
# len(fig.data) # 2

def update_point(trace, points, selector):
    print('clicked')

for hist in fig.data:
    hist.on_click(update_point)
fig
```

ただしひとつ罠があります。上の例だと、このようになってしまいます。
![for.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/43e81cbf-294e-f7a2-5ca5-8db8a7459f17.gif)
1回のクリックに対して、Traceの数ぶんだけcallback関数が呼ばれるため、ここでは2回 `print('clicked')` が実行されてしまっています。

これはクリックした点や棒を持たないTraceもcallback関数を呼び出すことが原因です。したがって対処するには、`if points.point_inds:` とか `for point in points.point_inds:` とかで、空のpointに対しては何も動かさないようにcallback関数を書き換えてあげればOKです[^arg_name]。

[^arg_name]: この例だと`point`が適当な命名なのかは微妙なところですが、先ほどと変えても混乱しそうなのでこのままにしてます。

```Python
def update_point(trace, points, selector):
    if points.point_inds:
        print('clicked')
```


# figureの見た目を変える以外の使い方
いくらでもあると思いますが、自分がパッと思いついたものを羅列しておきます。他にもアイデアなどあったらぜひコメントで教えてください！

### 複数のグラフを連動させる
https://junkudo-event-20210112.readthedocs.io/ja/latest/callback_example.html

こちらで紹介されている例です。上のグラフで日付を選ぶと、その日のデータが下の左右のグラフに出力されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/985382f4-6b6f-0a17-c9fb-8f8a728e1bc3.png)


### hoverで表示している内容をcopyする
plotlyの使いずらいポイントのひとつに、hoverされた情報をコピーできない点があります
pyperclipとかを使えば対処できます。

```Python
import pyperclip as pc

def update_point(trace, points, selector):
    for i in points.point_inds:
        pc.copy(i)
        print('copied!')
```

### urlを開く

webbrowserを使ってクリックしたら開くようにするとか、
```Python
import webbrowser

def update_point(trace, points, selector):
    webbrowser.open('http://example.com')
```
リンク付きのHTMLを表示するとかができそうです。
```Python
from IPython.display import HTML

def update_point(trace, points, selector):
    link_text = f'<a href={url}>open link</a>'
    display(HTML(link_text))
```

### 一度出力を消す
printとかを多用してると溜まって見にくいので、こうすることができます（が、レスポンスは悪いのでおそらく微妙です）。これまでにクリックした一連のものを対象にしたdataframeを出力するとかができます。

```Python
from IPython.display import clear_output

def update_point(trace, points, selector):
    # 一旦出力を消す
    clear_output(True)
    # figureを再表示
    display(fig)
    print('clicked')
```


# jupyter以外で使う
ipywidgetを用いているので、jupyter以外での使用は私の知る限り難しいと思います。JupyterLabでは使用できますが、extensionとかの設定がいるかもしれません[^lab]。Google Colab だとできないみたいです[^colab]

[^lab]: https://plotly.com/python/getting-started/#jupyterlab-support
[^colab]: https://github.com/plotly/plotly.py/issues/2202


Dashで似たようなことをやる方法は以下で紹介されています。

https://stackoverflow.com/questions/62235672/how-to-apply-a-dash-callback-decorator-appropriately-to-a-plotly-figurewidget-th

streamlitでは、以下を使うとできるかもですが、本記事で紹介する方法とは別物と思われます（特に確認などは出来てません汗）。

https://github.com/null-jones/streamlit-plotly-events






# クリック以外のイベント
### on_hover
```Python
scatter.on_hover(update_point)
fig
```
![hover.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/c1236f97-f4a7-6510-c4e2-1a791799c716.gif)


クリックでなく、ホバーしたときにアクションが起こるようになります。

### on_unhover
※ on_hoverとほぼ同じなので略

### on_selection
plotlyのレイアウトの `dragmode` を変更して利用します。矩形で選択したものを対象にします。`update_point` 関数のなかでfor文を使って点を取り出していたのは、これでも同じように使えるというメリットがあります。
```Python
fig.update_layout(dragmode="select") 
scatter.on_selection(update_point)
fig
```
![selection.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/546b1b63-be55-1e65-b269-c50e9863c870.gif)

### on_deselect
```Python
def deselect_point(trace, points):
    print('deselected')

fig.update_layout(dragmode="select") 
scatter.on_deselect(deselect_point)
fig
```
同じくdragmodeにて、selectionが解消された時に実行されます。この場合、callback関数は選択していた対象は受け取らないため `points` は空になります。また `selector` も受け取らないことに注意です。

### on_change

```Python
def zoom(layout, x_range):
    print('xaxis updated, new range is: ', x_range)

fig.layout.on_change(zoom, 'xaxis.range')
fig
```
![change.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/665792/54a9c650-61f6-0989-9a4e-4f57c2a8ac95.gif)
こいつだけ若干異質で、何かが変わったことに駆動されてcallback関数が実行されます。`on_change` の一つ目の引数がcallback関数で、二つ目の引数に「何が変わったときにcallback関数を呼ぶか」を指定します。ここではx軸の範囲aが変わったときをトリガーにしています。


# 最後に
色々できて面白いですね。jupyter以外で使えなさそうなので、使いどころは難しい気もしますが。最後に、リサーチ中に発掘したplotly, matplotlibのあまり知られていなさそうな情報を共有して終わりにします！

jupyterでならmatplotlibでもインタラクティブなグラフは作れるみたいです。

https://qiita.com/bpzAkiyama/items/7d50f0e1ef1e262df984

しかも点自体を動かせるプロットも可能とのこと。

https://matplotlib.org/stable/gallery/event_handling/poly_editor.html

plotlyはお絵かきできる機能もあるんですね。

https://plotly.com/python/configuration-options/