一. 前言:地圖不是把兩欄丟進去就完成 #
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 會序列化圖層資料並送到瀏覽器。資料量增加時,優先:
- 在伺服器端先做屬性與範圍篩選
- 只保留圖層和 Tooltip 需要的欄位
- 大量點位改用
HexagonLayer、GridLayer或預先聚合 - 快取讀檔與穩定轉換,不快取使用者事件
- 同一頁不要塞太多 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 或聚合層。地圖很會製造「看起來完成」的錯覺;資料契約才是讓它真的能用的部分。