Plotly DashとBootstrapでマルチページ

# 1. はじめに

[Streamlit](https://streamlit.io/)だとPlotlyのマウスホバー・クリック情報を活用したインタラクティブ可視化が難しそうだったので（詳しい方は情報いただけるとありがたいです）、[Plotly Dash](https://dash.plotly.com/)を勉強中です。

Dashでマルチページを作成したくて、色々調べたので備忘録です。

見栄えのよいナビゲーションバーをつけるために、[Bootstrap](https://getbootstrap.jp/docs/4.5/getting-started/theming/)を利用しました。

# 2. 参考にしたページ

https://qiita.com/myu65/items/6bab6c3bc9dc37eec9bb

https://dash.plotly.com/urls

https://qiita.com/Naoya_Study/items/851f4032fb6e2a5cd5ed

https://dash-bootstrap-components.opensource.faculty.ai/docs/components/nav/

# 3. 開発環境

- macOS Ventura
- Miniconda
- Python==3.11.6
- Dash==2.14.2
- Plotly==5.9.0
- dash-bootstrap-components==1.5.0

# 4. ライブラリインストール

```
% conda install -c conda-forge plotly
% conda install -c conda-forge dash
% conda install -c conda-forge dash-bootstrap-components
```

# 5. ディレクトリ構成

```
dash_multipage
├── app.py
└── pages
    ├── home.py
    ├── page1.py
    ├── page2.py
    └── page3.py
```

# 6. 動作イメージ（非公式）

![dash_multipage_image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3622965/f62b4d0e-e2b9-15b7-06a4-db6cdd468fbd.png)

使用に際してのイメージです。正確ではない可能性があります。

# 7. `.py`ファイルの内容

データにはPlotlyで取得できるデータセットを使用しました。

https://plotly.com/python-api-reference/generated/plotly.data.html

使用したデータセットは`iris`、`gapminder`、`wind`です。

```python:app.py
import dash
from dash import Dash, html, dcc
import dash_bootstrap_components as dbc

app = Dash(__name__,
           external_stylesheets=[dbc.themes.SANDSTONE],
           use_pages=True)

navbar = dbc.Nav(
                children=[dbc.NavItem(dbc.NavLink(page["name"], 
                                            href=page["relative_path"],
                                            class_name="nav-item")) for page in dash.page_registry.values()],
                id = "navibar",
                class_name = "navbar navbar-expand-lg bg-dark"
            )

app.layout = dbc.Container([
                        dcc.Location(id='url', refresh=False),
                        navbar,
                        html.Br(),
                        dash.page_container
                    ])

if __name__ == '__main__':
    app.run(debug=True)
```

BootstrapテンプレートにはSandStoneを利用しました。

https://bootswatch.com/sandstone/

クラス名は上のリンク先を参照しました。

`dash.register_page(__name__)`を宣言すると、`layout`変数に代入したレイアウトに従って、`app.py`の`dash.page_container`の場所に配置されるようです。どの.pyファイルを参照するかは、`dcc.Location`で定義してるっぽいです(`id='url'`)が、まだちゃんと理解しきれていないので、引き続き調べる予定です。

```python:home.py
import dash
from dash import html

dash.register_page(__name__, path='/')

layout = html.Div([
    html.H1('Dash Multipage Test'),
    html.Br(),
    html.Div([html.H3("Page1: Iris Data"),
              html.H3("Page2: Gapminder Data"),
              html.H3("Page3: Wind Data"),
              ])
])
```

```python:page1.py

import dash
from dash import html, dcc, callback, Input, Output
import plotly.express as px
from plotly.data import iris
import json

page_number = "page1"
data = iris()

dash.register_page(__name__)

layout = html.Div([
        html.H2("Scatter Plot of Iris data"),
        html.Div([dcc.Graph(id=f"{page_number}_scatter",
                            figure=px.scatter(data,
                                              x='sepal_length',
                                              y='sepal_width',
                                              color='species'))]),
        html.Br(),
        html.Div([html.P(id=f"{page_number}_json")])])


@callback(Output(f"{page_number}_json","children"),
              [Input(f"{page_number}_scatter","hoverData")])
def scatter_plot_hover_data(hover_data):
    if hover_data is None:
        return ""
    
    point_data = hover_data['points'][0]
    point_data_json = json.dumps(point_data)
    return point_data_json

```

ページの内容としては、`Iris`データの`sepal`の長さと幅をそれぞれ`x`、`y`として散布図を描き、マウスホバーした点の情報を`json`形式で記述するものとなっています。

シングルページの場合との違いは、`callback`を`app`オブジェクトのメソッドとして記述するのではなく、`callback`関数を`dash`からインポートしている点です。

参考ページにある通り、`callback`で使う各ページファイル内のidが被っているとエラーで動かないので、ページごとにidを変える必要があります。

`page2.py`と`page3.py`の内容は`page1.py`とほとんど同じなので割愛します。

# 8. 結果

最終的に、こんな感じになりました。

![2023-12-31.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3622965/38322f88-e1f7-f159-b3e1-f0a830415910.gif)

なかなか悪くない感じです。