快轉到主要內容
  1. 教學文章/

Streamlit + PyDeck 地理資料 Dashboard:圖層、篩選與互動地圖

·7 分鐘· loading · loading · ·
Python Streamlit PyDeck GeoJSON Geospatial Dashboard Data-Visualization
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 123: 本文

一. 前言:地圖不是把兩欄丟進去就完成
#

st.map() 很適合快速確認經緯度資料。

但真正的地理資料 Dashboard 很快就會需要:

  • 同時顯示據點、服務區與高亮結果
  • 依類型、分數或範圍篩選
  • 點選物件後顯示明細
  • 保持穩定的中心點、縮放層級與圖層 ID
  • 擋住顛倒座標、無效資料與過大的前端 payload

這時候 PyDeck 比單純的 st.map() 更合適。

拍拍君今天會做一個服務據點地圖,重點不是底圖多漂亮,而是圖層、查詢與互動狀態能不能保持一致。

若你還不熟悉 Streamlit 的執行模型,可以先看Streamlit 入門;本文不重講一般 widgets 與圖表。

二. 安裝與範例資料
#

建立環境:

uv init pydeck-dashboard
cd pydeck-dashboard
uv add streamlit pydeck pandas pytest

準備 data/locations.csv

id,name,kind,lat,lon,score,visits
TP01,松風站,hub,25.0478,121.5319,92,180
TP02,河岸站,pickup,25.0576,121.5079,78,95
TP03,東門站,pickup,25.0339,121.5286,85,122
TP04,信義站,hub,25.0330,121.5654,96,210
TP05,萬華站,service,25.0354,121.4997,73,64
TP06,中山站,service,25.0522,121.5201,88,143

啟動 App:

uv run streamlit run app.py

PyDeck 是 deck.gl 的 Python 介面;Streamlit 透過 st.pydeck_chart() 把 Deck 物件送到前端。

三. 第一條契約:經度在前,緯度在後
#

表格常寫 lat, lon,但 GeoJSON 與多數 deck.gl accessor 使用:

[longitude, latitude]

所以 PyDeck 裡要寫 get_position="[lon, lat]"

順序弄反時,點位可能落到海上,也可能落在合法但完全錯誤的位置。先在 geo.py 驗證資料:

from __future__ import annotations

from dataclasses import dataclass
import pandas as pd

REQUIRED = {"id", "name", "kind", "lat", "lon", "score", "visits"}


def validate_locations(frame: pd.DataFrame) -> pd.DataFrame:
    missing = REQUIRED - set(frame.columns)
    if missing:
        raise ValueError(f"缺少欄位:{sorted(missing)}")

    result = frame.copy()
    for column in ("lat", "lon", "score", "visits"):
        result[column] = pd.to_numeric(result[column], errors="coerce")

    invalid = (
        result["lat"].isna()
        | result["lon"].isna()
        | ~result["lat"].between(-90, 90)
        | ~result["lon"].between(-180, 180)
        | result["id"].duplicated(keep=False)
    )
    if invalid.any():
        ids = result.loc[invalid, "id"].astype(str).tolist()
        raise ValueError(f"座標或 id 無效:{ids}")
    return result

地圖能渲染不代表資料正確;「看起來差不多」也不是品質檢查。

四. 分開查詢範圍與視覺 Viewport
#

互動地圖有三種容易混在一起的狀態:

狀態 例子 負責者
資料狀態 類型、最低分數 Streamlit widgets
空間查詢 west、south、east、north 應用程式
視覺狀態 center、zoom、pitch PyDeck ViewState

把 bounding box 寫成明確契約:

@dataclass(frozen=True)
class Bounds:
    west: float
    south: float
    east: float
    north: float

    def __post_init__(self) -> None:
        if self.west >= self.east or self.south >= self.north:
            raise ValueError("查詢範圍方向錯誤")


def inside_bounds(frame: pd.DataFrame, bounds: Bounds) -> pd.DataFrame:
    mask = (
        frame["lon"].between(bounds.west, bounds.east)
        & frame["lat"].between(bounds.south, bounds.north)
    )
    return frame.loc[mask].copy()

目前 st.pydeck_chart() 支援物件選取事件,但不會把所有平移與縮放都當成 Python 輸入回傳。

因此本文用明確的 bounding box 控制查詢,不假裝它永遠等於使用者眼前的 viewport。畫面狀態和資料查詢狀態可以相關,但不是同一件事。

五. 建立穩定的 ViewState
#

用篩選結果的中心建立視角:

import pydeck as pdk


def make_view_state(frame: pd.DataFrame) -> pdk.ViewState:
    if frame.empty:
        return pdk.ViewState(
            latitude=25.0478,
            longitude=121.5319,
            zoom=11,
            pitch=0,
        )
    return pdk.ViewState(
        latitude=float(frame["lat"].mean()),
        longitude=float(frame["lon"].mean()),
        zoom=12,
        pitch=35,
        bearing=0,
    )

固定規則可避免 rerun 後地圖亂跳。若資料跨距很大,可以依 bounds 計算 zoom,但公式應集中在函式裡並測試。

六. 點位圖層:ScatterplotLayer
#

先替類型配色,再建立可點選圖層:

COLOR_BY_KIND = {
    "hub": [45, 125, 210, 210],
    "pickup": [48, 175, 120, 210],
    "service": [240, 155, 55, 210],
}


def add_color(frame: pd.DataFrame) -> pd.DataFrame:
    result = frame.copy()
    result["color"] = result["kind"].map(COLOR_BY_KIND)
    result["color"] = result["color"].apply(
        lambda value: value if isinstance(value, list) else [130, 130, 130, 190]
    )
    return result


def make_location_layer(frame: pd.DataFrame) -> pdk.Layer:
    return pdk.Layer(
        "ScatterplotLayer",
        id="locations",
        data=add_color(frame),
        get_position="[lon, lat]",
        get_fill_color="color",
        get_radius="40 + visits * 0.6",
        radius_min_pixels=6,
        radius_max_pixels=28,
        pickable=True,
        auto_highlight=True,
    )

id="locations" 讓選取事件能對應圖層;pickable=True 讓物件可點選。最小與最大像素半徑則避免縮放後看不到點,或讓點遮住整張地圖。

複雜商業邏輯先在 pandas 算好;圖層只負責呈現。

七. 區域圖層:GeoJsonLayer
#

準備 data/service_zones.geojson,座標同樣使用 [lon, lat],而 Polygon 的首尾點必須相同:

{
  "type": "FeatureCollection",
  "features": [{
    "type": "Feature",
    "properties": {"zone_id": "central", "name": "中央服務區"},
    "geometry": {
      "type": "Polygon",
      "coordinates": [[
        [121.500, 25.025], [121.550, 25.025],
        [121.550, 25.065], [121.500, 25.065],
        [121.500, 25.025]
      ]]
    }
  }]
}

讀取後建立圖層:

import json
from pathlib import Path


def load_zones(path: str | Path) -> dict:
    with Path(path).open(encoding="utf-8") as handle:
        data = json.load(handle)
    if data.get("type") != "FeatureCollection":
        raise ValueError("必須是 GeoJSON FeatureCollection")
    return data


def make_zone_layer(geojson: dict) -> pdk.Layer:
    return pdk.Layer(
        "GeoJsonLayer",
        id="service-zones",
        data=geojson,
        filled=True,
        stroked=True,
        get_fill_color=[90, 120, 220, 45],
        get_line_color=[70, 95, 180, 180],
        line_width_min_pixels=2,
        pickable=True,
    )

正式資料還要檢查自交、多重 polygon 與 CRS。若來源不是 WGS84 經緯度,必須先轉換,不能只改欄位名稱。

八. 組合 Deck:圖層順序也是設計
#

Polygon 放前面、point 放後面,避免半透明區域蓋住據點:

def make_deck(points: pd.DataFrame, zones: dict) -> pdk.Deck:
    return pdk.Deck(
        map_style=None,
        initial_view_state=make_view_state(points),
        layers=[make_zone_layer(zones), make_location_layer(points)],
        tooltip={
            "html": (
                "<b>{name}</b><br/>類型:{kind}<br/>"
                "分數:{score}<br/>造訪數:{visits}"
            ),
            "style": {"backgroundColor": "#172033", "color": "white"},
        },
    )

map_style=None 會讓地圖採用與 Streamlit 主題相容的樣式。

Tooltip 只應收到前端真的需要的欄位;內部備註、聯絡資訊與敏感資料不要跟著整列送出去。

九. Streamlit 篩選與地圖選取
#

app.py 載入資料,接著先篩選、再建圖:

from pathlib import Path
import pandas as pd
import streamlit as st

from geo import Bounds, inside_bounds, load_zones, make_deck, validate_locations

DATA_DIR = Path(__file__).parent / "data"


@st.cache_data
def read_locations() -> pd.DataFrame:
    return validate_locations(pd.read_csv(DATA_DIR / "locations.csv"))


st.set_page_config(page_title="服務據點地圖", layout="wide")
st.title("服務據點地理 Dashboard")
locations = read_locations()

with st.sidebar:
    st.header("地圖篩選")
    options = sorted(locations["kind"].unique())
    kinds = st.multiselect("據點類型", options, default=options)
    min_score = st.slider("最低分數", 0, 100, 70)
    west = st.number_input("西界", value=121.4900, format="%.4f")
    east = st.number_input("東界", value=121.5750, format="%.4f")
    south = st.number_input("南界", value=25.0200, format="%.4f")
    north = st.number_input("北界", value=25.0700, format="%.4f")

bounds = Bounds(west=west, south=south, east=east, north=north)
filtered = locations[
    locations["kind"].isin(kinds)
    & locations["score"].ge(min_score)
]
filtered = inside_bounds(filtered, bounds)

if filtered.empty:
    st.warning("目前條件沒有據點,請放寬分數或查詢範圍。")
    st.stop()

c1, c2, c3 = st.columns(3)
c1.metric("據點", len(filtered))
c2.metric("平均分數", f"{filtered['score'].mean():.1f}")
c3.metric("總造訪", f"{int(filtered['visits'].sum()):,}")

zones = load_zones(DATA_DIR / "service_zones.geojson")
event = st.pydeck_chart(
    make_deck(filtered, zones),
    key="service-map",
    on_select="rerun",
    selection_mode="single-object",
    width="stretch",
    height=620,
)

on_select="rerun" 時,chart 會像輸入元件一樣回傳選取狀態。所有可選 layer 都必須有穩定 id

取出被點選的據點:

selected = event.selection.objects.get("locations", [])
if selected:
    selected_id = str(selected[0]["id"])
    detail = locations.loc[locations["id"].eq(selected_id)]
    st.subheader(f"已選取:{detail.iloc[0]['name']}")
    st.dataframe(detail, hide_index=True, width="stretch")
else:
    st.info("點一下地圖上的據點,就會看到詳細資料。")

with st.expander("查看篩選後資料"):
    st.dataframe(filtered, hide_index=True, width="stretch")

事件回傳的 row metadata 適合定位;真正顯示明細時,再用業務 id 回原始資料查找。這比相信前端回傳的整列資料更穩定。

十. Rerun 下的地圖狀態契約
#

Streamlit 每次互動都可能 rerun,所以至少固定:

  • layer id
  • chart key
  • 每列資料的業務 id
  • ViewState 建立規則
  • 空間查詢參數
  • Tooltip 允許暴露的欄位

如果 layer ID 根據時間產生,選取狀態會失去對應;如果資料排序不穩定,只記 row index 也可能選錯物件。

選取結果是唯讀事件。若要做「加入比較清單」,請把選中的業務 ID 複製到自己的 st.session_state,不要改寫事件物件。

十一. 效能與測試
#

PyDeck 會序列化圖層資料並送到瀏覽器。資料量增加時,優先:

  1. 在伺服器端先做屬性與範圍篩選
  2. 只保留圖層和 Tooltip 需要的欄位
  3. 大量點位改用 HexagonLayerGridLayer 或預先聚合
  4. 快取讀檔與穩定轉換,不快取使用者事件
  5. 同一頁不要塞太多 PyDeck chart,以免耗盡 WebGL contexts

空間篩選不必啟動瀏覽器也能測:

import pandas as pd
import pytest
from geo import Bounds, inside_bounds


def test_inside_bounds_keeps_boundary_points() -> None:
    frame = pd.DataFrame({
        "id": ["A", "B", "C"],
        "lat": [25.00, 25.05, 25.20],
        "lon": [121.50, 121.55, 121.80],
    })
    bounds = Bounds(west=121.50, south=25.00, east=121.60, north=25.10)
    assert inside_bounds(frame, bounds)["id"].tolist() == ["A", "B"]


def test_rejects_reversed_bounds() -> None:
    with pytest.raises(ValueError):
        Bounds(west=121.6, south=25.0, east=121.5, north=25.1)

還應測試重複 ID、經緯度越界、空結果、Polygon ring 未閉合,以及事件裡沒有預期 layer ID 的情況。

十二. 常見踩雷
#

1. 把 [lat, lon] 傳給 GeoJSON
#

GeoJSON coordinate 使用 [lon, lat],不要被 CSV 欄位順序騙了。

2. 以為 viewport 就是查詢條件
#

視覺平移與資料篩選是兩套狀態。沒有 viewport event 時,就用明確 bounding box。

3. 忘記穩定的 layer ID
#

使用選取事件時,每個可選 layer 都需要 ID,chart 也應有固定 key。

4. 一口氣傳所有點
#

先裁切欄位、篩選範圍或聚合。地圖不是資料庫查詢引擎。

5. 沒確認 CRS
#

本文適合小範圍 WGS84 經緯度。跨日期變更線、極區或其他投影時,不能沿用簡單比較。

結語:把地圖當成互動資料契約
#

Streamlit + PyDeck 的價值,不只是快速畫出漂亮底圖。

可靠的地理 Dashboard 會清楚分開原始資料驗證、屬性與空間篩選、PyDeck 圖層、選取事件,以及 ViewState。

拍拍君建議先讓一個 point layer 的座標、ID、選取與測試都可靠,再慢慢疊上 polygon、heatmap 或聚合層。地圖很會製造「看起來完成」的錯覺;資料契約才是讓它真的能用的部分。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 123: 本文

相關文章

Streamlit + DuckDB 實戰:本地資料查詢 Dashboard
·8 分鐘· loading · loading
Python Streamlit DuckDB SQL Dashboard Data-Analysis
Streamlit Data Editor 實戰:可編輯表格、上傳驗證與 CSV 匯入匯出
·8 分鐘· loading · loading
Python Streamlit Data-Editor CSV Validation Developer-Tools
Streamlit + SQLModel 實戰:做一個本機 CRUD 小後台
·9 分鐘· loading · loading
Python Streamlit SQLModel SQLite CRUD Developer-Tools
Streamlit Fragments + Dialogs:局部 Rerun、即時面板與 Modal Workflow
·9 分鐘· loading · loading
Python Streamlit Fragments Dialogs Rerun Session State Developer-Tools
Streamlit AppTest 實戰:用 pytest 測 Widgets、Session State 與 CI
·9 分鐘· loading · loading
Python Streamlit AppTest Pytest Testing Session State Ci
Rich Logging Dashboard 實戰:進度、表格與 Log Console 整合
·7 分鐘· loading · loading
Python Rich Logging Cli Dashboard Developer-Tools