💾

PyGWalkerダッシュボードを簡単に保存・共有するアプリを作りました

2024/03/21に公開

はじめに

以前PyGWalkerをStreamlitで使ってみる記事を書きましたが、その時に思いついたアイディアを実践してみました。

https://zenn.dev/0msys/articles/34a380d0af1269

やりたいことの背景

前回の記事で試してわかったこととして、PyGWalkerは作成したグラフを、「export_code」ボタンからコードをコピーして、それを起動時に読み込ませることで、グラフの再利用ができることがわかりました。

ただし、これを行うためには、出力したコードをPythonコードに貼り付けて再実行する必要があり、Pythonの環境とスキルが必要です。
自分一人で使う分にはこれでも問題ないのですが、そもそも自分で使うだけならExcelのPower Queryとピボットグラフを使った方が、できることも多いですし、より簡単に行えます。

せっかくStreamlitでWebアプリとして公開できるのですから、その強みを活かして、複数人(Pythonスキルの無い人含む)で共有できるようにしたいと思いました。

作ったもの

起動方法

以下のリポジトリにコードを置いています。
dockerを使用するので、事前にdocker desktop等をインストールしておいてください。

https://github.com/0msys/pygwalker-workspace

こちらをクローンして、以下のコマンドで起動します。

docker compose up -d

ビルドが終わったら、以下のURLにアクセスしてください。

http://localhost:8501

使い方

テンプレートの起動

アプリを開くと、以下のような画面が表示されます。

まだ何も作成していないので、「既存のダッシュボードを開く」の方には何も表示されません。

「新規ダッシュボードを作成」をクリックすると、以下のような画面が表示されます。

まず、この画面でダッシュボードのベースとなるテンプレートを選択します。
テンプレートとして以下の二つを用意しています。

  • template_1_csv.py : 1つのCSVファイルを読み込んで、グラフを作成するテンプレート
  • template_2_csv.py : 2つのCSVファイルを読み込んで、マージしたデータを作成し、グラフを作成するテンプレート

まずは、template_1_csv.pyを選択して、「テンプレートを開く」をクリックします。

すると、以下のような画面が表示されます。

グラフの作成

ここに適当なCSVファイルをアップロードすると、PyGWalkerが起動するのでグラフを作成します。
(ここではリポジトリにあるsample_data/test_data_1.csvを使っています)
とりあえず以下のように2つグラフを作成しました。

ダッシュボードの保存

グラフができたので「export_code」ボタンを押し、表示されたウィンドウの「Copy to Clipboard」ボタンを押します。

サイドバーにある「ダッシュボードの保存」に必要項目を入力し、「保存」ボタンを押します。
(アプリ上では作成したグラフ群を「ダッシュボード」と呼んでいます)

  • カテゴリ名 : ダッシュボードを分類するためのカテゴリ名
  • ダッシュボード名 : 作成したダッシュボードの名前
  • ダッシュボードの説明 : 作成したダッシュボードの説明
  • グラフのコード : 先ほどコピーしたコードを貼り付け

今回は以下のように入力しました。

  • カテゴリ名 : 実績
  • ダッシュボード名 : 販売数
  • ダッシュボードの説明 : 販売実績のCSVデータをアップロードすると、「店舗別」「品目別」でそれぞれ集計したグラフが表示されます。
  • グラフのコード :
    vis_spec = r"""{"config":[{"config":{"defaultAggregated":true,"geoms":["auto"],"coordSystem":"generic","limit":-1,"timezoneDisplayOffset":0},"encodings":{"dimensions":[{"dragId":"gw_DJJS","fid":"日付","name":"日付","basename":"日付","semanticType":"temporal","analyticType":"dimension","offset":0},{"dragId":"gw_duH3","fid":"品目","name":"品目","basename":"品目","semanticType":"nominal","analyticType":"dimension","offset":0},{"dragId":"gw_is6J","fid":"店舗","name":"店舗","basename":"店舗","semanticType":"nominal","analyticType":"dimension","offset":0},{"dragId":"gw_mea_key_fid","fid":"gw_mea_key_fid","name":"Measure names","analyticType":"dimension","semanticType":"nominal"}],"measures":[{"dragId":"gw_Zl_c","fid":"個数","name":"個数","basename":"個数","semanticType":"quantitative","analyticType":"measure","offset":0},{"dragId":"gw_count_fid","fid":"gw_count_fid","name":"Row count","analyticType":"measure","semanticType":"quantitative","aggName":"sum","computed":true,"expression":{"op":"one","params":[],"as":"gw_count_fid"}},{"dragId":"gw_mea_val_fid","fid":"gw_mea_val_fid","name":"Measure values","analyticType":"measure","semanticType":"quantitative","aggName":"sum"}],"rows":[{"dragId":"gw_DJZh","fid":"個数","name":"個数","basename":"個数","semanticType":"quantitative","analyticType":"measure","offset":0,"aggName":"sum"}],"columns":[{"dragId":"gw_jh0h","fid":"店舗","name":"店舗","basename":"店舗","semanticType":"nominal","analyticType":"dimension","offset":0,"sort":"descending"}],"color":[],"opacity":[],"size":[],"shape":[],"radius":[],"theta":[],"longitude":[],"latitude":[],"geoId":[],"details":[],"filters":[],"text":[]},"layout":{"showActions":false,"showTableSummary":false,"stack":"stack","interactiveScale":false,"zeroScale":true,"size":{"mode":"full","width":320,"height":200},"format":{},"geoKey":"name","resolve":{"x":false,"y":false,"color":false,"opacity":false,"shape":false,"size":false}},"visId":"gw_NSKd","name":"店舗別"},{"config":{"defaultAggregated":true,"geoms":["auto"],"coordSystem":"generic","limit":-1,"timezoneDisplayOffset":0},"encodings":{"dimensions":[{"dragId":"gw_DJJS","fid":"日付","name":"日付","basename":"日付","semanticType":"temporal","analyticType":"dimension","offset":0},{"dragId":"gw_duH3","fid":"品目","name":"品目","basename":"品目","semanticType":"nominal","analyticType":"dimension","offset":0},{"dragId":"gw_is6J","fid":"店舗","name":"店舗","basename":"店舗","semanticType":"nominal","analyticType":"dimension","offset":0},{"dragId":"gw_mea_key_fid","fid":"gw_mea_key_fid","name":"Measure names","analyticType":"dimension","semanticType":"nominal"}],"measures":[{"dragId":"gw_Zl_c","fid":"個数","name":"個数","basename":"個数","semanticType":"quantitative","analyticType":"measure","offset":0},{"dragId":"gw_count_fid","fid":"gw_count_fid","name":"Row count","analyticType":"measure","semanticType":"quantitative","aggName":"sum","computed":true,"expression":{"op":"one","params":[],"as":"gw_count_fid"}},{"dragId":"gw_mea_val_fid","fid":"gw_mea_val_fid","name":"Measure values","analyticType":"measure","semanticType":"quantitative","aggName":"sum"}],"rows":[{"dragId":"gw_DJZh","fid":"個数","name":"個数","basename":"個数","semanticType":"quantitative","analyticType":"measure","offset":0,"aggName":"sum"}],"columns":[{"dragId":"gw_QMuf","fid":"品目","name":"品目","basename":"品目","semanticType":"nominal","analyticType":"dimension","offset":0,"sort":"descending"}],"color":[],"opacity":[],"size":[],"shape":[],"radius":[],"theta":[],"longitude":[],"latitude":[],"geoId":[],"details":[],"filters":[],"text":[]},"layout":{"showActions":false,"showTableSummary":false,"stack":"stack","interactiveScale":false,"zeroScale":true,"size":{"mode":"full","width":320,"height":200},"format":{},"geoKey":"name","resolve":{"x":false,"y":false,"color":false,"opacity":false,"shape":false,"size":false}},"visId":"gw_dO62","name":"品目別"}],"chart_map":{},"workflow_list":[{"workflow":[{"type":"view","query":[{"op":"aggregate","groupBy":["店舗"],"measures":[{"field":"個数","agg":"sum","asFieldKey":"個数_sum"}]}]}]},{"workflow":[{"type":"view","query":[{"op":"aggregate","groupBy":["品目"],"measures":[{"field":"個数","agg":"sum","asFieldKey":"個数_sum"}]}]}]}],"version":"0.4.7"}"""
    

「保存しました」と表示されたら、サイドバーの一番上にある「Homeに戻る」ボタンから、最初の画面に戻ります。

すると、以下のように「既存のダッシュボードを開く」に、先ほど作成したダッシュボードが表示されます。

保存したダッシュボードを開く

「ダッシュボードを開く」ボタンを押すと、先ほどのテンプレートを開いた時と同じような画面が表示されます。

違いとしては、「使い方」として先ほど保存したダッシュボードの説明が表示されていることです。

この画面でCSVデータをアップロードすると、先ほど作成したグラフが表示され、保存したダッシュボードが再利用できることがわかります。

これにより、販売実績のCSVデータから、店舗別、品目別のグラフを簡単に作成できるようになりました。

当然ですが、先ほどアップロードしたCSVファイルと異なる形式のCSVファイルをアップロードすると、エラーが表示されます。

ダッシュボードに変更を加えたい場合は、変更後に再度「export_code」ボタンを押し、コードをコピーして、サイドバーの「ダッシュボードの保存」から保存し直すことで、変更を反映できます。

別のテンプレートを使う

さすがにこれだけだと、Excelとあまり差が無いので、もう一つのテンプレートを使ってみます。

「Homeに戻る」ボタンを押し、再度「新規ダッシュボードを作成」をクリックし、今度はtemplate_2_csv.pyを選択して、「テンプレートを開く」をクリックします。

すると、以下のような画面が表示されます。

先ほどのファイルに加えて、sample_data/sample_data/test_data_2.csvをアップロードします。
すると以下のようにエラー画面が表示されます。

template_2_csv.pyは、2つのCSVファイルをマージしてグラフを作成するテンプレートなのですが、マージの起点となる選択中のカラムの内容が、まったく一致しないため、エラーが表示されているだけです。

sample_data/test_data_2.csvは、品目ごとの単価が記載されているので、カラムの選択を両方とも「品目」に変更し、マージの方法を「left」に変更します。
(left joinを行うことで、test_data_1.csvにあるデータを全て残しつつ、test_data_2.csvにあるデータを結合します)
こうすることでエラーが解消され、グラフが表示されます。

結合後のデータを使って、先ほどのグラフに売上金額を追加したものを作成しました。

売上金額の算出方法ですが、「Add Computed Field」ボタンを押し、以下のように設定しました。

このダッシュボードも、先ほどと同様に保存します。

  • カテゴリ名 : 実績
  • ダッシュボード名 : 販売数・売上金額
  • ダッシュボードの説明 : 1つ目のファイルは販売実績をアップロードしてください。2つ目のファイルは品目ごとの単価データをアップロードしてください。「店舗別」「品目別」でそれぞれ集計した販売数と売上金額のグラフが表示されます。
  • グラフのコード :
    vis_spec = r"""{"config":[{"config":{"defaultAggregated":true,"geoms":["auto"],"coordSystem":"generic","limit":-1,"timezoneDisplayOffset":0},"encodings":{"dimensions":[{"dragId":"gw_qpjp","fid":"日付","name":"日付","basename":"日付","semanticType":"temporal","analyticType":"dimension","offset":0},{"dragId":"gw_WEjn","fid":"品目","name":"品目","basename":"品目","semanticType":"nominal","analyticType":"dimension","offset":0},{"dragId":"gw_bt65","fid":"店舗","name":"店舗","basename":"店舗","semanticType":"nominal","analyticType":"dimension","offset":0},{"dragId":"gw_mea_key_fid","fid":"gw_mea_key_fid","name":"Measure names","analyticType":"dimension","semanticType":"nominal"}],"measures":[{"dragId":"gw_lzWR","fid":"個数","name":"個数","basename":"個数","semanticType":"quantitative","analyticType":"measure","offset":0},{"dragId":"gw_WGGb","fid":"単価","name":"単価","basename":"単価","analyticType":"measure","semanticType":"quantitative","aggName":"sum","offset":0},{"analyticType":"measure","dragId":"gw_pXj4","fid":"gw_pXj4","name":"売上","semanticType":"quantitative","computed":true,"aggName":"sum","expression":{"op":"expr","as":"gw_pXj4","params":[{"type":"sql","value":"\"単価\" * \"個数\""}]}},{"dragId":"gw_count_fid","fid":"gw_count_fid","name":"Row count","analyticType":"measure","semanticType":"quantitative","aggName":"sum","computed":true,"expression":{"op":"one","params":[],"as":"gw_count_fid"}},{"dragId":"gw_mea_val_fid","fid":"gw_mea_val_fid","name":"Measure values","analyticType":"measure","semanticType":"quantitative","aggName":"sum"}],"rows":[{"dragId":"gw_-pLJ","fid":"個数","name":"個数","basename":"個数","semanticType":"quantitative","analyticType":"measure","offset":0,"aggName":"sum"},{"analyticType":"measure","dragId":"gw_AQdt","fid":"gw_pXj4","name":"売上","semanticType":"quantitative","computed":true,"aggName":"sum","expression":{"op":"expr","as":"gw_pXj4","params":[{"type":"sql","value":"\"単価\" * \"個数\""}]}}],"columns":[{"dragId":"gw_iZzS","fid":"店舗","name":"店舗","basename":"店舗","semanticType":"nominal","analyticType":"dimension","offset":0}],"color":[],"opacity":[],"size":[],"shape":[],"radius":[],"theta":[],"longitude":[],"latitude":[],"geoId":[],"details":[],"filters":[],"text":[]},"layout":{"showActions":false,"showTableSummary":false,"stack":"stack","interactiveScale":false,"zeroScale":true,"size":{"mode":"full","width":320,"height":200},"format":{},"geoKey":"name","resolve":{"x":false,"y":false,"color":false,"opacity":false,"shape":false,"size":false}},"visId":"gw_F7-x","name":"店舗別"},{"config":{"defaultAggregated":true,"geoms":["auto"],"coordSystem":"generic","limit":-1,"timezoneDisplayOffset":0},"encodings":{"dimensions":[{"dragId":"gw_qpjp","fid":"日付","name":"日付","basename":"日付","semanticType":"temporal","analyticType":"dimension","offset":0},{"dragId":"gw_WEjn","fid":"品目","name":"品目","basename":"品目","semanticType":"nominal","analyticType":"dimension","offset":0},{"dragId":"gw_bt65","fid":"店舗","name":"店舗","basename":"店舗","semanticType":"nominal","analyticType":"dimension","offset":0},{"dragId":"gw_mea_key_fid","fid":"gw_mea_key_fid","name":"Measure names","analyticType":"dimension","semanticType":"nominal"}],"measures":[{"dragId":"gw_lzWR","fid":"個数","name":"個数","basename":"個数","semanticType":"quantitative","analyticType":"measure","offset":0},{"dragId":"gw_WGGb","fid":"単価","name":"単価","basename":"単価","analyticType":"measure","semanticType":"quantitative","aggName":"sum","offset":0},{"analyticType":"measure","dragId":"gw_pXj4","fid":"gw_pXj4","name":"売上","semanticType":"quantitative","computed":true,"aggName":"sum","expression":{"op":"expr","as":"gw_pXj4","params":[{"type":"sql","value":"\"単価\" * \"個数\""}]}},{"dragId":"gw_count_fid","fid":"gw_count_fid","name":"Row count","analyticType":"measure","semanticType":"quantitative","aggName":"sum","computed":true,"expression":{"op":"one","params":[],"as":"gw_count_fid"}},{"dragId":"gw_mea_val_fid","fid":"gw_mea_val_fid","name":"Measure values","analyticType":"measure","semanticType":"quantitative","aggName":"sum"}],"rows":[{"dragId":"gw_-pLJ","fid":"個数","name":"個数","basename":"個数","semanticType":"quantitative","analyticType":"measure","offset":0,"aggName":"sum"},{"analyticType":"measure","dragId":"gw_AQdt","fid":"gw_pXj4","name":"売上","semanticType":"quantitative","computed":true,"aggName":"sum","expression":{"op":"expr","as":"gw_pXj4","params":[{"type":"sql","value":"\"単価\" * \"個数\""}]}}],"columns":[{"dragId":"gw_I630","fid":"品目","name":"品目","basename":"品目","semanticType":"nominal","analyticType":"dimension","offset":0,"sort":"descending"}],"color":[],"opacity":[],"size":[],"shape":[],"radius":[],"theta":[],"longitude":[],"latitude":[],"geoId":[],"details":[],"filters":[],"text":[]},"layout":{"showActions":false,"showTableSummary":false,"stack":"stack","interactiveScale":false,"zeroScale":true,"size":{"mode":"full","width":320,"height":200},"format":{},"geoKey":"name","resolve":{"x":false,"y":false,"color":false,"opacity":false,"shape":false,"size":false}},"visId":"gw_Ll4h","name":"品目別"}],"chart_map":{},"workflow_list":[{"workflow":[{"type":"transform","transform":[{"key":"gw_pXj4","expression":{"op":"expr","as":"gw_pXj4","params":[{"type":"sql","value":"(\"単価\" * \"個数\")"}]}}]},{"type":"view","query":[{"op":"aggregate","groupBy":["店舗"],"measures":[{"field":"個数","agg":"sum","asFieldKey":"個数_sum"},{"field":"gw_pXj4","agg":"sum","asFieldKey":"gw_pXj4_sum"}]}]}]},{"workflow":[{"type":"transform","transform":[{"key":"gw_pXj4","expression":{"op":"expr","as":"gw_pXj4","params":[{"type":"sql","value":"(\"単価\" * \"個数\")"}]}}]},{"type":"view","query":[{"op":"aggregate","groupBy":["品目"],"measures":[{"field":"個数","agg":"sum","asFieldKey":"個数_sum"},{"field":"gw_pXj4","agg":"sum","asFieldKey":"gw_pXj4_sum"}]}]}]}],"version":"0.4.7"}"""
    

Homeに戻ると、先ほど保存したダッシュボードが一覧に表示されていることがわかります。

このように、テンプレートの工夫次第で、様々なダッシュボードを作成し、保存・共有することができます。
売上金額のカラムのように、追加したカラムも保存されていますので、何度も同じ作業を繰り返す必要がありません。

仕組みの概要

新規ダッシュボードの作成から保存を行うと、以下の情報がDBに保存されます。
(DBにはSQLiteを使用しています)

  • カテゴリ名
  • ダッシュボード名
  • ダッシュボードの説明
  • グラフのコード
  • templateのファイル名
  • パラメータ

カテゴリ名とダッシュボード名は、ダッシュボードを一意に特定するための情報です。
ダッシュボードの説明は、使い方を記載するための情報です。
グラフのコードは、export_codeボタンを押した際にコピーしたコードです。
templateのファイル名は、ダッシュボードを作成した際に選択したテンプレートのファイル名です。
パラメータは、templateのファイル名によって異なりますが、template_2_csv.pyの場合は、マージの起点となるカラムの選択とマージ方法の選択が保存されます。

保存されたダッシュボードを開く際は、まずこれらの情報をDBから取得し、st.session_stateに保存します。

次にtemplateのファイル名に対応するページに遷移し、st.session_stateに保存された情報を使って、ダッシュボードを作成します。
この時テンプレートとしてページが開かれたのか、保存されたダッシュボードが開かれたのかを判別するために、st.session_state.template_modeに、True/Falseを保存しています。

遷移したtemplateページは、st.session_stateに保存されている情報を使い、新規作成か既存のダッシュボードを開くかを判別し、それぞれの処理を行います。

templateの作り方

このアプリはtemplateを追加することで、利便性が大幅に向上すると思っています。

例えば、DBにSQLを発行してデータを取得するテンプレートや、クラウド上のデータを取得するテンプレートなど、データが集まっているところへのアクセス部分をテンプレートとして作成することで、データ分析の効率が大幅に向上すると思います。

自分としては、業務でAWSを使用しているため、AWSのCloudWatch Logs Insightsのデータを取得するテンプレートを作成してみたいと思っています。
ログデータを取得してその場で分析できると、障害対応などが大幅に効率化できますし、それらを共有することでチーム全体の効率化・スキル平準化にもつながります。

リポジトリに_template_base.pyというファイルを用意していますので、これをコピーして、新しいテンプレートを作成してみてください。

中身は以下のようになっています。

_template_base.py
from pygwalker.api.streamlit import init_streamlit_comm
import pandas as pd
import streamlit as st

from utils import get_pyg_renderer, save_dashboard


st.set_page_config(page_title="PyGWalker WorkSpace", layout="wide")

# Homeに戻るリンクを追加
st.sidebar.page_link(page="Home.py", label="Homeに戻る")

# PyGWalkerとStreamlitの通信を確立する
init_streamlit_comm()

if st.session_state.template_mode:
    # テンプレートモードの場合、specをデフォルトに設定する
    spec = "./gw_config.json"

else:
    # テンプレートモードでない場合、specをセッションから取得し、使い方を表示する
    spec = st.session_state.spec
    st.markdown(f"### 使い方\n\n{st.session_state.discription}")


# ==============================================
# ここにPyGWalkerに渡すdfを作成するコードを記述する。


# ==============================================

renderer = get_pyg_renderer(df, spec)

# データ探索インターフェースをレンダリングする。
renderer.render_explore()

# ダッシュボードの保存UIを表示
# ダッシュボードの表示に必要なパラメータが有れば、それをdbに保存する
st.session_state.params = {
    "param1": param1,
    "param2": param2,
    "param3": param3,
}
save_dashboard(st.session_state.params)

template_1_csv.pyとtemplate_2_csv.pyを参考にしてもらえれば、どのように作成すれば良いかがわかると思います。
もしわからないことがあれば、コメントで聞いていただければ、お力になれるかもしれません。

まとめ

PyGWalkerのダッシュボードを簡単に保存・共有するアプリを作成しました。

突貫工事なところも多いですが、割と業務でも使えるものだと思うので、もし興味があれば試してみてください。

Discussion