Python Streamlitを使用した基礎的なWebアプリケーションの作成(1) June, 2025

Basic Learning Python Library Streamlit which easily create web applivation

 Streamlitを使って簡単なデータベースとUserInterfaceApplyの作成に挑戦しようと、一念発起です。

 Streamlitの基礎については、PythonVtuberのサプーさんがUploadしているMovieでStudyしました。サプーさんのMovieは解説がわかりやすく、とても勉強になるので、本当に助かります。

※PythonVtuberサプーさんによるStreamlitの基礎がわかるYouTube動画

https://www.youtube.com/watch?v=4nsTce1Oce8&t=153s

 次はStreamlitをどのような環境で実行するか、という点について検討です。おそらくHighSpectなPersonalComputerを持っていたら、AnacondaとJupyterNotebookあるいはJupyterLab、あるいは、Spyderを組み合わせたEnviromentでCodeをDoすることがBetterなのかなぁと思います。

 わたしのLowSpecなLaptopPCでAnacondaのLocalEnviromentでStreamlitをRunしたのですが、Every OperatingがVery SlowでとてもUnbearableでした。わたしのようなLowSpec環境だと、やはりGoogleColabを活用した方がよさそうです。

 MasterGeminiにTry to Askしたところ、TkinterはGoogleColab環境では動作しないのですが、Streamlitは動作するということがわかりました。MasterGeminiが提案してくれたCodeをGoogleColab上でRunしてみます。

>The Code that MasterGemini has made<

!pip install streamlit

%%writefile app.py

import streamlit as st

st.title('Colabで動くStreamlitアプリ')
st.write('これはGoogle Colabで実行されているStreamlitのデモです。')

name = st.text_input('お名前を入力してください:', '')
if name:
    st.write(f'こんにちは、{name}さん!')

number = st.slider('数字を選んでください:', 0, 100, 50)
st.write(f'選んだ数字: {number}') 

2行目にWritingしている%%writefile app.pyがPointで、Anaconda環境であれば必要ないCodeですが、GoogleColabでStreamlitを動かすためにはEssentialなコードになるようです。このCodeをGoogleColab上でRunすると、GoogleColabのTemporaryFolderにapp.pyというFileがCreateされます。ちなみにgoogleColabのOutputは’Writing app.py'Onlyです。


このGooleColabのTemporaryFolderにできた'app.py'を左クリックして現れる縦に・が3つならんでるいるところからDownloadをSelectして、MyPCのLocalDriveにあるDownLoadFolderにDownLoadされた'app.py'を、AnacondaでCreateしたCodesがSaveされるLocalFolderにMoveします。
 そして、WindowsStartButtonにあるAnacondaのMenuから’AnacondaPromt'(Application選択画面のAnacondaFolderの中にあります)を起動します。

そして、AnacondaPromptのComamandLineから'cd'LocalFolder(私の場合、’\user\にChangeDirectryして、CommandLineに'streamlit run app.py'を入力して、app.pyをRunします。

そうすると、自動的にWebブラウザーに新しいTagができて、StreamlitのApplicationが起動します。今回のCodeだと以下のような感じです。

 もし、Codeを更新したら、GoogleColabでCodeをRunして、App.pyをDownload&Moveしたあとに、WebブラウザのRenewButtonを押したら、Applicationが更新されるはずです。

S treamlitのWebApplicationのRunのMethodがUndestandできたところで、次の課題に移ります。次は、以前、Foliumで試した国土数値情報の全国のダム地点のデータを都道府県名やダム形式、ダムの目的で絞り込んだうえで、該当するデータを表示するApplicationを作ってみます。といっても自分で作るのでは無く、MasterGeminiに相談です。

>MasterGeminiへのAsking<
Streamlitでメイン画面にgeodataframe'gdf'の一覧を表示して、左側のサブ画面に'都道府県名で選択'、'ダム型式で選択'、'ダムの目的で選択'、アプリを終了'の3種類のボタンを設定するコードを書いて

>MasterGeminiのAnswerCode<…最初からPerfectだった訳ではございません
%%writefile app.py
import streamlit as st
import geopandas as gpd
import pandas as pd

# --- サンプルデータの作成 (実際のgdfに置き換えてください) ---
# 都道府県名、ダム型式、ダムの目的のサンプル値
prefectures = ['東京都', '神奈川県', '大阪府', '京都府', '兵庫県', '愛知県']
dam_types = ['重力式コンクリートダム', 'ロックフィルダム', 'アーチ式コンクリートダム']
dam_purposes = ['治水', '利水', '発電', '治水・利水']

data = {
    '都道府県名': [prefectures[i % len(prefectures)] for i in range(20)],
    'ダム名': [f'ダム{i+1}' for i in range(20)],
    'ダム型式': [dam_types[i % len(dam_types)] for i in range(20)],
    'ダムの目的': [dam_purposes[i % len(dam_purposes)] for i in range(20)],
    'geometry': gpd.points_from_xy([135 + i * 0.1 for i in range(20)], [35 + i * 0.05 for i in range(20)])
}
gdf = gpd.GeoDataFrame(data, crs="EPSG:4326")
# --- サンプルデータの作成ここまで ---

st.set_page_config(layout="wide") # 画面全体を使う設定

st.title("ダム情報ビューア")

# メイン画面
st.header("ダム一覧")

# geometry列をWKT形式の文字列に変換して表示用のDataFrameを作成
# Create a display DataFrame by converting the geometry column to WKT string format
gdf_display = gdf.copy()
gdf_display['geometry'] = gdf_display['geometry'].apply(lambda x: x.wkt)
st.dataframe(gdf_display) # geodataframe 'gdf' の一覧を表示

# サブ画面 (サイドバー)
st.sidebar.header("選択オプション")

# 都道府県名で選択
st.sidebar.subheader("都道府県名で選択")
selected_prefecture = st.sidebar.selectbox(
    "都道府県を選択してください",
    ['全て'] + sorted(gdf['都道府県名'].unique().tolist())
)
if selected_prefecture != '全て':
    gdf_filtered_prefecture = gdf[gdf['都道府県名'] == selected_prefecture]
    # Filtered DataFrameもgeometry列をWKTに変換して表示
    # Convert geometry column to WKT for the filtered DataFrame display as well
    gdf_filtered_prefecture_display = gdf_filtered_prefecture.copy()
    gdf_filtered_prefecture_display['geometry'] = gdf_filtered_prefecture_display['geometry'].apply(lambda x: x.wkt)
    st.header(f"{selected_prefecture}のダム一覧")
    st.dataframe(gdf_filtered_prefecture_display)
else:
    st.header("全てのダム一覧")
    st.dataframe(gdf_display) # 全てのダムを表示する場合は、既に変換済みのgdf_displayを使用


# ダム型式で選択
st.sidebar.subheader("ダム型式で選択")
selected_dam_type = st.sidebar.selectbox(
    "ダム型式を選択してください",
    ['全て'] + sorted(gdf['ダム型式'].unique().tolist())
)
if selected_dam_type != '全て':
    gdf_filtered_type = gdf[gdf['ダム型式'] == selected_dam_type]
    # Filtered DataFrameもgeometry列をWKTに変換して表示
    # Convert geometry column to WKT for the filtered DataFrame display as well
    gdf_filtered_type_display = gdf_filtered_type.copy()
    gdf_filtered_type_display['geometry'] = gdf_filtered_type_display['geometry'].apply(lambda x: x.wkt)
    st.header(f"{selected_dam_type}のダム一覧")
    st.dataframe(gdf_filtered_type_display)

# ダムの目的で選択
st.sidebar.subheader("ダムの目的で選択")
selected_dam_purpose = st.sidebar.selectbox(
    "ダムの目的を選択してください",
    ['全て'] + sorted(gdf['ダムの目的'].unique().tolist())
)
if selected_dam_purpose != '全て':
    gdf_filtered_purpose = gdf[gdf['ダムの目的'] == selected_dam_purpose]
    # Filtered DataFrameもgeometry列をWKTに変換して表示
    # Convert geometry column to WKT for the filtered DataFrame display as well
    gdf_filtered_purpose_display = gdf_filtered_purpose.copy()
    gdf_filtered_purpose_display['geometry'] = gdf_filtered_purpose_display['geometry'].apply(lambda x: x.wkt)
    st.header(f"{selected_dam_purpose}のダム一覧")
    st.dataframe(gdf_filtered_purpose_display)

# アプリを終了ボタン
st.sidebar.subheader("アプリの操作")
if st.sidebar.button("アプリを終了"):
    st.stop() # Streamlitアプリを停止 (開発環境ではブラウザタブを閉じることで終了)

# フィルタリングされたデータフレームの表示 (重複を避けるため、各選択肢のブロック内で表示)
# 注意: 上記のコードでは、最後に選択されたフィルタが適用されます。
# 複数のフィルタを同時に適用したい場合は、ロジックを調整する必要があります。


これをAnacondaPromptでRunするといかのような画面になります。
 

なんとなくですが、仮作成したGeoPandasDataFrameのダム一覧から選択した都道府県名やダム形式のダムを抽出して一覧表を作り直してくれているような気がします。
都道府県はもう少し見やすくしたいので、地方別に都道府県をグループ化して表示することができないか、MasterGeminiに相談してみると、これまた上手に整理してくれます。


 もう少しコンパクトに表示してもらいたいなぁということでGemini先生にお願いしてみましたが、うまくいきません。あきらめて、地方別にグループ化して操作性を向上しつつ都道府県選択画面を左サイドバーに戻すことにしました。

次に選択されている全てのダムについてFoliumの地図にマーカー表示するように改良しました。StreamlitにはStreamlit_foliumというライブラリがあるので、とっても便利です。
>>Code<<
# 20250705_ダム型式もダム目的も複数選択可能に見直し
# さらに、選択したダムの位置情報をfoliumのmap上にShownするコード

%%writefile app.py
import streamlit as st
import geopandas as gpd
import pandas as pd
import random # サンプルデータ作成のために使用
import folium # Folium地図のために追加
from streamlit_folium import st_folium # StreamlitでFolium地図を表示するために追加

# --- 47都道府県と地方の定義 ---
# 全都道府県のリスト
prefectures_all = [
    '北海道', '青森県', '岩手県', '宮城県', '秋田県', '山形県', '福島県',
    '茨城県', '栃木県', '群馬県', '埼玉県', '千葉県', '東京都', '神奈川県',
    '新潟県', '富山県', '石川県', '福井県', '山梨県', '長野県', '岐阜県', '静岡県', '愛知県',
    '三重県', '滋賀県', '京都府', '大阪府', '兵庫県', '奈良県', '和歌山県',
    '鳥取県', '島根県', '岡山県', '広島県', '山口県',
    '徳島県', '香川県', '愛媛県', '高知県',
    '福岡県', '佐賀県', '長崎県', '熊本県', '大分県', '宮崎県', '鹿児島県', '沖縄県'
]

# 都道府県と地方の辞書 (北海道地方と東北地方を結合)
regions = {
    '北海道・東北地方': ['北海道', '青森県', '岩手県', '宮城県', '秋田県', '山形県', '福島県'], # 結合されたグループ
    '関東地方': ['茨城県', '栃木県', '群馬県', '埼玉県', '千葉県', '東京都', '神奈川県'],
    '中部地方': ['新潟県', '富山県', '石川県', '福井県', '山梨県', '長野県', '岐阜県', '静岡県', '愛知県'],
    '近畿地方': ['三重県', '滋賀県', '京都府', '大阪府', '兵庫県', '奈良県', '和歌山県'],
    '中国地方': ['鳥取県', '島根県', '岡山県', '広島県', '山口県'],
    '四国地方': ['徳島県', '香川県', '愛媛県', '高知県'],
    '九州・沖縄地方': ['福岡県', '佐賀県', '長崎県', '熊本県', '大分県', '宮崎県', '鹿児島県', '沖縄県']
}

# --- サンプルデータの作成 ---
random.seed(42) # 再現性のためにシードを設定
num_dams = 100 # サンプルダムの数を増やします

data = {
    '都道府県名': [random.choice(prefectures_all) for _ in range(num_dams)],
    'ダム名': [f'サンプルダム{i+1}' for i in range(num_dams)],
    'ダム型式': [random.choice(['重力式コンクリートダム', 'ロックフィルダム', 'アーチ式コンクリートダム', 'アースダム', '複合ダム']) for _ in range(num_dams)],
    'ダム目的': [random.choice(['治水', '利水', '発電', '治水・利水', '工業用水']) for _ in range(num_dams)],
    'geometry': gpd.points_from_xy(
        [130 + i * 0.2 + random.uniform(-0.1, 0.1) for i in range(num_dams)], # 経度
        [33 + i * 0.1 + random.uniform(-0.05, 0.05) for i in range(num_dams)]  # 緯度
    )
}
gdf = gpd.GeoDataFrame(data, crs="EPSG:4326")
# --- サンプルデータの作成ここまで ---


# --- Streamlit ページ設定 ---
st.set_page_config(layout="wide", page_title="ダム情報ビューア") # 画面全体を使う設定
st.title("ダム情報ビューア")


# --- セッションステートの初期化 ---
# ユーザーが選択した都道府県のリスト
if 'selected_prefectures' not in st.session_state:
    st.session_state.selected_prefectures = []

# サイドバーのフィルタリング状態を保持する (複数選択用リストに変更)
if 'selected_dam_types' not in st.session_state:
    st.session_state.selected_dam_types = []
if 'selected_dam_purposes' not in st.session_state:
    st.session_state.selected_dam_purposes = []


# --- メイン画面の表示関数 ---
# この関数は常にメインコンテンツを表示します
def show_main_page_content(): # 関数名を変更して、ページ遷移がないことを明確に
    st.header("ダム一覧")

    # 表示するGeoDataFrameを準備 (元のgdfを変更しないようにコピー)
    display_gdf = gdf.copy()

    # フィルタリング条件を累積的に適用する
    # 1. 都道府県によるフィルタリング
    if st.session_state.selected_prefectures:
        display_gdf = display_gdf[display_gdf['都道府県名'].isin(st.session_state.selected_prefectures)]
        st.subheader(f"選択された都道府県 ({', '.join(st.session_state.selected_prefectures)}) のダム")
    else:
        st.subheader("全てのダムデータ")

    # 2. ダム型式によるフィルタリング (複数選択に対応)
    if st.session_state.selected_dam_types:
        display_gdf = display_gdf[display_gdf['ダム型式'].isin(st.session_state.selected_dam_types)]
        st.info(f"「ダム型式: {', '.join(st.session_state.selected_dam_types)}」でフィルタリング中")

    # 3. ダム目的によるフィルタリング (複数選択に対応)
    if st.session_state.selected_dam_purposes:
        display_gdf = display_gdf[display_gdf['ダム目的'].isin(st.session_state.selected_dam_purposes)]
        st.info(f"「ダム目的: {', '.join(st.session_state.selected_dam_purposes)}」でフィルタリング中")

    # --- 表表示 ---
    st.subheader("ダム一覧表")
    # geometry列をWKT形式の文字列に変換して表示 (StreamlitのdataframeでGeoPandasのgeometryを直接表示するとエラーになる場合があるため)
    gdf_display_wkt = display_gdf.copy()
    gdf_display_wkt['geometry'] = gdf_display_wkt['geometry'].apply(lambda x: x.wkt)

    # フィルタリング結果の表示
    if not gdf_display_wkt.empty:
        st.dataframe(
            gdf_display_wkt,
            use_container_width=True, # コンテナの幅に合わせる
            height=300, # 表示行数を調整
            key="main_dam_data_table" # データフレームにユニークなキーを設定
        )
    else:
        st.info("選択された条件に一致するダムは見つかりませんでした。")

    st.markdown("---") # 地図と表の間に区切り線

    # --- 地図表示 (Foliumを使用) ---
    if not display_gdf.empty:
        st.subheader("ダム位置情報マップ (Folium)")
       
        # マップの中心座標を計算 (フィルタリングされたダムの中央)
        if not display_gdf.empty:
            center_lat = display_gdf.geometry.y.mean()
            center_lon = display_gdf.geometry.x.mean()
        else: # データがない場合は日本の中心付近に設定
            center_lat = 36.2048
            center_lon = 138.2529

        # Foliumマップを作成
        m = folium.Map(location=[center_lat, center_lon], zoom_start=6, tiles="OpenStreetMap")

        # 各ダムの位置にマーカーを追加
        for idx, row in display_gdf.iterrows():
            popup_html = f"""
            <b>ダム名:</b> {row['ダム名']}<br>
            <b>都道府県:</b> {row['都道府県名']}<br>
            <b>型式:</b> {row['ダム型式']}<br>
            <b>目的:</b> {row['ダム目的']}<br>
            <b>緯度:</b> {row.geometry.y:.4f}<br>
            <b>経度:</b> {row.geometry.x:.4f}
            """
            # FoliumのPopupはHTMLを直接受け取れる
            folium.Marker(
                location=[row.geometry.y, row.geometry.x],
                popup=folium.Popup(popup_html, max_width=300), # ポップアップの最大幅を設定
                tooltip=row['ダム名'] # マーカーにマウスオーバーしたときに表示されるテキスト
            ).add_to(m)

        # StreamlitでFoliumマップを表示
        st_folium(m, width=700, height=500, key="folium_map")
    else:
        st.info("地図上に表示するダムがありません。")


# --- サイドバーのナビゲーションとフィルタオプション ---
with st.sidebar:
    st.header("操作メニュー")

    # 「全ての選択を解除」ボタンの追加
    if st.button("全ての選択を解除", key="clear_all_filters"):
        st.session_state.selected_prefectures = []
        st.session_state.selected_dam_types = [] # 複数選択用リストをクリア
        st.session_state.selected_dam_purposes = [] # 複数選択用リストをクリア
        st.info("全ての選択を解除しました。")
        st.rerun() # 変更を反映するために再実行

    st.subheader("都道府県の選択 (複数選択可能)")
    st.write("地方ごとに開閉して選択してください。") # 説明を追加

    # 各地方をst.expanderで囲み、開閉可能にする
    for region, prefectures in regions.items():
        # expanderのキーをユニークにする
        with st.expander(region, expanded=False): # デフォルトで閉じる
            # 現在の選択状態を一時リストにコピー(チェックボックスの変更を即座に反映するため)
            current_selected_prefectures_in_page = list(st.session_state.selected_prefectures)
           
            # 列を削除し、チェックボックスを縦一列に配置
            for pref in prefectures:
                checkbox_key = f"sidebar_checkbox_pref_{pref}" # ユニークキーをより明確に
                initial_value = pref in current_selected_prefectures_in_page
               
                if st.checkbox(pref, value=initial_value, key=checkbox_key):
                    if pref not in current_selected_prefectures_in_page:
                        current_selected_prefectures_in_page.append(pref)
                else:
                    if pref in current_selected_prefectures_in_page:
                        current_selected_prefectures_in_page.remove(pref)
           
            # サイドバー内のチェックボックス変更を即座にセッションステートに反映し、メイン画面を更新
            # ここで変更がなければrerunしないようにすることで、不要な再実行を減らす
            if set(current_selected_prefectures_in_page) != set(st.session_state.selected_prefectures):
                st.session_state.selected_prefectures = current_selected_prefectures_in_page
                st.rerun() # 変更を反映するために再実行

    st.markdown("---") # 区切り線
    st.subheader("その他のフィルタオプション")
   
    # ダム型式で選択 (複数選択に対応)
    all_dam_types = sorted(gdf['ダム型式'].unique().tolist()) # '全て'オプションは不要
    with st.expander("ダム型式で選択 (複数選択)", expanded=False):
        current_selected_dam_types_sidebar = list(st.session_state.selected_dam_types)
        # 列を削除し、チェックボックスを縦一列に配置
        for dam_type in all_dam_types:
            checkbox_key_type = f"sidebar_checkbox_dam_type_{dam_type}"
            initial_value_type = dam_type in current_selected_dam_types_sidebar
            if st.checkbox(dam_type, value=initial_value_type, key=checkbox_key_type):
                if dam_type not in current_selected_dam_types_sidebar:
                    current_selected_dam_types_sidebar.append(dam_type)
            else:
                if dam_type in current_selected_dam_types_sidebar:
                    current_selected_dam_types_sidebar.remove(dam_type)
       
        if set(current_selected_dam_types_sidebar) != set(st.session_state.selected_dam_types):
            st.session_state.selected_dam_types = current_selected_dam_types_sidebar
            st.rerun()

    # ダムの目的で選択 (複数選択に対応)
    all_dam_purposes = sorted(gdf['ダム目的'].unique().tolist()) # '全て'オプションは不要
    with st.expander("ダム目的で選択 (複数選択)", expanded=False):
        current_selected_dam_purposes_sidebar = list(st.session_state.selected_dam_purposes)
        # 列を削除し、チェックボックスを縦一列に配置
        for dam_purpose in all_dam_purposes:
            checkbox_key_purpose = f"sidebar_checkbox_dam_purpose_{dam_purpose}"
            initial_value_purpose = dam_purpose in current_selected_dam_purposes_sidebar
            if st.checkbox(dam_purpose, value=initial_value_purpose, key=checkbox_key_purpose):
                if dam_purpose not in current_selected_dam_purposes_sidebar:
                    current_selected_dam_purposes_sidebar.append(dam_purpose)
            else:
                if dam_purpose in current_selected_dam_purposes_sidebar:
                    current_selected_dam_purposes_sidebar.remove(dam_purpose)
       
        if set(current_selected_dam_purposes_sidebar) != set(st.session_state.selected_dam_purposes):
            st.session_state.selected_dam_purposes = current_selected_dam_purposes_sidebar
            st.rerun()

    st.subheader("アプリの操作")
    if st.button("アプリを終了"):
        st.stop()


# --- メインロジック: 常にメインコンテンツを表示 ---
show_main_page_content()


>>Output<<

 ダミーデータを使用したTrialCodeの最後にそれぞれのDamに行ったことがあるかどうかを記録する機能を付けたいなぁと考えました。こちらもGemini先生に質問です。
>>Gemini先生への質問<<

それぞれのダムに行ったか、行っていないかを記録する列を追加して、行ったことがあったら「訪問済」、行っていなければ「未訪問」を選択し、記録として保存できるように改良できますか?

ちなみに、最初の表示は全てのダムで「未訪問」で構いません。


>>Gemini先生の回答<<

承知いたしました。ご要望の通り、各ダムの訪問状態を記録する「訪問状態」列を追加し、その状態を「訪問済」または「未訪問」として選択・保存できるようにCanvasを改良します。

最初の表示では全てのダムが「未訪問」となります。この機能は、現在のセッション内でのみ状態が保持されるように実装します。永続的な保存(アプリを閉じても状態が残るようにする)にはFirestoreのようなデータベースが必要ですが、StreamlitのPython環境から直接Firestoreに安全に接続するには追加の複雑な設定が必要となるため、今回はセッション内での状態管理に留めます。

以下に修正済みのCanvasを提供します

 これ以外にも細かい点についていろいろとGemini先生に相談しましたが、それらはOmitして、FinalCodeとWebAppのOutputを掲載します。

>>Code<<

# 20250706_App上で各ダムの訪問履歴を記録する機能を追加

%%writefile app.py
# -*- coding: utf-8 -*-
import streamlit as st
import geopandas as gpd
import pandas as pd
import random # サンプルデータ作成のために使用
import folium # Folium地図のために追加
from streamlit_folium import st_folium # StreamlitでFolium地図を表示するために追加

# --- 47都道府県と地方の定義 ---
# 全都道府県のリスト
prefectures_all = [
    '北海道', '青森県', '岩手県', '宮城県', '秋田県', '山形県', '福島県',
    '茨城県', '栃木県', '群馬県', '埼玉県', '千葉県', '東京都', '神奈川県',
    '新潟県', '富山県', '石川県', '福井県', '山梨県', '長野県', '岐阜県', '静岡県', '愛知県',
    '三重県', '滋賀県', '京都府', '大阪府', '兵庫県', '奈良県', '和歌山県',
    '鳥取県', '島根県', '岡山県', '広島県', '山口県',
    '徳島県', '香川県', '愛媛県', '高知県',
    '福岡県', '佐賀県', '長崎県', '熊本県', '大分県', '宮崎県', '鹿児島県', '沖縄県'
]

# 都道府県と地方の辞書 (北海道地方と東北地方を結合)
regions = {
    '北海道・東北地方': ['北海道', '青森県', '岩手県', '宮城県', '秋田県', '山形県', '福島県'], # 結合されたグループ
    '関東地方': ['茨城県', '栃木県', '群馬県', '埼玉県', '千葉県', '東京都', '神奈川県'],
    '中部地方': ['新潟県', '富山県', '石川県', '福井県', '山梨県', '長野県', '岐阜県', '静岡県', '愛知県'],
    '近畿地方': ['三重県', '滋賀県', '京都府', '大阪府', '兵庫県', '奈良県', '和歌山県'],
    '中国地方': ['鳥取県', '島根県', '岡山県', '広島県', '山口県'],
    '四国地方': ['徳島県', '香川県', '愛媛県', '高知県'],
    '九州・沖縄地方': ['福岡県', '佐賀県', '長崎県', '熊本県', '大分県', '宮崎県', '鹿児島県', '沖縄県']
}

# --- サンプルデータの作成 ---
random.seed(42) # 再現性のためにシードを設定
num_dams = 100 # サンプルダムの数を増やします

data = {
    '都道府県名': [random.choice(prefectures_all) for _ in range(num_dams)],
    'ダム名': [f'サンプルダム{i+1}' for i in range(num_dams)],
    'ダム型式': [random.choice(['重力式コンクリートダム', 'ロックフィルダム', 'アーチ式コンクリートダム', 'アースダム', '複合ダム']) for _ in range(num_dams)],
    'ダム目的': [random.choice(['治水', '利水', '発電', '治水・利水', '工業用水']) for _ in range(num_dams)],
    'geometry': gpd.points_from_xy(
        [130 + i * 0.2 + random.uniform(-0.1, 0.1) for i in range(num_dams)], # 経度
        [33 + i * 0.1 + random.uniform(-0.05, 0.05) for i in range(num_dams)]  # 緯度
    )
}
gdf = gpd.GeoDataFrame(data, crs="EPSG:4326")

# 各ダムにユニークなIDを付与 (FirestoreのドキュメントIDとして使用することを想定)
# ダム名と都道府県名を組み合わせることで、サンプルデータ内でユニークなIDを生成
gdf['dam_id'] = gdf['ダム名'] + "_" + gdf['都道府県名']
# --- サンプルデータの作成ここまで ---


# --- Streamlit ページ設定 ---
st.set_page_config(layout="wide", page_title="ダム情報ビューア") # 画面全体を使う設定
st.title("ダム情報ビューア")


# --- セッションステートの初期化 ---
# ユーザーが選択した都道府県のリスト
if 'selected_prefectures' not in st.session_state:
    st.session_state.selected_prefectures = []

# サイドバーのフィルタリング状態を保持する (複数選択用リストに変更)
if 'selected_dam_types' not in st.session_state:
    st.session_state.selected_dam_types = []
if 'selected_dam_purposes' not in st.session_state:
    st.session_state.selected_dam_purposes = []

# ダムの訪問状態を保持する辞書 (dam_id: 状態)
# アプリ起動時に全てのダムを「未訪問」で初期化
if 'dam_visit_statuses' not in st.session_state:
    st.session_state.dam_visit_statuses = {
        dam_id: '未訪問' for dam_id in gdf['dam_id'].unique()
    }


# --- メイン画面の表示関数 ---
# この関数は常にメインコンテンツを表示します
def show_main_page_content(): # 関数名を変更して、ページ遷移がないことを明確に
    st.header("ダム一覧")

    # 表示するGeoDataFrameを準備 (元のgdfを変更しないようにコピー)
    display_gdf = gdf.copy()

    # 「訪問状態」列をdisplay_gdfに追加または更新
    # セッションステートから現在の訪問状態をマージ
    display_gdf['訪問状態'] = display_gdf['dam_id'].map(st.session_state.dam_visit_statuses).fillna('未訪問')

    # フィルタリング条件を累積的に適用する
    # 1. 都道府県によるフィルタリング
    if st.session_state.selected_prefectures:
        display_gdf = display_gdf[display_gdf['都道府県名'].isin(st.session_state.selected_prefectures)]
        st.subheader(f"選択された都道府県 ({', '.join(st.session_state.selected_prefectures)}) のダム")
    else:
        st.subheader("全てのダムデータ")

    # 2. ダム型式によるフィルタリング (複数選択に対応)
    if st.session_state.selected_dam_types:
        display_gdf = display_gdf[display_gdf['ダム型式'].isin(st.session_state.selected_dam_types)]
        st.info(f"「ダム型式: {', '.join(st.session_state.selected_dam_types)}」でフィルタリング中")

    # 3. ダム目的によるフィルタリング (複数選択に対応)
    if st.session_state.selected_dam_purposes:
        display_gdf = display_gdf[display_gdf['ダム目的'].isin(st.session_state.selected_dam_purposes)]
        st.info(f"「ダム目的: {', '.join(st.session_state.selected_dam_purposes)}」でフィルタリング中")

    # --- 表表示 ---
    st.subheader("ダム一覧表")
    # geometry列をWKT形式の文字列に変換して表示 (StreamlitのdataframeでGeoPandasのgeometryを直接表示するとエラーになる場合があるため)
    gdf_display_wkt = display_gdf.copy()
    gdf_display_wkt['geometry'] = gdf_display_wkt['geometry'].apply(lambda x: x.wkt)

    # 「dam_id」列は内部的なものなので表示しない
    columns_to_show = [col for col in gdf_display_wkt.columns if col != 'dam_id']

    # フィルタリング結果の表示
    if not gdf_display_wkt.empty:
        st.dataframe(
            gdf_display_wkt[columns_to_show], # 表示する列を指定
            use_container_width=True, # コンテナの幅に合わせる
            height=300, # 表示行数を調整
            key="main_dam_data_table" # データフレームにユニークなキーを設定
        )
    else:
        st.info("選択された条件に一致するダムは見つかりませんでした。")

    st.markdown("---") # ダム状態記録セクションとの間に区切り線

    # --- ダム訪問状態の記録セクション (地図の上に移動) ---
    st.subheader("ダム訪問状態の記録")
    st.write("訪問状態を記録したいダムを選択し、状態を更新してください。")

    # 選択可能なダムのリストを作成 (表示されている display_gdf から)
    # dam_id と表示名を組み合わせる
    dam_options = [f"{row['ダム名']} ({row['都道府県名']})" for idx, row in display_gdf.iterrows()]
    dam_id_map = {f"{row['ダム名']} ({row['都道府県名']})": row['dam_id'] for idx, row in display_gdf.iterrows()}

    if dam_options:
        selected_dam_display_name = st.selectbox(
            "状態を更新するダムを選択",
            options=dam_options,
            key="select_dam_for_status"
        )
       
        selected_dam_id = dam_id_map[selected_dam_display_name]
        current_status = st.session_state.dam_visit_statuses.get(selected_dam_id, '未訪問')

        # 選択されたダムの情報を表示
        # gdfからdam_idで該当する行を検索
        selected_dam_row = gdf[gdf['dam_id'] == selected_dam_id].iloc[0]
        st.write(f"**選択中のダム情報:**")
        st.write(f"  **都道府県:** {selected_dam_row['都道府県名']}")
        st.write(f"  **ダム名:** {selected_dam_row['ダム名']}")
        st.write(f"  **ダム型式:** {selected_dam_row['ダム型式']}")
        st.write(f"  **ダム目的:** {selected_dam_row['ダム目的']}")
       
        new_status = st.radio(
            "訪問状態",
            options=['未訪問', '訪問済'],
            index=0 if current_status == '未訪問' else 1,
            key="dam_visit_status_radio"
        )

        if st.button("状態を更新", key="update_dam_status_button"):
            st.session_state.dam_visit_statuses[selected_dam_id] = new_status
            st.success(f"'{selected_dam_display_name}' の状態を '{new_status}' に更新しました!")
            st.rerun() # 状態更新を反映するために再実行
    else:
        st.info("表示するダムがないため、状態を記録できません。")

    st.markdown("---") # 地図とダム状態記録セクションとの間に区切り線

    # --- 地図表示 (Foliumを使用) ---
    if not display_gdf.empty:
        st.subheader("ダム位置情報マップ (Folium)")
       
        # マップの中心座標を計算 (フィルタリングされたダムの中央)
        if not display_gdf.empty:
            center_lat = display_gdf.geometry.y.mean()
            center_lon = display_gdf.geometry.x.mean()
        else: # データがない場合は日本の中心付近に設定
            center_lat = 36.2048
            center_lon = 138.2529

        # Foliumマップを作成
        m = folium.Map(location=[center_lat, center_lon], zoom_start=6, tiles="OpenStreetMap")

        # 各ダムの位置にマーカーを追加
        for idx, row in display_gdf.iterrows():
            popup_html = f"""
            <b>ダム名:</b> {row['ダム名']}<br>
            <b>都道府県:</b> {row['都道府県名']}<br>
            <b>型式:</b> {row['ダム型式']}<br>
            <b>目的:</b> {row['ダム目的']}<br>
            <b>訪問状態:</b> {row['訪問状態']}<br>
            <b>緯度:</b> {row.geometry.y:.4f}<br>
            <b>経度:</b> {row.geometry.x:.4f}
            """
            # FoliumのPopupはHTMLを直接受け取れる
            folium.Marker(
                location=[row.geometry.y, row.geometry.x],
                popup=folium.Popup(popup_html, max_width=300), # ポップアップの最大幅を設定
                tooltip=row['ダム名'] # マーカーにマウスオーバーしたときに表示されるテキスト
            ).add_to(m)

        # StreamlitでFoliumマップを表示
        st_folium(m, width=700, height=500, key="folium_map")
    else:
        st.info("地図上に表示するダムがありません。")


# --- サイドバーのナビゲーションとフィルタオプション ---
with st.sidebar:
    st.header("操作メニュー")

    # 「全ての選択を解除」ボタンの追加
    if st.button("全ての選択を解除", key="clear_all_filters"):
        st.session_state.selected_prefectures = []
        st.session_state.selected_dam_types = [] # 複数選択用リストをクリア
        st.session_state.selected_dam_purposes = [] # 複数選択用リストをクリア
        # 訪問状態はフィルタとは別なので、ここではリセットしない
        st.info("全てのフィルタリング条件を解除しました。")
        st.rerun() # 変更を反映するために再実行

    st.subheader("都道府県の選択 (複数選択可能)")
    st.write("地方ごとに開閉して選択してください。") # 説明を追加

    # 各地方をst.expanderで囲み、開閉可能にする
    for region, prefectures in regions.items():
        # expanderのキーをユニークにする
        with st.expander(region, expanded=False): # デフォルトで閉じる
            # 現在の選択状態を一時リストにコピー(チェックボックスの変更を即座に反映するため)
            current_selected_prefectures_in_page = list(st.session_state.selected_prefectures)
           
            # 列を削除し、チェックボックスを縦一列に配置
            for pref in prefectures:
                checkbox_key = f"sidebar_checkbox_pref_{pref}" # ユニークキーをより明確に
                initial_value = pref in current_selected_prefectures_in_page
               
                if st.checkbox(pref, value=initial_value, key=checkbox_key):
                    if pref not in current_selected_prefectures_in_page:
                        current_selected_prefectures_in_page.append(pref)
                else:
                    if pref in current_selected_prefectures_in_page:
                        current_selected_prefectures_in_page.remove(pref)
           
            # サイドバー内のチェックボックス変更を即座にセッションステートに反映し、メイン画面を更新
            # ここで変更がなければrerunしないようにすることで、不要な再実行を減らす
            if set(current_selected_prefectures_in_page) != set(st.session_state.selected_prefectures):
                st.session_state.selected_prefectures = current_selected_prefectures_in_page
                st.rerun() # 変更を反映するために再実行

    st.markdown("---") # 区切り線
    st.subheader("その他のフィルタオプション")
   
    # ダム型式で選択 (複数選択に対応)
    all_dam_types = sorted(gdf['ダム型式'].unique().tolist()) # '全て'オプションは不要
    with st.expander("ダム型式で選択 (複数選択)", expanded=False):
        current_selected_dam_types_sidebar = list(st.session_state.selected_dam_types)
        # 列を削除し、チェックボックスを縦一列に配置
        for dam_type in all_dam_types:
            checkbox_key_type = f"sidebar_checkbox_dam_type_{dam_type}"
            initial_value_type = dam_type in current_selected_dam_types_sidebar
            if st.checkbox(dam_type, value=initial_value_type, key=checkbox_key_type):
                if dam_type not in current_selected_dam_types_sidebar:
                    current_selected_dam_types_sidebar.append(dam_type)
            else:
                if dam_type in current_selected_dam_types_sidebar:
                    current_selected_dam_types_sidebar.remove(dam_type)
       
        if set(current_selected_dam_types_sidebar) != set(st.session_state.selected_dam_types):
            st.session_state.selected_dam_types = current_selected_dam_types_sidebar
            st.rerun()

    # ダムの目的で選択 (複数選択に対応)
    all_dam_purposes = sorted(gdf['ダム目的'].unique().tolist()) # '全て'オプションは不要
    with st.expander("ダム目的で選択 (複数選択)", expanded=False):
        current_selected_dam_purposes_sidebar = list(st.session_state.selected_dam_purposes)
        # 列を削除し、チェックボックスを縦一列に配置
        for dam_purpose in all_dam_purposes:
            checkbox_key_purpose = f"sidebar_checkbox_dam_purpose_{dam_purpose}"
            initial_value_purpose = dam_purpose in current_selected_dam_purposes_sidebar
            if st.checkbox(dam_purpose, value=initial_value_purpose, key=checkbox_key_purpose):
                if dam_purpose not in current_selected_dam_purposes_sidebar:
                    current_selected_dam_purposes_sidebar.append(dam_purpose)
            else:
                if dam_purpose in current_selected_dam_purposes_sidebar:
                    current_selected_dam_purposes_sidebar.remove(dam_purpose)
       
        if set(current_selected_dam_purposes_sidebar) != set(st.session_state.selected_dam_purposes):
            st.session_state.selected_dam_purposes = current_selected_dam_purposes_sidebar
            st.rerun()

    st.subheader("アプリの操作")
    if st.button("アプリを終了"):
        st.stop()


# --- メインロジック: 常にメインコンテンツを表示 ---
show_main_page_content()


>>Output<<


 「訪問済」、「未訪問」の情報は今回のAppではApp起動中のみ有効で、Appを閉じると情報は消えてしまいます。Appとして使用するならば、AppをCloseしたあとも訪問状況をどこかに保存しておき、次にAppを起動したときにその情報を読み込みにいかないといけないと思います。

 これをDataの永続性と呼ぶそうです。

 Streamlitで永続性を確保するためのSkillを身に着ける必要がありそうですが、とりあえず今回はここまでに留めて、次のStepでは実際に国内に存在するダムの情報を国土数値情報からGetして、TrialCodeに実装する方法を検討します。


次のBlog記事:https://shikuuk.blogspot.com/2025/07/python-streamlitweb2july-2025.html


















コメント