streamlitを用いたデータ可視化Webアプリの作成

### 概要
Pythonのstreamlitライブラリを使ってデータ可視化webアプリを作成した
### はじめに
普段、実験で出力されるのはcsvデータで、データ加工、解析、可視化もろもろはすべてExcelアプリで行っている
ここで問題なのは、csvデータが大きい(測定項目が多い、時間が長い、サンプリングが多い)と読込に時間がかかるし、グラフで表示する際に、固まってしまう。そして、ほかのExcel作業ができなくなる。
これを何とかしたいと思い、データ加工/解析はExcel関数が便利だなぁ感じるので、まずは、データ可視化部分について専用アプリが作れないか考えてみた

#### アプリ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940191/132be491-3187-4385-9fa6-8c9164ad9c6c.png)

ファイルのアップロード、表示ラベルの選択、グラフ操作など、すべてをマウスのみで作業が可能！


### コード
```streamlit_app.py
import streamlit as st
import pandas as pd
import plotly.graph_objects as go
from plotly.colors import DEFAULT_PLOTLY_COLORS
import re
import math
import json
import io


# データ読込み_ファイルをキャッシュ対象として処理
@st.cache_data
def load_data(file_bytes):
    # BytesIOに変換してから読み込む
    if uploaded_file.name.endswith(".csv"):
        df = pd.read_csv(io.BytesIO(file_bytes))
    elif uploaded_file.name.endswith(".xlsx"):
        df = pd.read_excel(io.BytesIO(file_bytes))

    return df

# RGB -> HEX 変換関数
def rgb_to_hex(rgb_string):
    match = re.match(r'rgb\((\d+), (\d+), (\d+)\)', rgb_string)
    if match:
        r, g, b = map(int, match.groups())
        return f'#{r:02X}{g:02X}{b:02X}'
    return '#000000'  # fallback

# 範囲と間隔の設定関数
def auto_range_rough(min_val, max_val):
    def round_min(val):
        if val == 0:
            return 0
        base = 10 ** math.floor(math.log10(abs(val)))
        step = base / 5
        return math.floor(val / step) * step if val > 0 else math.ceil(val / step) * step

    def round_max(val):
        if val == 0:
            return 0
        base = 10 ** math.floor(math.log10(abs(val)))
        step = base / 5
        return math.ceil(val / step) * step if val > 0 else math.floor(val / step) * step

    def rough_tick(min_val, max_val):
        diff = (max_val - min_val) // 5
        if diff == 0:
            return 1
        base = 10 ** math.floor(math.log10(diff))
        return math.ceil(diff / base) * base
    
    min_rounded = round_min(min_val)
    max_rounded = round_max(max_val)
    dtick = rough_tick(min_rounded, max_rounded)
    return min_rounded, max_rounded, dtick

# デフォルト値を config_data から取得するヘルパー
def get_config_value(path, default):
    if not config_data:
        return default
    for p in path:
        config = config_data
        try:
            for key in p:
                config = config[key]
            return config
        except (KeyError, IndexError, TypeError):
            continue
    return default

# グラフの描画
def draw_plotly_chart(df, chart_type, x_col, x_min, x_max, x_dtick, y_settings, width, height, n_charts, font_style):
    layout = dict(
        height=int(height),
        width=int(width),
        showlegend=True,
        font=font_style,
        hoversubplots="axis",
        hovermode="x unified",
        grid=dict(rows=n_charts, columns=1),
    )

    data = []
    for i, (y_axis, y_cols, y_labels, colors, widths, dashes, y_min, y_max, y_dtick) in enumerate(y_settings, start=1):
        for y_col, label, color, w, dash in zip(y_cols, y_labels, colors, widths, dashes):
            if chart_type == "Line":
                trace = go.Scatter(x=df[x_col], y=df[y_col], xaxis='x', yaxis=y_axis, mode="lines", name=y_col, line=dict(color=color, width=w, dash=dash))
            elif chart_type == "Scatter":
                trace = go.Scattergl(x=df[x_col], y=df[y_col], xaxis='x', yaxis=y_axis, mode="markers", name=y_col, marker=dict(color=color))
            elif chart_type == "Scatter+Line":
                trace = go.Scatter(x=df[x_col], y=df[y_col], xaxis='x', yaxis=y_axis, mode="lines+markers", name=y_col, line=dict(color=color, width=w, dash=dash), marker=dict(color=color))
            elif chart_type == "Bar":
                trace = go.Bar(x=df[x_col], y=df[y_col], xaxis='x', yaxis=y_axis, name=y_col, marker=dict(color=color))

            data.append(trace)

        # Y軸の設定を layout に直接追加（辞書に格納）
        layout[f'yaxis{i}'] = dict(
            title=y_labels[0] if y_labels else f'Y{i}',
            range=[y_min, y_max],
            dtick=y_dtick,
            title_font=font_style,
            tickfont=font_style,
        )

    fig = go.Figure(data=data, layout=layout)

    # x軸ラベルの追加
    fig.update_layout(
        xaxis=dict(
            title=x_col,
            range=[x_min, x_max],
            title_font=font_style,
            tickfont=font_style,
            dtick=x_dtick
            ),
    )
    # 表示
    st.plotly_chart(fig, use_container_width=False)

# configの保存
def build_config(chart_type, n_charts, height, width, x_col, x_min, x_max, x_dtick, y_settings, df):
    save_config = {
        "chart_type": chart_type,
        "n_charts": n_charts,
        "height": height,
        "width": width,
        "x_col": x_col,
        "x_min": x_min,
        "x_max": x_max,
        "x_dtick": x_dtick,
        "charts": [
            {
                "y_axis": y_axis,
                "y_cols": y_cols,
                "label": [y_labels[0]] if y_labels else ["Y"],
                "colors": colors,
                "dashes": dashes,
                "widths": widths,
                "y_min": y_min,
                "y_max": y_max,
                "y_dtick": y_dtick
            }
            for (y_axis, y_cols, y_labels, colors, widths, dashes, y_min, y_max, y_dtick) in y_settings
        ],
        # "data": df.to_dict(orient="list")
    }

    return save_config


# ページ設定
st.set_page_config(
    page_title="Data Visualization",
    page_icon="📈",  # 好きな絵文字やアイコンに変更可能
    layout="wide"
    )
# 定数
font_style = dict(family="Meiryo UI", color="black")

# CSSで右上に文字を表示するスタイルを定義
st.markdown(
    """
    <style>
    .right-top {
        position: absolute;
        top: 10px;
        right: 10px;
        font-size: 20px;
        font-weight: bold;
        color: grey;
    }
    </style>
    """,
    unsafe_allow_html=True
)
# 右上に表示する文字
st.markdown('<div class="right-top">Ver.0</div>', unsafe_allow_html=True)

# 初期化
df = None
config_data = None
config_loaded = False
save_config = []

with st.sidebar.expander("ファイル読込み"):
    # ファイルアップロード
    uploaded_file = st.file_uploader("データファイルをアップロード", type=["csv", "xlsx"])
    # 設定読み込み（初期化）
    config_file = st.file_uploader("設定ファイル(JSON)をアップロード", type="json")

# 設定ファイルを読込み
if config_file:
    config_data = json.load(config_file)
    # CSVデータが含まれていればDataFrameとして読み込む
    if "data" in config_data:
        try:
            df = pd.DataFrame(config_data["data"])
            uploaded_file = True  # フラグ的に扱って分岐を通すため
        except Exception as e:
            st.error(f"設定ファイルに含まれるデータの読み込みに失敗しました: {e}")


if uploaded_file or ("data" in config_data if config_data else False):
    try:
        # ファイルを読み取り、バイナリに変換
        file_bytes = uploaded_file.read()
        # キャッシュ付きデータ読み込み
        df = load_data(file_bytes)
    except Exception as e:
        pass

    # --- サイドバー: グラフ設定 ---
    with st.sidebar.expander("グラフ設定（全グラフ共通）"):
        chart_type = st.selectbox("タイプ", ["Line", "Scatter", "Scatter+Line", "Bar"],
                                index=["Line", "Scatter", "Scatter+Line", "Bar"].index(
                                get_config_value([["chart_type"]], "Line")))
        n_charts = st.number_input("グラフ数（行）", min_value=1, max_value=10,
                                value=get_config_value([["n_charts"]], 2))
        height = int(st.number_input("高さ(px)", min_value=200, max_value=5000,
                                value=get_config_value([["height"]],250 * n_charts)))
        width = int(st.number_input("幅(px)", min_value=200, max_value=5000,
                                value=get_config_value([["width"]],2000)))

    # --- X軸の範囲と間隔設定（共通） ---
    with st.sidebar.expander("X軸設定（全グラフ共通）"):
        x_col = st.selectbox("X軸（共通）", df.columns,
                            index=df.columns.get_loc(get_config_value([["x_col"]], df.columns[0])))
        # 自動スケール取得
        auto_x_min, auto_x_max, auto_x_dtick = auto_range_rough(df[x_col].min(), df[x_col].max())
        # 範囲と間隔
        x_min = st.number_input("X軸最小値", value=get_config_value([["x_min"]], float(auto_x_min)))
        x_max = st.number_input("X軸最大値", value=get_config_value([["x_max"]], float(auto_x_max)))
        x_dtick = st.number_input("X軸tick間隔", value=get_config_value([["x_dtick"]], float(auto_x_dtick)))

    # --- 各グラフの Y軸設定 ---
    y_settings = []
    color_index = 0
    charts_config = get_config_value([["charts"]], [])

    for i in range(n_charts):
        chart_conf = charts_config[i] if i < len(charts_config) else {}
        y_axis = f"y{i+1}"

        with st.sidebar.expander(f"グラフ{i+1}"):
            y_cols = st.multiselect(
                f"Y軸（グラフ{i+1}）", df.columns,
                default=chart_conf.get("y_cols", []),key=f"y_{i}"
            )
            # ラベル名
            label = st.text_input("ラベル名", value=", ".join(chart_conf.get("label",y_cols)), key=f"label_{i}")

            # 一時グラフでY軸の自動範囲取得（最初のY軸列）
            if y_cols:
                auto_y_min, auto_y_max, auto_y_dtick = auto_range_rough(df[y_cols[0]].min(), df[y_cols[0]].max())
            else:
                auto_y_min, auto_y_max, auto_y_dtick = (0, 1, 1)
            # 軸範囲と間隔（Y軸）
            y_min = st.number_input(f"Y軸最小値（グラフ{i+1}）", value=chart_conf.get("y_min", float(auto_y_min)), key=f"ymin_{i}")
            y_max = st.number_input(f"Y軸最大値（グラフ{i+1}）", value=chart_conf.get("y_max", float(auto_y_max)), key=f"ymax_{i}")
            y_dtick = st.number_input(f"Y軸tick間隔（グラフ{i+1}）", value=chart_conf.get("y_dtick", float(auto_y_dtick)), key=f"ytick_{i}")

            colors,dashes,widths = [],[],[]
            for j in range(len(y_cols)):
                # --- 色の設定 ---
                default_rgb = DEFAULT_PLOTLY_COLORS[(color_index + j) % len(DEFAULT_PLOTLY_COLORS)]
                default_hex = rgb_to_hex(default_rgb)
                color_value = chart_conf.get("colors", [default_hex] * len(y_cols))[j] if j < len(chart_conf.get("colors", [])) else default_hex
                color = st.color_picker(f"色（{y_cols[j]}）", value=color_value, key=f"color_{i}_{j}")
                colors.append(color)

                # --- 線の種類の設定 ---
                dash_options = ["実線", "点線", "破線", "ドット線"]
                dash_map = {
                    "実線": "solid",
                    "点線": "dot",
                    "破線": "dash",
                    "ドット線": "dashdot"
                }
                # JSONからdashスタイル名を取得（dashのPlotly値から逆引きする）
                default_dash = chart_conf.get("dashes", ["solid"] * len(y_cols))
                default_dash_style_name = next(
                    (k for k, v in dash_map.items() if v == default_dash[j]),
                    "実線"  # 見つからない場合のデフォルト
                ) if j < len(default_dash) else "実線"

                dash_style = st.selectbox(
                    f"線の種類（{y_cols[j]}）", dash_options,
                    index=dash_options.index(default_dash_style_name),
                    key=f"dash_{i}_{j}"
                )
                dashes.append(dash_map[dash_style])

                # --- 線の太さの設定 ---
                default_widths = chart_conf.get("widths", [2] * len(y_cols))
                chart_width = st.slider(
                    f"線の太さ（{y_cols[j]}）", min_value=1, max_value=10,
                    value=default_widths[j] if j < len(default_widths) else 2,
                    key=f"width_{i}_{j}"
                )
                widths.append(chart_width)

            color_index += len(y_cols)
            y_labels = [label] * len(y_cols)
            y_settings.append((y_axis, y_cols, y_labels, colors, widths, dashes, y_min, y_max, y_dtick))

    # --- グラフの描画 ---
    draw_plotly_chart(df, chart_type, x_col, x_min, x_max, x_dtick, y_settings, width, height, n_charts, font_style)

    # --- 設定を保存 ---
    save_config = build_config(chart_type, n_charts, height, width, x_col, x_min, x_max, x_dtick, y_settings, df)
    config_json_str = json.dumps(save_config, indent=2)
    st.sidebar.download_button("JSONファイルを保存", data=config_json_str, file_name="chart_config.json", mime="application/json")
```

#### 実行
ターミナルで以下を実行
```
streamlit run streamlit_app.py  
````

#### 終わり
streamlitを使うことで簡単にwebアプリの作成ができた。オフラインでも使えるので、会社のセキュリティ問題もない。グラフはplotlyにしたことでインタラクティブに操作できる。これはExcelではできないので使い勝手がGood！
今後はアプリを発展させて、データの加工やAI連携もできるようにしたい！