📈

Streamlit Elements を使ってドラッグ・サイズ変更可能なダッシュボードを作ってみた

2023/12/08に公開

この記事は、Retty Advent Calendar 2023、7日目の記事です🎄🎁 (1日遅れての投稿です > <)

サマリ

  • streamlit-elementsを使うと、ドラッグ・サイズ変換可能なダッシュボードを作ることができる

  • Streamlitだと、データの集計対象の絞り込み・表示/非表示の出し分けのコントロールも行いやすい

  • 実装した挙動はこんな感じ↓実際の挙動
    ※本記事の細かい実装内容はhttps://github.com/Igecchi/bq_streamlit/をご参照ください

  • ↓試せるようにしました! ※ダークモードの場合、一部表示崩れがありますmm
    📊こちらからアクセス📊

Streamlitとは?

Streamlitは、Pythonで実装されたオープンソースのWebアプリケーションのフレームワークです。
このフレームワークを使うことで、フロントエンドの経験がなくともPythonのみで、機械学習やデータの可視化を行ったWebアプリを作成することができます。

直近では、Snowflake上でStreamlitアプリをデプロイできるようになり、小耳にはさむ機会も増えています。

なおStreamlitの基本的な使い方に関しては、他の記事を探してみてください!

[参考になりそうな記事]
30 Days of Streamlit
Streamlit入門+応用 ~ データ分析Webアプリを爆速で開発する
Streamlit documentation(公式ドキュメント)
Streamlit | Snowflake Documentation

データの準備

まずは、Streamlitアプリでチャート作成するために必要なデータの準備から進めていきます。

サンプルデータの用意

今回は、BigQueryの公開データであるcovid19_open_dataを絞り込んで利用しました。
都度BQにクエリ発行したくなかったため、日本のデータに絞り込みcsvとしてデータを取り込みました。(抽出したデータはこちらからDLできます。)

ちなみにデータ取得に用いたクエリは下記です↓

select
  date
  , location_key
  , country_name
  , subregion1_name
  , subregion2_name
  , population
  , population_male
  , population_female
  , cumulative_confirmed
  , cumulative_deceased
  , cumulative_recovered
from `bigquery-public-data.covid19_open_data.covid19_open_data`
where country_name = 'Japan'

必要なパッケージのインストール

streamlitおよび、今回利用するstreamlit-elementsのパッケージをインストールします。
自分の環境では、下記のバージョンを利用しました。(※Pythonのバージョンは3.10.10)

pip install streamlit==1.27
pip install streamlit-elements==0.1

アプリケーションの作成

フォルダ構成

開発するコードを記載するstreamlit_app.pyと、読み込むデータを格納しているdata/test_covid19_analysis_japan.csvから構成されています。
※ファイル名は任意のものを設定可能です。

streamlit_app.py
data
 └─ test_covid19_analysis_japan.csv

コードの実装

実装するコードの全体像は下記となります。

コードの全体像
streamlit_app.py
## ----- Import Library(必要なライブラリのインポート) ----- ##
import pandas as pd
import streamlit as st
from datetime import datetime
from streamlit_elements import elements, dashboard, mui, nivo
from streamlit_elements import dashboard

## ----- Load Data & Process(データの読み込みと加工)----- ##
# Read csv file(csvの読み込み)
df = pd.read_csv('data/test_covid19_analysis_japan.csv', index_col=0)
df_dataset = df.reset_index(drop=True) # Reset index(indexの振り直し)

# Filter dataset by Japan and extract prefecture name list
# (データを日本に絞り込み、都道府県リストを抽出する)
df_dataset = df_dataset[(df_dataset['country_name']=='Japan')]
df_dataset = df_dataset.rename({'subregion1_name': 'prefecture_name'}, axis='columns')
prefecture_name_list = df_dataset['prefecture_name'].unique()
column_list = df_dataset.columns.to_list()

## Fill NaN with 0(null値を0埋めする)
df_dataset = df_dataset.fillna(0)
df_dataset_all = df_dataset.copy()


## ----- Sidebar(サイドバーの設定) ----- ##
## --- Input box(データ絞り込み用の入力欄の作成) --- ##
## Input box of prefecture_name(複数都道府県で絞り込みを行うための入力Boxの作成)
prefecture_name = st.sidebar.multiselect(
    'Prefecutre Name'
    , prefecture_name_list
    , default=['Tokyo']
    )

## Input box of Start day(集計期間の起点となる日付)
start_date = pd.to_datetime(st.sidebar.date_input('Start date', datetime(2020, 3, 1)))

## Input box of End day(集計期間の終点となる日付)
end_date = pd.to_datetime(st.sidebar.date_input('End date', datetime(2021, 12, 31)))
st.sidebar.write('------------------')

## --- Check box(グラフ・表の表示/非表示を出し分けるためのチェックボックス作成) --- ##
st.sidebar.write('Graph Check Box')
is_graph_active_confirmed = st.sidebar.checkbox('Show Confirmed Graph', value=True)
is_graph_active_deceased = st.sidebar.checkbox('Show Deceased Graph', value=True)
# is_graph_active_recovered = st.sidebar.checkbox('Show Recovered Graph', value=True)
# is_graph_active_pupulation = st.sidebar.checkbox('Show Pupulation Graph', value=True)
is_graph_active_male_pupulation = st.sidebar.checkbox('Show Male Pupulation Graph', value=True)
is_graph_active_female_pupulation = st.sidebar.checkbox('Show Female Pupulation Graph', value=True)

st.sidebar.write('------------------')
st.sidebar.write('Table Column Check Box')
is_table_active = st.sidebar.checkbox('Show Table', value=True)
column_list = st.sidebar.multiselect(
    'Show Table'
    , column_list
    , default=['country_name', 'prefecture_name', 'population', 'population_male', 'population_female', 'cumulative_confirmed', 'cumulative_deceased', 'cumulative_recovered']
    )

## ----- Dataset processing(データセットの加工・絞り込み) ----- ##
## -------- Dataset processing(データセットの加工・絞り込み) -------- ##
df_dataset['date'] = pd.to_datetime(df_dataset['date'])
# Filter dataset by first day of month(月初日のみ抽出)
df_dataset = df_dataset[(df_dataset['date'].dt.day == 1)]
## Filter dataset by selected prefecture_name&term(サイドバーで選択された都道府県・集計期間に絞り込み)
df_dataset = df_dataset[(df_dataset['prefecture_name'].isin(prefecture_name)) & (df_dataset['date'] >= start_date) & (df_dataset['date'] <= end_date)]
# Change date type to str(日付型だと想定通りの可視化ができなかったため、文字列型に変換)
df_dataset['date'] = df_dataset['date'].astype(str)

## duplicate dataset 1.for graph, 2.for table
df_dataset_graph = df_dataset.copy()
df_dataset_table = df_dataset.copy()

### -------- Graph Visualization setting(グラフの可視化を行うための設定) -------- ###
tmp = df_dataset_graph.groupby(['date']).sum()[['cumulative_confirmed']]
tmp['date'] = tmp.index
tmp = tmp.rename(columns={'date': 'x', 'cumulative_confirmed': 'y'})[['x', 'y']].to_json(orient='records')
tmp_data = [
        {
            "id": prefecture_name,
            "data": eval(tmp)
        }
    ]

## If you want to show detail data, you can use this code.
# st.write(tmp_data)

def create_data(y_data):
    tmp = df_dataset_graph.groupby(['date']).sum()[[y_data]]
    tmp['date'] = tmp.index
    tmp = tmp.rename(columns={'date': 'x', y_data: 'y'})[['x', 'y']].to_json(orient='records')
    return [
                {
                    "id": "+".join(prefecture_name),
                    "data": eval(tmp)
                }
            ]

def create_chart(KEYNAME, CARD_TITLE, INPUT_DATA):
    with mui.Card(key=KEYNAME, sx={"display": "flex", "flexDirection": "column"}):
                mui.CardHeader(title=CARD_TITLE, className="draggable")
                with mui.CardContent(sx={"flex": 1, "minHeight": 0}):
                    nivo.Line(
                        data=INPUT_DATA,
                        margin={ 'top': 10, 'right': 80, 'bottom': 90, 'left': 80 },
                        xScale={
                            'type': 'point',
                            'min': 'auto',
                            'max': 'auto',
                            'stacked': False,
                            'reverse': False
                        },
                        yScale={
                            'type': 'linear',
                            'min': 'auto',
                            'max': 'auto',
                            'stacked': True,
                            'reverse': False
                        },
                        yFormat=" >-,.2~d",
                        axisTop=None,
                        axisRight=None,
                        axisBottom={
                            'tickSize': 1,
                            'tickPadding': 1,
                            'tickRotation': -70,
                            'legend': '日付',
                            'legendOffset': 80,
                            'legendPosition': 'middle'
                        },
                        axisLeft={
                            'tickSize': 3,
                            'tickPadding': 3,
                            'tickRotation': 0,
                            'legend': 'count',
                            'legendOffset': -75,
                            'legendPosition': 'middle'
                        },
                        enableGridX=False,
                        enableGridY=False,
                        enablePoints=False,
                        pointSize=2,
                        pointColor={ 'theme': 'background' },
                        pointBorderWidth=1,
                        pointBorderColor={ 'from': 'serieColor' },
                        pointLabelYOffset=-7,
                        useMesh=True,
                        # -- 凡例の設定 -- #
                        legends=[
                            {
                                'anchor': 'top-left',
                                'direction': 'column',
                                'justify': False,
                                'translateX': 15,
                                'translateY': 0,
                                'itemsSpacing': 0,
                                'itemDirection': 'left-to-right',
                                'itemWidth': 80,
                                'itemHeight': 10,
                                'itemOpacity': 0.75,
                                'symbolSize': 7,
                                'symbolShape': 'circle',
                                'symbolBorderColor': 'rgba(0, 0, 0, .5)',
                                'effects': [
                                    {
                                        'on': 'hover',
                                        'style': {
                                            'itemBackground': 'rgba(0, 0, 0, .03)',
                                            'itemOpacity': 1
                                        }
                                    }
                                ]
                            }
                        ]
                    )

### ----- Graph Visualization(グラフの可視化) ----- ###
with elements("dashboard"):
    # default layout setting(デフォルトレイアウトの設定)
    layout = [
        # Parameters: element_identifier, x_pos, y_pos, width, height, [item properties...]
        dashboard.Item("confirmed_chart", 0, 0, 6, 3.5),
        dashboard.Item("deceased_chart", 6, 0, 6, 3.5),
        dashboard.Item("male_pupulation_chart", 0, 0, 6, 3.5),
        dashboard.Item("female_pupulation_chart", 6, 0, 6, 3.5),
    ]

    # IF check box is ON, show graph.(サイドバーでチェックボックスがONになっている場合のみグラフを表示)
    with dashboard.Grid(layout, draggableHandle=".draggable"):
        if is_graph_active_confirmed:
            dataset_graph_confirmed = create_data(y_data='cumulative_confirmed')
            create_chart(KEYNAME="confirmed_chart", CARD_TITLE="Confirmed Chart", INPUT_DATA=dataset_graph_confirmed)

        if is_graph_active_deceased:
            dataset_graph_deceased = create_data(y_data='cumulative_deceased')
            create_chart(KEYNAME="deceased_chart", CARD_TITLE="Deceased Chart", INPUT_DATA=dataset_graph_deceased)


        if is_graph_active_male_pupulation:
            dataset_graph_population_male = create_data(y_data='population_male')
            create_chart(KEYNAME="male_pupulation_chart", CARD_TITLE="Male Pupulation Chart", INPUT_DATA=dataset_graph_population_male)

        if is_graph_active_female_pupulation:
            dataset_graph_population_female = create_data(y_data='population_female')
            create_chart(KEYNAME="female_pupulation_chart", CARD_TITLE="Female Pupulation Chart", INPUT_DATA=dataset_graph_population_female)


### ----- Table Visualization(表の可視化) -----  ###
## Check box what index to show(Table)
if is_table_active:
    ## Show result as a table with scroll bar.
    st.dataframe(df_dataset_table[df_dataset_table['prefecture_name'].isin(prefecture_name)].groupby(['date']).sum()[column_list].T)


## ----- SQL Editor(SQLエディタの作成) ----- ##
conn = sqlite3.connect('data.db')
conn.commit()
df_dataset_table.to_sql('my_table', conn, if_exists='replace', index=False)

sql_editor_md = """
## SQL Editor
You can use SQL Editor.

Sample Query:
```sql
select * from my_table;
``` ``` ← 実装時は「```」のみとしてくださいmm
### ↓Enter your SQL query below
"""
st.markdown(sql_editor_md)
query = st.text_input('※Table Name is `my_table`')
if query:
    results = pd.read_sql_query(query, conn)
    st.write(results)

コードの解説

コードとUIの対応について画像を交えて説明します。

サイドバー(データの集計対象を絞り込む)

今回の実装では、サイドバーで選択した項目でデータのフィルタリングを行う方針としています。
後のコードにて、選択された値でフィルターをかけています。
サイドバー(都道府県と期間の指定)

## ----- Sidebar(サイドバーの設定) ----- ##
## --- Input box(データ絞り込み用の入力欄の作成) --- ##
## Input box of prefecture_name(複数都道府県で絞り込みを行うための入力Boxの作成)
prefecture_name = st.sidebar.multiselect(
    'Prefecutre Name'
    , prefecture_name_list
    , default=['Tokyo']
    )

## Input box of Start day(集計期間の起点となる日付)
start_date = pd.to_datetime(st.sidebar.date_input('Start date', datetime(2020, 3, 1)))

## Input box of End day(集計期間の終点となる日付)
end_date = pd.to_datetime(st.sidebar.date_input('End date', datetime(2021, 12, 31)))
st.sidebar.write('------------------')

サイドバー(グラフ・表の表示/非表示を出し分ける)

続いてグラフ・表の表示/非表示を出し分けるために、サイドバーにチェックボックスを作成します。こちらも後のコードにて、チェックボックスを入力されて得られたtrue/falseの値を元に、表示/非表示を出し分けるよう実装しています。

グラフの表示/非表示の出し分け

グラフの表示/非表示の出し分け

## --- Check box(グラフ・表の表示/非表示を出し分けるためのチェックボックス作成) --- ##
st.sidebar.write('Graph Check Box')
is_graph_active_confirmed = st.sidebar.checkbox('Show Confirmed Graph', value=True)
is_graph_active_deceased = st.sidebar.checkbox('Show Deceased Graph', value=True)
# is_graph_active_recovered = st.sidebar.checkbox('Show Recovered Graph', value=True)
# is_graph_active_pupulation = st.sidebar.checkbox('Show Pupulation Graph', value=True)
is_graph_active_male_pupulation = st.sidebar.checkbox('Show Male Pupulation Graph', value=True)
is_graph_active_female_pupulation = st.sidebar.checkbox('Show Female Pupulation Graph', value=True)

表の表示/非表示の出し分け

表の表示/非表示の出し分け

st.sidebar.write('------------------')
st.sidebar.write('Table Column Check Box')
is_table_active = st.sidebar.checkbox('Show Table', value=True)
column_list = st.sidebar.multiselect(
    'Show Table'
    , column_list
    , default=['country_name', 'prefecture_name', 'population', 'population_male', 'population_female', 'cumulative_confirmed', 'cumulative_deceased', 'cumulative_recovered']
    )

データセットの加工

pandasを用いて、データセットの加工・絞り込みを行います。
prefecture_name, start_date, end_dateは、前述のサイドバーで入力された変数が入ります。

## -------- Dataset processing(データセットの加工・絞り込み) -------- ##
df_dataset['date'] = pd.to_datetime(df_dataset['date'])
# Filter dataset by first day of month(月初日のみ抽出)
df_dataset = df_dataset[(df_dataset['date'].dt.day == 1)]
## Filter dataset by selected prefecture_name&term(サイドバーで選択された都道府県・集計期間に絞り込み)
df_dataset = df_dataset[(df_dataset['prefecture_name'].isin(prefecture_name)) & (df_dataset['date'] >= start_date) & (df_dataset['date'] <= end_date)]
# Change date type to str(日付型だと想定通りの可視化ができなかったため、文字列型に変換)
df_dataset['date'] = df_dataset['date'].astype(str)

## duplicate dataset 1.for graph, 2.for table
df_dataset_graph = df_dataset.copy()
df_dataset_table = df_dataset.copy()

グラフの可視化を行うための設定

今回はドラッグ・サイズ変更可能なグラフを作成したかったため、streamlit-elementsを利用しています。
現状のstreamlit-elementsでは、Reactのチャートライブラリであるnivoが採用されています。
※現状のstreamlit-elementsでは、streamlit標準のチャートをドラッグ・サイズ変更可能なコンポーネントに変更することはできないようでした。

したがって、nivoで必要とされているデータフォーマットへの修正とチャートの設定をここで行います。(設定については、Line chart | nivoをご覧ください。)

コード(長いため折りたたんでいます)
### -------- Graph Visualization setting(グラフの可視化を行うための設定) -------- ###
tmp = df_dataset_graph.groupby(['date']).sum()[['cumulative_confirmed']]
tmp['date'] = tmp.index
tmp = tmp.rename(columns={'date': 'x', 'cumulative_confirmed': 'y'})[['x', 'y']].to_json(orient='records')
tmp_data = [
        {
            "id": prefecture_name,
            "data": eval(tmp)
        }
    ]

## If you want to show detail data, you can use this code.
# st.write(tmp_data)

def create_data(y_data):
    tmp = df_dataset_graph.groupby(['date']).sum()[[y_data]]
    tmp['date'] = tmp.index
    tmp = tmp.rename(columns={'date': 'x', y_data: 'y'})[['x', 'y']].to_json(orient='records')
    return [
                {
                    "id": "+".join(prefecture_name),
                    "data": eval(tmp)
                }
            ]

def create_chart(KEYNAME, CARD_TITLE, INPUT_DATA):
    with mui.Card(key=KEYNAME, sx={"display": "flex", "flexDirection": "column"}):
                mui.CardHeader(title=CARD_TITLE, className="draggable")
                with mui.CardContent(sx={"flex": 1, "minHeight": 0}):
                    nivo.Line(
                        data=INPUT_DATA,
                        margin={ 'top': 10, 'right': 80, 'bottom': 90, 'left': 80 },
                        xScale={
                            'type': 'point',
                            'min': 'auto',
                            'max': 'auto',
                            'stacked': False,
                            'reverse': False
                        },
                        yScale={
                            'type': 'linear',
                            'min': 'auto',
                            'max': 'auto',
                            'stacked': True,
                            'reverse': False
                        },
                        yFormat=" >-,.2~d",
                        axisTop=None,
                        axisRight=None,
                        axisBottom={
                            'tickSize': 1,
                            'tickPadding': 1,
                            'tickRotation': -70,
                            'legend': '日付',
                            'legendOffset': 80,
                            'legendPosition': 'middle'
                        },
                        axisLeft={
                            'tickSize': 3,
                            'tickPadding': 3,
                            'tickRotation': 0,
                            'legend': 'count',
                            'legendOffset': -75,
                            'legendPosition': 'middle'
                        },
                        enableGridX=False,
                        enableGridY=False,
                        enablePoints=False,
                        pointSize=2,
                        pointColor={ 'theme': 'background' },
                        pointBorderWidth=1,
                        pointBorderColor={ 'from': 'serieColor' },
                        pointLabelYOffset=-7,
                        useMesh=True,
                        # -- 凡例の設定 -- #
                        legends=[
                            {
                                'anchor': 'top-left',
                                'direction': 'column',
                                'justify': False,
                                'translateX': 15,
                                'translateY': 0,
                                'itemsSpacing': 0,
                                'itemDirection': 'left-to-right',
                                'itemWidth': 80,
                                'itemHeight': 10,
                                'itemOpacity': 0.75,
                                'symbolSize': 7,
                                'symbolShape': 'circle',
                                'symbolBorderColor': 'rgba(0, 0, 0, .5)',
                                'effects': [
                                    {
                                        'on': 'hover',
                                        'style': {
                                            'itemBackground': 'rgba(0, 0, 0, .03)',
                                            'itemOpacity': 1
                                        }
                                    }
                                ]
                            }
                        ]
                    )

表の可視化

サイドバーで設定した内容をもとに表の表示/非表示・表示する行の絞り込みを行います。

### ----- Table Visualization(表の可視化) -----  ###
## Check box what index to show(Table)
if is_table_active:
    ## Show result as a table with scroll bar.
    st.dataframe(df_dataset_table[df_dataset_table['prefecture_name'].isin(prefecture_name)].groupby(['date']).sum()[column_list].T)

SQLエディタの作成

StreamlitではBigQueryやSnowflakeのようなSQLエディタを、GUI上に設定することもできるようです。
SQLエディタ

## ----- SQL Editor(SQLエディタの作成) ----- ##
conn = sqlite3.connect('data.db')
conn.commit()
df_dataset_table.to_sql('my_table', conn, if_exists='replace', index=False)

sql_editor_md = """
## SQL Editor
You can use SQL Editor.

Sample Query:
```sql
select * from my_table;
``` ``` ← 実装時は「```」のみとしてくださいmm
### ↓Enter your SQL query below
"""
st.markdown(sql_editor_md)
query = st.text_input('※Table Name is `my_table`')
if query:
    results = pd.read_sql_query(query, conn)
    st.write(results)

所感

Streamlit(+nivo)を用いることで、BIツールのように利用者がグラフや表のドラッグ、サイズ変更、表示/非表示の切り替えを行えるダッシュボードを作成できました。
今回の実装だと、データ可視化の部分はstreamlitではなくReactのチャートライブラリであるnivoに依存しているため、とっつきにくさや保守・運用観点での課題がありそうと感じました。

他のもっといい手段をご存知の方いれば、ぜひぜひコメント、DMいただけると嬉しいです!

GitHubで編集を提案

Discussion