Ryan181/AirPollution
0
1import ssl2import urllib.request3import pandas as pd4import requests5import streamlit as st6import plotly.express as px7import plotly.graph_objects as go8 9# ── 頁面設定 ──────────────────────────────────────────────────────────────────10st.set_page_config(11 page_title="台南市開放資料分析",12 page_icon="📊",13 layout="wide",14)15 16# ── 自訂 CSS ──────────────────────────────────────────────────────────────────17st.markdown("""18<style>19 /* 主背景 */20 .stApp { background: linear-gradient(135deg, #0f0c29, #302b63, #24243e); }21 22 /* 標題區塊 */23 .title-block {24 background: linear-gradient(90deg, #667eea, #764ba2);25 border-radius: 16px;26 padding: 28px 36px;27 margin-bottom: 24px;28 box-shadow: 0 8px 32px rgba(102,126,234,0.35);29 }30 .title-block h1 { color: #fff; font-size: 2.2rem; margin: 0; letter-spacing: 1px; }31 .title-block p { color: rgba(255,255,255,0.75); margin: 6px 0 0; font-size: 1rem; }32 33 /* 指標卡片 */34 .metric-card {35 background: rgba(255,255,255,0.06);36 border: 1px solid rgba(255,255,255,0.12);37 border-radius: 14px;38 padding: 20px 24px;39 text-align: center;40 backdrop-filter: blur(8px);41 }42 .metric-card .val { font-size: 2rem; font-weight: 700; color: #a78bfa; }43 .metric-card .lbl { font-size: 0.85rem; color: rgba(255,255,255,0.6); margin-top: 4px; }44 45 /* 側邊欄 */46 section[data-testid="stSidebar"] {47 background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);48 border-right: 1px solid rgba(255,255,255,0.08);49 }50 section[data-testid="stSidebar"] * { color: #e2e8f0 !important; }51 52 /* 圖表容器 */53 .chart-container {54 background: rgba(255,255,255,0.04);55 border: 1px solid rgba(255,255,255,0.1);56 border-radius: 16px;57 padding: 8px;58 margin-top: 8px;59 }60 61 /* Divider */62 hr { border-color: rgba(255,255,255,0.1); }63</style>64""", unsafe_allow_html=True)65 66# ── 資料載入(含 SSL 略過) ────────────────────────────────────────────────────67@st.cache_data(show_spinner=False)68def load_data():69 url = "https://data.tainan.gov.tw/File/ResourceCsvDownload/aad7b580-5a86-467d-8d91-fda661267a71"70 ctx = ssl.create_default_context()71 ctx.check_hostname = False72 ctx.verify_mode = ssl.CERT_NONE73 try:74 with urllib.request.urlopen(url, context=ctx) as resp:75 raw = resp.read()76 except Exception:77 # Fallback: requests 不驗憑證78 r = requests.get(url, verify=False, timeout=30)79 raw = r.content80 import io81 df = pd.read_csv(io.BytesIO(raw), encoding="utf-8-sig")82 df = df.dropna()83 df.columns = df.columns.str.strip()84 return df85 86# ── 讀取資料 ──────────────────────────────────────────────────────────────────87with st.spinner("📡 正在載入資料…"):88 try:89 df_raw = load_data()90 load_ok = True91 except Exception as e:92 load_ok = False93 err_msg = str(e)94 95# ── 標題 ──────────────────────────────────────────────────────────────────────96st.markdown("""97<div class="title-block">98 <h1>📊 台南市開放資料分析儀表板</h1>99 <p>互動式旭日圖 & 折線圖 · Powered by Streamlit × Plotly</p>100</div>101""", unsafe_allow_html=True)102 103if not load_ok:104 st.error(f"資料載入失敗:{err_msg}")105 st.stop()106 107# ── 欄位偵測 ──────────────────────────────────────────────────────────────────108cols = df_raw.columns.tolist()109 110# 自動偵測「縣市」欄(優先關鍵字)111CITY_KEYS = ["縣市", "city", "行政區", "地區", "區域", "發生地點"]112ADDR_KEYS = ["地址類型", "道路類型", "路段", "地點", "type", "類型", "肇事地點"]113TIME_KEYS = ["年", "year", "月", "month", "日期", "發生時間", "時間"]114COUNT_KEYS = ["件數", "數量", "count", "筆數", "總數", "死亡人數", "受傷人數"]115 116def auto_col(keys):117 for k in keys:118 for c in cols:119 if k.lower() in c.lower():120 return c121 return None122 123city_col = auto_col(CITY_KEYS)124addr_col = auto_col(ADDR_KEYS)125time_col = auto_col(TIME_KEYS)126count_col = auto_col(COUNT_KEYS)127 128# ── 側邊欄:篩選器 ────────────────────────────────────────────────────────────129with st.sidebar:130 st.markdown("## ⚙️ 篩選條件")131 st.markdown("---")132 133 # 縣市欄位選擇134 city_col_sel = st.selectbox(135 "縣市 / 地點欄位",136 options=cols,137 index=cols.index(city_col) if city_col else 0,138 )139 140 # 使用者輸入縣市關鍵字141 city_input = st.text_input("🔍 輸入縣市(模糊搜尋,留空=全部)", placeholder="例:台南、臺南、永康")142 143 st.markdown("---")144 145 # 地址類型欄位選擇146 addr_col_sel = st.selectbox(147 "地址類型欄位",148 options=cols,149 index=cols.index(addr_col) if addr_col else 0,150 )151 152 # 複選地址類型153 all_addr_types = sorted(df_raw[addr_col_sel].dropna().unique().tolist())154 selected_addr = st.multiselect(155 "📍 地址類型(複選)",156 options=all_addr_types,157 default=all_addr_types[:min(5, len(all_addr_types))],158 )159 160 st.markdown("---")161 162 # 時間欄 & 數值欄163 time_col_sel = st.selectbox(164 "時間欄位(折線圖 X 軸)",165 options=[None] + cols,166 index=(cols.index(time_col) + 1) if time_col else 0,167 format_func=lambda x: "(自動計數)" if x is None else x,168 )169 count_col_sel = st.selectbox(170 "數值欄位(折線圖 Y 軸)",171 options=[None] + cols,172 index=(cols.index(count_col) + 1) if count_col else 0,173 format_func=lambda x: "(計筆數)" if x is None else x,174 )175 176 st.markdown("---")177 st.caption("資料來源:台南市政府開放資料平台")178 179# ── 篩選資料 ──────────────────────────────────────────────────────────────────180df = df_raw.copy()181 182if city_input.strip():183 df = df[df[city_col_sel].astype(str).str.contains(city_input.strip(), na=False)]184 185if selected_addr:186 df = df[df[addr_col_sel].isin(selected_addr)]187 188# ── 指標列 ────────────────────────────────────────────────────────────────────189total_rows = len(df)190addr_count = df[addr_col_sel].nunique()191city_count = df[city_col_sel].nunique()192 193c1, c2, c3, c4 = st.columns(4)194for col_ui, val, lbl in zip(195 [c1, c2, c3, c4],196 [len(df_raw), total_rows, city_count, addr_count],197 ["原始筆數", "篩選後筆數", "縣市種類數", "地址類型數"],198):199 col_ui.markdown(f"""200 <div class="metric-card">201 <div class="val">{val:,}</div>202 <div class="lbl">{lbl}</div>203 </div>204 """, unsafe_allow_html=True)205 206st.markdown("<br>", unsafe_allow_html=True)207 208# ── 若無資料 ──────────────────────────────────────────────────────────────────209if df.empty:210 st.warning("⚠️ 篩選條件下無資料,請調整左側設定。")211 st.stop()212 213# ── 圖表區:旭日圖 & 折線圖 ───────────────────────────────────────────────────214left, right = st.columns([1, 1], gap="large")215 216# ╔══════════ 旭日圖 ════════════╗217with left:218 st.markdown("### 🌞 旭日圖")219 st.caption(f"以「{city_col_sel}」→「{addr_col_sel}」雙層展開")220 221 # 計算各組合筆數222 sun_df = (223 df.groupby([city_col_sel, addr_col_sel])224 .size()225 .reset_index(name="件數")226 )227 228 fig_sun = px.sunburst(229 sun_df,230 path=[city_col_sel, addr_col_sel],231 values="件數",232 color="件數",233 color_continuous_scale="Plasma",234 title="",235 )236 fig_sun.update_traces(237 textinfo="label+percent root",238 hovertemplate="<b>%{label}</b><br>件數:%{value}<br>佔比:%{percentRoot:.1%}<extra></extra>",239 )240 fig_sun.update_layout(241 paper_bgcolor="rgba(0,0,0,0)",242 plot_bgcolor="rgba(0,0,0,0)",243 font_color="#e2e8f0",244 height=520,245 margin=dict(t=20, b=10, l=10, r=10),246 coloraxis_colorbar=dict(247 tickfont=dict(color="#e2e8f0"),248 title=dict(text="件數", font=dict(color="#e2e8f0")),249 ),250 )251 with st.container():252 st.markdown('<div class="chart-container">', unsafe_allow_html=True)253 st.plotly_chart(fig_sun, use_container_width=True)254 st.markdown('</div>', unsafe_allow_html=True)255 256# ╔══════════ 折線圖 ════════════╗257with right:258 st.markdown("### 📈 折線圖")259 260 if time_col_sel:261 st.caption(f"X 軸:{time_col_sel} Y 軸:{'筆數' if not count_col_sel else count_col_sel}")262 if count_col_sel:263 line_df = (264 df.groupby([time_col_sel, addr_col_sel])[count_col_sel]265 .sum()266 .reset_index()267 .rename(columns={count_col_sel: "數值"})268 )269 y_label = count_col_sel270 else:271 line_df = (272 df.groupby([time_col_sel, addr_col_sel])273 .size()274 .reset_index(name="數值")275 )276 y_label = "筆數"277 278 line_df[time_col_sel] = line_df[time_col_sel].astype(str)279 280 fig_line = px.line(281 line_df,282 x=time_col_sel,283 y="數值",284 color=addr_col_sel,285 markers=True,286 labels={"數值": y_label, time_col_sel: time_col_sel, addr_col_sel: "地址類型"},287 color_discrete_sequence=px.colors.qualitative.Vivid,288 )289 else:290 # 無時間欄:用地址類型做橫軸長條折線291 st.caption("(未指定時間欄,改以地址類型為 X 軸)")292 line_df = (293 df.groupby([addr_col_sel])294 .size()295 .reset_index(name="筆數")296 .sort_values("筆數", ascending=False)297 )298 fig_line = go.Figure(299 go.Scatter(300 x=line_df[addr_col_sel].astype(str),301 y=line_df["筆數"],302 mode="lines+markers",303 marker=dict(size=10, color="#a78bfa"),304 line=dict(color="#7c3aed", width=2.5),305 hovertemplate="%{x}<br>筆數:%{y}<extra></extra>",306 )307 )308 fig_line.update_layout(xaxis_title="地址類型", yaxis_title="筆數")309 310 fig_line.update_layout(311 paper_bgcolor="rgba(0,0,0,0)",312 plot_bgcolor="rgba(0,0,0,0)",313 font_color="#e2e8f0",314 height=520,315 margin=dict(t=20, b=10, l=10, r=10),316 legend=dict(317 bgcolor="rgba(255,255,255,0.05)",318 bordercolor="rgba(255,255,255,0.1)",319 font=dict(color="#e2e8f0"),320 ),321 xaxis=dict(gridcolor="rgba(255,255,255,0.08)", tickangle=-30),322 yaxis=dict(gridcolor="rgba(255,255,255,0.08)"),323 hovermode="x unified",324 )325 with st.container():326 st.markdown('<div class="chart-container">', unsafe_allow_html=True)327 st.plotly_chart(fig_line, use_container_width=True)328 st.markdown('</div>', unsafe_allow_html=True)329 330# ── 原始資料預覽 ──────────────────────────────────────────────────────────────331st.markdown("---")332with st.expander("🗂️ 查看篩選後原始資料", expanded=False):333 st.dataframe(334 df.reset_index(drop=True),335 use_container_width=True,336 height=300,337 )338 st.caption(f"共 {len(df):,} 筆 · 欄位:{', '.join(df.columns.tolist())}")