File size: 10,794 Bytes
7b28d75
7b9e963
7b28d75
7b9e963
7b28d75
 
7b9e963
 
 
7b28d75
 
7b9e963
 
 
 
7b28d75
7b9e963
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7b28d75
7b9e963
 
 
 
 
 
 
 
 
7b28d75
7b9e963
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7b28d75
 
7b9e963
7b28d75
7b9e963
 
 
7b28d75
7b9e963
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7b28d75
 
7b9e963
 
 
 
 
 
 
7b28d75
7b9e963
 
 
 
 
7b28d75
7b9e963
 
 
 
 
7b28d75
7b9e963
 
 
 
 
7b28d75
7b9e963
7b28d75
7b9e963
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
import os
import io
import json
from datetime import datetime, timedelta, timezone

import requests
import pandas as pd
import matplotlib.pyplot as plt
import gradio as gr


# -----------------------------
# 時區:台北 (UTC+8)
# -----------------------------
TAIPEI_TZ = timezone(timedelta(hours=8))

def now_taipei():
    return datetime.now(TAIPEI_TZ)

def fmt_dt(dt: datetime) -> str:
    return dt.strftime("%Y-%m-%dT%H:%M:%S")


# -----------------------------
# 快速時間範圍
# -----------------------------
def set_time_range(hours: int | None = None, days: int | None = None):
    """
    依台北時間回傳 (timeFrom, timeTo) 字串(yyyy-MM-ddTHH:mm:ss)
    """
    now = now_taipei()
    if hours is not None:
        t_from = now - timedelta(hours=hours)
    elif days is not None:
        t_from = now - timedelta(days=days)
    else:
        t_from = now - timedelta(days=3)  # 預設最近 3 天
    return fmt_dt(t_from), fmt_dt(now)


# -----------------------------
# 呼叫 CWA E-A0015-001 API
# -----------------------------
API_URL = "https://opendata.cwa.gov.tw/api/v1/rest/datastore/E-A0015-001"

def fetch_reports(time_from: str, time_to: str) -> dict:
    """
    以環境變數 CWA_API_KEY 做授權參數呼叫 API,回傳 JSON 物件(dict)
    """
    api_key = os.getenv("CWA_API_KEY", "").strip()
    if not api_key:
        raise RuntimeError("環境變數 CWA_API_KEY 未設定。請在 Space Secrets 設定。")

    params = {
        "Authorization": api_key,
        "timeFrom": time_from,
        "timeTo": time_to,
        # 其餘參數保持預設;排序改由本地處理,以避免介面差異
    }
    resp = requests.get(API_URL, params=params, timeout=30)
    resp.raise_for_status()
    return resp.json()


# -----------------------------
# 解析 JSON → pandas.DataFrame
# 只保留必要欄位(不含 area / station 等)
# -----------------------------
def safe_get(d, *keys, default=None):
    cur = d
    for k in keys:
        if isinstance(cur, dict) and k in cur:
            cur = cur[k]
        else:
            return default
    return cur

def parse_ea0015(json_obj: dict) -> pd.DataFrame:
    """
    嘗試容錯各種大小寫與路徑差異,輸出欄位:
    OriginTime, Lat, Lon, Depth_km, Magnitude, Location, ReportURL
    """
    records = json_obj.get("records") or json_obj.get("Records") or {}
    quakes = (
        records.get("earthquake") or
        records.get("Earthquake") or
        records.get("data") or
        []
    )

    rows = []
    for q in quakes:
        origin = safe_get(q, "originTime") or safe_get(q, "OriginTime")
        # 經緯度 & 深度
        lat = (
            safe_get(q, "epicenter", "epicenterLat") or
            safe_get(q, "Epicenter", "EpicenterLat")
        )
        lon = (
            safe_get(q, "epicenter", "epicenterLon") or
            safe_get(q, "Epicenter", "EpicenterLon")
        )
        depth = (
            safe_get(q, "depth") or
            safe_get(q, "Depth")
        )
        # 規模
        mag = (
            safe_get(q, "magnitude", "magnitudeValue") or
            safe_get(q, "Magnitude", "MagnitudeValue") or
            safe_get(q, "magnitude", "magnitude") or
            safe_get(q, "Magnitude", "Magnitude")
        )
        # 位置與連結
        loc = (
            safe_get(q, "epicenter", "location") or
            safe_get(q, "Epicenter", "Location")
        )
        url = safe_get(q, "reportURL") or safe_get(q, "ReportURL")

        rows.append({
            "OriginTime": origin,
            "Lat": _to_float(lat),
            "Lon": _to_float(lon),
            "Depth_km": _to_float(depth),
            "Magnitude": _to_float(mag),
            "Location": loc,
            "ReportURL": url,
        })

    df = pd.DataFrame(rows)

    # 轉時間、排序(預設:OriginTime 由新到舊)
    if not df.empty:
        df["OriginTime"] = pd.to_datetime(df["OriginTime"], errors="coerce")
        df = df.sort_values("OriginTime", ascending=False, na_position="last").reset_index(drop=True)

    return df

def _to_float(x):
    try:
        if x is None or x == "":
            return None
        return float(str(x).strip())
    except Exception:
        return None


# -----------------------------
# 視覺化:趨勢圖、台灣地圖
# -----------------------------
TAIWAN_BBOX = (119, 123, 21, 26)  # lon_min, lon_max, lat_min, lat_max

def plot_trend(df: pd.DataFrame) -> bytes | None:
    """時間-規模散點圖,輸出 PNG bytes"""
    if df.empty:
        return None
    fig, ax = plt.subplots(figsize=(6, 3.8))
    ax.scatter(df["OriginTime"], df["Magnitude"])
    ax.set_xlabel("Origin Time (Taipei)")
    ax.set_ylabel("Magnitude")
    ax.grid(True, linestyle="--", alpha=0.4)
    fig.autofmt_xdate()

    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=160, bbox_inches="tight")
    plt.close(fig)
    buf.seek(0)
    return buf.getvalue()

def plot_taiwan_map(df: pd.DataFrame) -> bytes | None:
    """
    基礎台灣範圍框圖(非海岸線),用散點展示震央;
    以規模對應 marker 大小,附簡易圖例。
    """
    if df.empty:
        return None

    lon_min, lon_max, lat_min, lat_max = TAIWAN_BBOX
    fig, ax = plt.subplots(figsize=(6, 6))

    # 邊框
    ax.set_xlim(lon_min, lon_max)
    ax.set_ylim(lat_min, lat_max)
    ax.set_xlabel("Longitude (°E)")
    ax.set_ylabel("Latitude (°N)")
    ax.set_title("Epicenters in Taiwan Region (119–123E, 21–26N)")

    # 散點:大小反映規模
    mags = df["Magnitude"].fillna(0)
    sizes = (mags.clip(lower=0) + 2.0) ** 3  # 調整一下讓差異更明顯
    ax.scatter(df["Lon"], df["Lat"], s=sizes, alpha=0.6, edgecolor="black")

    # 簡單圖例:M3/4/5/6 對應大小
    for m in [3, 4, 5, 6]:
        ax.scatter([], [], s=((m + 2.0) ** 3), alpha=0.6, edgecolor="black", label=f"M {m}")
    ax.legend(title="Magnitude", loc="upper right", frameon=True)

    ax.grid(True, linestyle="--", alpha=0.3)

    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=160, bbox_inches="tight")
    plt.close(fig)
    buf.seek(0)
    return buf.getvalue()


# -----------------------------
# 主流程:查詢 + 輸出
# -----------------------------
def query_and_render(time_from: str, time_to: str, sort_order: str):
    """
    1) 取資料 → 2) 轉成 DataFrame → 3) 依排序輸出 → 4) 回傳表格、圖、CSV
    Gradio 會接收回傳值更新元件。
    """
    try:
        raw = fetch_reports(time_from, time_to)
    except Exception as e:
        return gr.update(value=f"查詢錯誤:{e}"), None, None, None

    try:
        df = parse_ea0015(raw)
        if df.empty:
            return "(查無資料)", None, None, None

        # 排序(本地)
        if sort_order == "OriginTime (舊→新)":
            df = df.sort_values("OriginTime", ascending=True, na_position="last").reset_index(drop=True)
        else:
            df = df.sort_values("OriginTime", ascending=False, na_position="last").reset_index(drop=True)

        # 表格(轉為 markdown 簡表,手機閱讀較清楚)
        md = df_to_markdown(df)

        # 圖
        trend_png = plot_trend(df)
        map_png = plot_taiwan_map(df)

        # 下載 CSV(以 bytes 形式回傳給 DownloadButton)
        csv_bytes = df.to_csv(index=False).encode("utf-8-sig")

        return md, trend_png, map_png, csv_bytes
    except Exception as e:
        return gr.update(value=f"解析錯誤:{e}"), None, None, None


def df_to_markdown(df: pd.DataFrame, top_n: int = 100) -> str:
    """
    將前 top_n 筆轉為 markdown 表(欄位:OriginTime, Magnitude, Depth_km, Lat, Lon, Location, ReportURL)
    """
    if df.empty:
        return "(查無資料)"

    show_cols = ["OriginTime", "Magnitude", "Depth_km", "Lat", "Lon", "Location", "ReportURL"]
    exist_cols = [c for c in show_cols if c in df.columns]
    slim = df[exist_cols].head(top_n).copy()

    # 時間顯示(台北時區)
    if "OriginTime" in slim.columns:
        slim["OriginTime"] = slim["OriginTime"].dt.tz_convert(TAIPEI_TZ).dt.strftime("%Y-%m-%d %H:%M:%S %Z")

    return slim.to_markdown(index=False)


# -----------------------------
# Gradio 介面
# -----------------------------
default_from, default_to = set_time_range(days=3)

with gr.Blocks(fill_height=True, analytics_enabled=False) as demo:
    gr.Markdown(
        """
# CWA 顯著有感地震報告 (E-A0015-001)
此 Space **只使用環境變數 `CWA_API_KEY` 作為授權**。  
預設查詢 **最近 3 天(台北時間)**。手機版為單欄顯示。
        """
    )

    # 單欄(手機友好)
    with gr.Column():
        with gr.Row():
            time_from = gr.Textbox(label="timeFrom yyyy-MM-ddThh:mm:ss", value=default_from, scale=1)
        with gr.Row():
            time_to   = gr.Textbox(label="timeTo yyyy-MM-ddThh:mm:ss", value=default_to, scale=1)

        # 快速時間範圍(已移除「最近6小時」)
        with gr.Row():
            btn_12h = gr.Button("最近 12 小時")
            btn_24h = gr.Button("最近 24 小時")
            btn_3d  = gr.Button("最近 3 天")
            btn_5d  = gr.Button("最近 5 天")

        # 排序(在地端)
        sort_dd = gr.Dropdown(
            choices=["OriginTime (新→舊)", "OriginTime (舊→新)"],
            value="OriginTime (新→舊)",
            label="排序(本地)"
        )

        run_btn = gr.Button("查詢", variant="primary")

        # 輸出
        table_out = gr.Markdown("(尚未查詢)")
        trend_out = gr.Image(label="趨勢圖(時間-規模)", type="numpy")
        map_out   = gr.Image(label="台灣範圍圖(119–123E, 21–26N)", type="numpy")
        dl_btn    = gr.DownloadButton(label="下載 CSV", file_name="CWA_E-A0015-001.csv")

    # 綁定:快速鍵 → 更新時間欄位
    btn_12h.click(lambda: set_time_range(hours=12), outputs=[time_from, time_to])
    btn_24h.click(lambda: set_time_range(hours=24), outputs=[time_from, time_to])
    btn_3d.click(lambda: set_time_range(days=3), outputs=[time_from, time_to])
    btn_5d.click(lambda: set_time_range(days=5), outputs=[time_from, time_to])

    # 綁定:查詢 → 表格 / 圖 / CSV
    def _on_query(tfrom, tto, sort_sel):
        md, trend_png, map_png, csv_bytes = query_and_render(tfrom, tto, sort_sel)
        # DownloadButton 需要返回 bytes-like 物件
        return md, trend_png, map_png, csv_bytes

    run_btn.click(
        _on_query,
        inputs=[time_from, time_to, sort_dd],
        outputs=[table_out, trend_out, map_out, dl_btn]
    )

# 注意:Hugging Face Spaces 會自動呼叫 demo.launch()
if __name__ == "__main__":
    demo.launch()