cwadayi commited on
Commit
7b9e963
·
verified ·
1 Parent(s): 7b28d75

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +308 -325
app.py CHANGED
@@ -1,345 +1,328 @@
1
-
2
- # app.py (pure HTML/folium version: no PNG charts)
3
  import os
 
4
  import json
5
- import tempfile
6
- from datetime import datetime, timedelta
7
- from typing import List, Dict, Any, Tuple
8
 
9
- import gradio as gr
10
- import pandas as pd
11
  import requests
12
- import folium
 
 
13
 
14
- BASE_URL = "https://opendata.cwa.gov.tw/api/v1/rest/datastore/E-A0015-001"
15
 
16
- TILE_CHOICES = {
17
- "OpenStreetMap": "OpenStreetMap",
18
- "CartoDB Positron": "CartoDB positron",
19
- "Stamen Terrain": "Stamen Terrain",
20
- "Esri World Imagery (衛星)": "Esri.WorldImagery"
21
- }
22
 
23
- def validate_iso(dt: str) -> str:
24
- if not dt:
25
- return ""
26
- try:
27
- datetime.strptime(dt, "%Y-%m-%dT%H:%M:%S")
28
- return dt
29
- except ValueError:
30
- raise gr.Error("時間格式需為 yyyy-MM-ddThh:mm:ss")
31
-
32
- def build_params(limit: int|None, offset: int|None, fmt: str, sort: str|None, timeFrom: str|None, timeTo: str|None):
33
- params = []
34
- api_key = os.getenv("CWA_API_KEY")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  if not api_key:
36
- raise gr.Error("缺少授權碼:請到 Space Settings Repository secrets 新增 CWA_API_KEY。")
37
- params.append(("Authorization", api_key))
38
- if limit is not None: params.append(("limit", str(limit)))
39
- if offset is not None: params.append(("offset", str(offset)))
40
- if fmt: params.append(("format", fmt))
41
- if sort: params.append(("sort", sort))
42
- if timeFrom: params.append(("timeFrom", timeFrom))
43
- if timeTo: params.append(("timeTo", timeTo))
44
- return params
45
-
46
- def http_get(url: str, params: List[Tuple[str,str]]) -> Dict[str, Any]:
47
- sess = requests.Session()
48
- resp = sess.get(url, params=params, timeout=(5, 20))
49
  resp.raise_for_status()
50
- if "application/json" in resp.headers.get("Content-Type","").lower() or resp.text.strip().startswith("{"):
51
- return resp.json()
52
- else:
53
- return {"raw": resp.text}
54
-
55
- def extract_records(payload: Dict[str, Any]):
56
- recs = payload.get("records")
57
- if isinstance(recs, dict):
58
- for k, v in recs.items():
59
- if isinstance(v, list):
60
- return v
61
- result = payload.get("result")
62
- if isinstance(result, dict) and isinstance(result.get("records"), list):
63
- return result["records"]
64
- for key in ("Earthquake","earthquakes","data","items"):
65
- v = payload.get(key)
66
- if isinstance(v, list):
67
- return v
68
- return []
69
-
70
- def flatten_row(row: Dict[str, Any]) -> Dict[str, Any]:
71
- out = {}
72
- for key in ("EarthquakeNo","ReportImageURI","Web","ReportColor","ReportContent"):
73
- if key in row:
74
- out[key] = row.get(key)
75
- eqi = row.get("EarthquakeInfo")
76
- if isinstance(eqi, dict):
77
- out["OriginTime"] = eqi.get("OriginTime")
78
- out["Depth_km"] = eqi.get("FocalDepth")
79
- mag = eqi.get("EarthquakeMagnitude") or {}
80
- if isinstance(mag, dict):
81
- out["Magnitude"] = mag.get("MagnitudeValue")
82
- out["MagnitudeType"] = mag.get("MagnitudeType")
83
- epic = eqi.get("Epicenter") or {}
84
- if isinstance(epic, dict):
85
- out["Epicenter"] = epic.get("Location")
86
- out["EpicenterLon"] = epic.get("EpicenterLongitude")
87
- out["EpicenterLat"] = epic.get("EpicenterLatitude")
88
- for k in ("OriginTime","originTime","Time"):
89
- if k in row and "OriginTime" not in out:
90
- out["OriginTime"] = row.get(k)
91
- for k in ("Depth","depth","FocalDepth"):
92
- if k in row and "Depth_km" not in out:
93
- out["Depth_km"] = row.get(k)
94
- for k in ("Magnitude","mag"):
95
- if k in row and "Magnitude" not in out:
96
- out["Magnitude"] = row.get(k)
97
- maxint = row.get("Intensity") or row.get("ShakingArea")
98
- if isinstance(maxint, dict):
99
- out["MaxIntensity"] = maxint.get("MaxIntensity")
100
- return out
101
-
102
- def df_with_types(df: pd.DataFrame) -> pd.DataFrame:
103
- if "OriginTime" in df.columns:
104
- try:
105
- df["OriginTime"] = pd.to_datetime(df["OriginTime"], format="%Y-%m-%dT%H:%M:%S", errors="coerce")
106
- except Exception:
107
- pass
108
- if "Magnitude" in df.columns:
109
- df["Magnitude"] = pd.to_numeric(df["Magnitude"], errors="coerce")
110
- if "Depth_km" in df.columns:
111
- df["Depth_km"] = pd.to_numeric(df["Depth_km"], errors="coerce")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  return df
113
 
114
- def add_tw_bbox(m: folium.Map):
115
- bounds = [(21.0, 119.0), (26.0, 123.0)]
116
- folium.Rectangle(bounds=bounds, color="#444", fill=False, weight=2, dash_array="5").add_to(m)
117
-
118
- def add_legend(m: folium.Map):
119
- html = '''
120
- <div style="position: fixed; bottom: 10px; right: 10px; z-index:9999; background: rgba(255,255,255,0.9); padding: 8px 10px; border:1px solid #999; border-radius:6px; font-size:12px;">
121
- <div style="font-weight:600; margin-bottom:4px;">圖例</div>
122
- <div><span style="display:inline-block;width:12px;height:12px;background:#abd9e9;margin-right:6px;border:1px solid #999;"></span> M4.0–4.9</div>
123
- <div><span style="display:inline-block;width:12px;height:12px;background:#fdae61;margin-right:6px;border:1px solid #999;"></span> M5.0–5.9</div>
124
- <div><span style="display:inline-block;width:12px;height:12px;background:#d7191c;margin-right:6px;border:1px solid #999;"></span> M≥6.0</div>
125
- <div style="margin-top:4px;">圓徑 ≈ 規模 × 2.5</div>
126
- </div>
127
- '''
128
- folium.Marker(location=[0,0], icon=folium.DivIcon(html=html)).add_to(m)
129
-
130
- def make_map(df: pd.DataFrame, tile_choice: str) -> str:
131
- if not {"EpicenterLat","EpicenterLon"}.issubset(df.columns):
132
- return "<p>沒有可用的經緯度資料。</p>"
133
- valid = df.dropna(subset=["EpicenterLat","EpicenterLon"]).copy()
134
- if valid.empty:
135
- return "<p>沒有可用的經緯度資料。</p>"
136
  try:
137
- valid["EpicenterLat"] = pd.to_numeric(valid["EpicenterLat"], errors="coerce")
138
- valid["EpicenterLon"] = pd.to_numeric(valid["EpicenterLon"], errors="coerce")
 
139
  except Exception:
140
- pass
141
- valid = valid.dropna(subset=["EpicenterLat","EpicenterLon"])
142
- if valid.empty:
143
- return "<p>沒有可用的經緯度資料。</p>"
144
- tiles = TILE_CHOICES.get(tile_choice, "OpenStreetMap")
145
- if tiles == "Esri.WorldImagery":
146
- m = folium.Map(location=[valid["EpicenterLat"].mean(), valid["EpicenterLon"].mean()], zoom_start=6, tiles=None)
147
- folium.TileLayer(
148
- tiles="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
149
- attr="Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community",
150
- name="Esri World Imagery"
151
- ).add_to(m)
152
- else:
153
- m = folium.Map(location=[valid["EpicenterLat"].mean(), valid["EpicenterLon"].mean()], zoom_start=6, tiles=tiles)
154
- add_tw_bbox(m)
155
- for _, r in valid.iterrows():
156
- lat = float(r["EpicenterLat"]); lon = float(r["EpicenterLon"])
157
- mag = r.get("Magnitude", None)
158
- radius = 4.0
159
- try:
160
- if pd.notna(mag):
161
- radius = max(4.0, min(20.0, float(mag) * 2.5))
162
- except Exception:
163
- pass
164
- color = "#2c7bb6"
165
- try:
166
- if mag is not None and float(mag) >= 6.0:
167
- color = "#d7191c"
168
- elif mag is not None and float(mag) >= 5.0:
169
- color = "#fdae61"
170
- elif mag is not None and float(mag) >= 4.0:
171
- color = "#abd9e9"
172
- except Exception:
173
- pass
174
- popup = folium.Popup(html=f"<b>時間</b>: {r.get('OriginTime','')}<br>"
175
- f"<b>震央</b>: {r.get('Epicenter','')}<br>"
176
- f"<b>規模</b>: {mag}<br>"
177
- f"<b>深度</b>: {r.get('Depth_km','')} km", max_width=320)
178
- folium.CircleMarker(location=[lat, lon], radius=radius, color=color, fill=True, fill_opacity=0.7, popup=popup).add_to(m)
179
- add_legend(m)
180
- return m._repr_html_()
181
-
182
- def fetch(time_from, time_to, limit, offset, fmt, sort, tile_choice):
183
- time_from = validate_iso(time_from) if time_from else None
184
- time_to = validate_iso(time_to) if time_to else None
185
- params = build_params(limit=limit, offset=offset, fmt=fmt, sort=sort, timeFrom=time_from, timeTo=time_to)
186
- payload = http_get(BASE_URL, params)
187
- records = extract_records(payload)
188
- flat = [flatten_row(r) for r in records]
189
- df = pd.DataFrame(flat)
190
- df = df_with_types(df)
191
- if "OriginTime" in df.columns:
192
- ascending = True if sort == "OriginTime" else False
193
- df = df.sort_values("OriginTime", ascending=ascending)
194
- tmpdir = tempfile.mkdtemp(prefix="cwa_")
195
- csv_path = os.path.join(tmpdir, "cwa_quake.csv")
196
- json_path = os.path.join(tmpdir, "raw.json")
197
- geojson_path = os.path.join(tmpdir, "cwa_quake.geojson")
198
- kml_path = os.path.join(tmpdir, "cwa_quake.kml")
199
- df.to_csv(csv_path, index=False, encoding="utf-8")
200
- with open(json_path, "w", encoding="utf-8") as f:
201
- json.dump(payload, f, ensure_ascii=False, indent=2)
202
- # Write GeoJSON
203
- to_geojson(df, geojson_path)
204
- # Write KML
205
- to_kml(df, kml_path)
206
- total = len(df)
207
- earliest = latest = ""
208
- if total and "OriginTime" in df.columns:
209
- earliest_row = df.iloc[0]; latest_row = df.iloc[-1]
210
- def fmt_row(r):
211
- ot = r.get("OriginTime","")
212
- if isinstance(ot, pd.Timestamp):
213
- ot = ot.strftime("%Y-%m-%dT%H:%M:%S")
214
- return f"{ot} | {r.get('Epicenter','')} | M{r.get('Magnitude','')} | 深{r.get('Depth_km','')}km"
215
- earliest = "最早: " + fmt_row(earliest_row)
216
- latest = "最新: " + fmt_row(latest_row)
217
- summary = f"取得筆數: {total}\n{earliest}\n{latest}"
218
- html_map = make_map(df, tile_choice)
219
- if "OriginTime" in df.columns:
220
- df["OriginTime"] = df["OriginTime"].astype(str)
221
- return df, summary, csv_path, json_path, html_map, geojson_path, kml_path
222
-
223
- def to_geojson(df: pd.DataFrame, path: str) -> str:
224
- if not {"EpicenterLat","EpicenterLon"}.issubset(df.columns):
225
- with open(path, "w", encoding="utf-8") as f:
226
- json.dump({"type":"FeatureCollection","features":[]}, f)
227
- return path
228
- features = []
229
- for _, r in df.iterrows():
230
- try:
231
- lat = float(r.get("EpicenterLat"))
232
- lon = float(r.get("EpicenterLon"))
233
- except (TypeError, ValueError):
234
- continue
235
- props = {
236
- "OriginTime": str(r.get("OriginTime","")),
237
- "Epicenter": r.get("Epicenter",""),
238
- "Magnitude": r.get("Magnitude",""),
239
- "Depth_km": r.get("Depth_km","")
240
- }
241
- features.append({
242
- "type": "Feature",
243
- "geometry": {"type": "Point", "coordinates": [lon, lat]},
244
- "properties": props
245
- })
246
- fc = {"type":"FeatureCollection", "features": features}
247
- with open(path, "w", encoding="utf-8") as f:
248
- json.dump(fc, f, ensure_ascii=False, indent=2)
249
- return path
250
-
251
- def to_kml(df: pd.DataFrame, path: str) -> str:
252
- def esc(s):
253
- return str(s).replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
254
- kml = [
255
- '<?xml version="1.0" encoding="UTF-8"?>',
256
- '<kml xmlns="http://www.opengis.net/kml/2.2">',
257
- "<Document>"
258
- ]
259
- if {"EpicenterLat","EpicenterLon"}.issubset(df.columns):
260
- for _, r in df.iterrows():
261
- try:
262
- lat = float(r.get("EpicenterLat"))
263
- lon = float(r.get("EpicenterLon"))
264
- except (TypeError, ValueError):
265
- continue
266
- name = f"M{r.get('Magnitude','')} {r.get('Epicenter','')}"
267
- desc = f"時間: {r.get('OriginTime','')}\n深度: {r.get('Depth_km','')} km"
268
- kml.extend([
269
- "<Placemark>",
270
- f"<name>{esc(name)}</name>",
271
- f"<description>{esc(desc)}</description>",
272
- "<Point>",
273
- f"<coordinates>{lon},{lat},0</coordinates>",
274
- "</Point>",
275
- "</Placemark>"
276
- ])
277
- kml.append("</Document></kml>")
278
- with open(path, "w", encoding="utf-8") as f:
279
- f.write("\n".join(kml))
280
- return path
281
-
282
- def quick_range(hours: int):
283
- now = datetime.now()
284
- tf = (now - timedelta(hours=hours)).strftime("%Y-%m-%dT%H:%M:%S")
285
- tt = now.strftime("%Y-%m-%dT%H:%M:%S")
286
- return tf, tt
287
-
288
- with gr.Blocks(title="CWA 顯著有感地震報告 (E-A0015-001)") as demo:
289
- gr.Markdown("# CWA 顯著有感地震報告 (E-A0015-001)")
290
- gr.Markdown("**此 Space 只使用環境變數 `CWA_API_KEY` 作為授權。** 預設查詢最近 3 天。")
291
-
292
- tf_default = (datetime.now() - timedelta(days=3)).strftime("%Y-%m-%dT%H:%M:%S")
293
- tt_default = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
294
-
295
- time_from = gr.Textbox(label="timeFrom yyyy-MM-ddThh:mm:ss", value=tf_default)
296
- time_to = gr.Textbox(label="timeTo yyyy-MM-ddThh:mm:ss", value=tt_default)
297
- with gr.Row():
298
- btn6 = gr.Button("最近 6 小時")
299
- btn12 = gr.Button("最近 12 小時")
300
- btn24 = gr.Button("最近 24 小時")
301
- btn3d = gr.Button("最近 3 天")
302
- sort = gr.Dropdown(choices=[None, "OriginTime"], value=None, label="sort(預設降冪;選 OriginTime 會升冪)")
303
- limit = gr.Number(label="limit(筆數上限)", precision=0, value=60)
304
- offset = gr.Number(label="offset(起始偏移)", precision=0, value=0)
305
- fmt = gr.Radio(choices=["JSON","XML"], value="JSON", label="回傳格式")
306
- tile_choice = gr.Dropdown(choices=list(TILE_CHOICES.keys()), value="OpenStreetMap", label="地圖底圖")
307
-
308
- auto_on = gr.Checkbox(label="每小時自動刷新(固定使用目前 timeFrom/timeTo)", value=False)
309
- timer = gr.Timer(3600.0)
310
-
311
- run_btn = gr.Button("查詢", variant="primary")
312
-
313
- out_df = gr.Dataframe(label="查詢結果(扁平化)", interactive=False, wrap=True, datatype="str")
314
- out_summary = gr.Textbox(label="摘要", interactive=False)
315
- out_csv = gr.File(label="下載 CSV")
316
- out_json = gr.File(label="下載原始 JSON")
317
- out_map = gr.HTML(label="震央地圖")
318
- out_geojson = gr.File(label="下載 GeoJSON")
319
- out_kml = gr.File(label="下載 KML")
320
-
321
- def on_click(time_from, time_to, limit, offset, fmt, sort, tile_choice):
322
- df, summary, csv_path, json_path, html_map, geojson_path, kml_path = fetch(
323
- time_from, time_to, int(limit) if limit is not None else None, int(offset) if offset is not None else None,
324
- fmt, sort, tile_choice
325
  )
326
- return df, summary, csv_path, json_path, html_map, geojson_path, kml_path
327
 
328
- def on_tick(auto_on, time_from, time_to, limit, offset, fmt, sort, tile_choice):
329
- if not auto_on:
330
- return [gr.skip()] * 7
331
- return on_click(time_from, time_to, limit, offset, fmt, sort, tile_choice)
 
 
 
332
 
333
- run_btn.click(on_click, inputs=[time_from, time_to, limit, offset, fmt, sort, tile_choice],
334
- outputs=[out_df, out_summary, out_csv, out_json, out_map, out_geojson, out_kml])
 
 
 
335
 
336
- btn6.click(lambda: quick_range(6), inputs=[], outputs=[time_from, time_to])
337
- btn12.click(lambda: quick_range(12), inputs=[], outputs=[time_from, time_to])
338
- btn24.click(lambda: quick_range(24), inputs=[], outputs=[time_from, time_to])
339
- btn3d.click(lambda: ((datetime.now() - timedelta(days=3)).strftime("%Y-%m-%dT%H:%M:%S"), datetime.now().strftime("%Y-%m-%dT%H:%M:%S")), inputs=[], outputs=[time_from, time_to])
 
340
 
341
- timer.tick(on_tick, inputs=[auto_on, time_from, time_to, limit, offset, fmt, sort, tile_choice],
342
- outputs=[out_df, out_summary, out_csv, out_json, out_map, out_geojson, out_kml])
 
 
 
343
 
 
344
  if __name__ == "__main__":
345
- demo.launch()
 
 
 
1
  import os
2
+ import io
3
  import json
4
+ from datetime import datetime, timedelta, timezone
 
 
5
 
 
 
6
  import requests
7
+ import pandas as pd
8
+ import matplotlib.pyplot as plt
9
+ import gradio as gr
10
 
 
11
 
12
+ # -----------------------------
13
+ # 時區:台北 (UTC+8)
14
+ # -----------------------------
15
+ TAIPEI_TZ = timezone(timedelta(hours=8))
 
 
16
 
17
+ def now_taipei():
18
+ return datetime.now(TAIPEI_TZ)
19
+
20
+ def fmt_dt(dt: datetime) -> str:
21
+ return dt.strftime("%Y-%m-%dT%H:%M:%S")
22
+
23
+
24
+ # -----------------------------
25
+ # 快速時間範圍
26
+ # -----------------------------
27
+ def set_time_range(hours: int | None = None, days: int | None = None):
28
+ """
29
+ 依台北時間回傳 (timeFrom, timeTo) 字串(yyyy-MM-ddTHH:mm:ss)
30
+ """
31
+ now = now_taipei()
32
+ if hours is not None:
33
+ t_from = now - timedelta(hours=hours)
34
+ elif days is not None:
35
+ t_from = now - timedelta(days=days)
36
+ else:
37
+ t_from = now - timedelta(days=3) # 預設最近 3 天
38
+ return fmt_dt(t_from), fmt_dt(now)
39
+
40
+
41
+ # -----------------------------
42
+ # 呼叫 CWA E-A0015-001 API
43
+ # -----------------------------
44
+ API_URL = "https://opendata.cwa.gov.tw/api/v1/rest/datastore/E-A0015-001"
45
+
46
+ def fetch_reports(time_from: str, time_to: str) -> dict:
47
+ """
48
+ 以環境變數 CWA_API_KEY 做授權參數呼叫 API,回傳 JSON 物件(dict)
49
+ """
50
+ api_key = os.getenv("CWA_API_KEY", "").strip()
51
  if not api_key:
52
+ raise RuntimeError("環境變數 CWA_API_KEY 未設定。請在 Space Secrets 設定。")
53
+
54
+ params = {
55
+ "Authorization": api_key,
56
+ "timeFrom": time_from,
57
+ "timeTo": time_to,
58
+ # 其餘參數保持預設;排序改由本地處理,以避免介面差異
59
+ }
60
+ resp = requests.get(API_URL, params=params, timeout=30)
 
 
 
 
61
  resp.raise_for_status()
62
+ return resp.json()
63
+
64
+
65
+ # -----------------------------
66
+ # 解析 JSON → pandas.DataFrame
67
+ # 只保留必要欄位(不含 area / station 等)
68
+ # -----------------------------
69
+ def safe_get(d, *keys, default=None):
70
+ cur = d
71
+ for k in keys:
72
+ if isinstance(cur, dict) and k in cur:
73
+ cur = cur[k]
74
+ else:
75
+ return default
76
+ return cur
77
+
78
+ def parse_ea0015(json_obj: dict) -> pd.DataFrame:
79
+ """
80
+ 嘗試容錯各種大小寫與路徑差異,輸出欄位:
81
+ OriginTime, Lat, Lon, Depth_km, Magnitude, Location, ReportURL
82
+ """
83
+ records = json_obj.get("records") or json_obj.get("Records") or {}
84
+ quakes = (
85
+ records.get("earthquake") or
86
+ records.get("Earthquake") or
87
+ records.get("data") or
88
+ []
89
+ )
90
+
91
+ rows = []
92
+ for q in quakes:
93
+ origin = safe_get(q, "originTime") or safe_get(q, "OriginTime")
94
+ # 經緯度 & 深度
95
+ lat = (
96
+ safe_get(q, "epicenter", "epicenterLat") or
97
+ safe_get(q, "Epicenter", "EpicenterLat")
98
+ )
99
+ lon = (
100
+ safe_get(q, "epicenter", "epicenterLon") or
101
+ safe_get(q, "Epicenter", "EpicenterLon")
102
+ )
103
+ depth = (
104
+ safe_get(q, "depth") or
105
+ safe_get(q, "Depth")
106
+ )
107
+ # 規模
108
+ mag = (
109
+ safe_get(q, "magnitude", "magnitudeValue") or
110
+ safe_get(q, "Magnitude", "MagnitudeValue") or
111
+ safe_get(q, "magnitude", "magnitude") or
112
+ safe_get(q, "Magnitude", "Magnitude")
113
+ )
114
+ # 位置與連結
115
+ loc = (
116
+ safe_get(q, "epicenter", "location") or
117
+ safe_get(q, "Epicenter", "Location")
118
+ )
119
+ url = safe_get(q, "reportURL") or safe_get(q, "ReportURL")
120
+
121
+ rows.append({
122
+ "OriginTime": origin,
123
+ "Lat": _to_float(lat),
124
+ "Lon": _to_float(lon),
125
+ "Depth_km": _to_float(depth),
126
+ "Magnitude": _to_float(mag),
127
+ "Location": loc,
128
+ "ReportURL": url,
129
+ })
130
+
131
+ df = pd.DataFrame(rows)
132
+
133
+ # 轉時間、排序(預設:OriginTime 由新到舊)
134
+ if not df.empty:
135
+ df["OriginTime"] = pd.to_datetime(df["OriginTime"], errors="coerce")
136
+ df = df.sort_values("OriginTime", ascending=False, na_position="last").reset_index(drop=True)
137
+
138
  return df
139
 
140
+ def _to_float(x):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  try:
142
+ if x is None or x == "":
143
+ return None
144
+ return float(str(x).strip())
145
  except Exception:
146
+ return None
147
+
148
+
149
+ # -----------------------------
150
+ # 視覺化:趨勢圖、台灣地圖
151
+ # -----------------------------
152
+ TAIWAN_BBOX = (119, 123, 21, 26) # lon_min, lon_max, lat_min, lat_max
153
+
154
+ def plot_trend(df: pd.DataFrame) -> bytes | None:
155
+ """時間-規模散點圖,輸出 PNG bytes"""
156
+ if df.empty:
157
+ return None
158
+ fig, ax = plt.subplots(figsize=(6, 3.8))
159
+ ax.scatter(df["OriginTime"], df["Magnitude"])
160
+ ax.set_xlabel("Origin Time (Taipei)")
161
+ ax.set_ylabel("Magnitude")
162
+ ax.grid(True, linestyle="--", alpha=0.4)
163
+ fig.autofmt_xdate()
164
+
165
+ buf = io.BytesIO()
166
+ fig.savefig(buf, format="png", dpi=160, bbox_inches="tight")
167
+ plt.close(fig)
168
+ buf.seek(0)
169
+ return buf.getvalue()
170
+
171
+ def plot_taiwan_map(df: pd.DataFrame) -> bytes | None:
172
+ """
173
+ 基礎台灣範圍框圖(非海岸線),用散點展示震央;
174
+ 以規模對應 marker 大小,附簡易圖例。
175
+ """
176
+ if df.empty:
177
+ return None
178
+
179
+ lon_min, lon_max, lat_min, lat_max = TAIWAN_BBOX
180
+ fig, ax = plt.subplots(figsize=(6, 6))
181
+
182
+ # 邊框
183
+ ax.set_xlim(lon_min, lon_max)
184
+ ax.set_ylim(lat_min, lat_max)
185
+ ax.set_xlabel("Longitude (°E)")
186
+ ax.set_ylabel("Latitude (°N)")
187
+ ax.set_title("Epicenters in Taiwan Region (119–123E, 21–26N)")
188
+
189
+ # 散點:大小反映規模
190
+ mags = df["Magnitude"].fillna(0)
191
+ sizes = (mags.clip(lower=0) + 2.0) ** 3 # 調整一下讓差異更明顯
192
+ ax.scatter(df["Lon"], df["Lat"], s=sizes, alpha=0.6, edgecolor="black")
193
+
194
+ # 簡單圖例:M3/4/5/6 對應大小
195
+ for m in [3, 4, 5, 6]:
196
+ ax.scatter([], [], s=((m + 2.0) ** 3), alpha=0.6, edgecolor="black", label=f"M {m}")
197
+ ax.legend(title="Magnitude", loc="upper right", frameon=True)
198
+
199
+ ax.grid(True, linestyle="--", alpha=0.3)
200
+
201
+ buf = io.BytesIO()
202
+ fig.savefig(buf, format="png", dpi=160, bbox_inches="tight")
203
+ plt.close(fig)
204
+ buf.seek(0)
205
+ return buf.getvalue()
206
+
207
+
208
+ # -----------------------------
209
+ # 主流程:查詢 + 輸出
210
+ # -----------------------------
211
+ def query_and_render(time_from: str, time_to: str, sort_order: str):
212
+ """
213
+ 1) 取資料 2) 轉成 DataFrame → 3) 依排序輸出 → 4) 回傳表格、圖、CSV
214
+ Gradio 會接收回傳值更新元件。
215
+ """
216
+ try:
217
+ raw = fetch_reports(time_from, time_to)
218
+ except Exception as e:
219
+ return gr.update(value=f"查詢錯誤:{e}"), None, None, None
220
+
221
+ try:
222
+ df = parse_ea0015(raw)
223
+ if df.empty:
224
+ return "(查無資料)", None, None, None
225
+
226
+ # 排序(本地)
227
+ if sort_order == "OriginTime (舊→新)":
228
+ df = df.sort_values("OriginTime", ascending=True, na_position="last").reset_index(drop=True)
229
+ else:
230
+ df = df.sort_values("OriginTime", ascending=False, na_position="last").reset_index(drop=True)
231
+
232
+ # 表格(轉為 markdown 簡表,手機閱讀較清楚)
233
+ md = df_to_markdown(df)
234
+
235
+ #
236
+ trend_png = plot_trend(df)
237
+ map_png = plot_taiwan_map(df)
238
+
239
+ # 下載 CSV(以 bytes 形式回傳給 DownloadButton)
240
+ csv_bytes = df.to_csv(index=False).encode("utf-8-sig")
241
+
242
+ return md, trend_png, map_png, csv_bytes
243
+ except Exception as e:
244
+ return gr.update(value=f"解析錯誤:{e}"), None, None, None
245
+
246
+
247
+ def df_to_markdown(df: pd.DataFrame, top_n: int = 100) -> str:
248
+ """
249
+ 將前 top_n 筆轉為 markdown 表(欄位:OriginTime, Magnitude, Depth_km, Lat, Lon, Location, ReportURL)
250
+ """
251
+ if df.empty:
252
+ return "(查無資料)"
253
+
254
+ show_cols = ["OriginTime", "Magnitude", "Depth_km", "Lat", "Lon", "Location", "ReportURL"]
255
+ exist_cols = [c for c in show_cols if c in df.columns]
256
+ slim = df[exist_cols].head(top_n).copy()
257
+
258
+ # 時間顯示(台北時區)
259
+ if "OriginTime" in slim.columns:
260
+ slim["OriginTime"] = slim["OriginTime"].dt.tz_convert(TAIPEI_TZ).dt.strftime("%Y-%m-%d %H:%M:%S %Z")
261
+
262
+ return slim.to_markdown(index=False)
263
+
264
+
265
+ # -----------------------------
266
+ # Gradio 介面
267
+ # -----------------------------
268
+ default_from, default_to = set_time_range(days=3)
269
+
270
+ with gr.Blocks(fill_height=True, analytics_enabled=False) as demo:
271
+ gr.Markdown(
272
+ """
273
+ # CWA 顯著有感地震報告 (E-A0015-001)
274
+ 此 Space **只使用環境變數 `CWA_API_KEY` 作為授權**。
275
+ 預設查詢 **最近 3 天(台北時間)**。手機版為單欄顯示。
276
+ """
277
+ )
278
+
279
+ # 單欄(手機友好)
280
+ with gr.Column():
281
+ with gr.Row():
282
+ time_from = gr.Textbox(label="timeFrom yyyy-MM-ddThh:mm:ss", value=default_from, scale=1)
283
+ with gr.Row():
284
+ time_to = gr.Textbox(label="timeTo yyyy-MM-ddThh:mm:ss", value=default_to, scale=1)
285
+
286
+ # 快速時間範圍(已移除「最近6小時」)
287
+ with gr.Row():
288
+ btn_12h = gr.Button("最近 12 小時")
289
+ btn_24h = gr.Button("最近 24 小時")
290
+ btn_3d = gr.Button("最近 3 ")
291
+ btn_5d = gr.Button("最近 5 天")
292
+
293
+ # 排序(在地端)
294
+ sort_dd = gr.Dropdown(
295
+ choices=["OriginTime (新→舊)", "OriginTime (舊→新)"],
296
+ value="OriginTime (新→舊)",
297
+ label="排序(本地)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  )
 
299
 
300
+ run_btn = gr.Button("查詢", variant="primary")
301
+
302
+ # 輸出
303
+ table_out = gr.Markdown("(尚未查詢)")
304
+ trend_out = gr.Image(label="趨勢圖(時間-規模)", type="numpy")
305
+ map_out = gr.Image(label="台灣範圍圖(119–123E, 21–26N)", type="numpy")
306
+ dl_btn = gr.DownloadButton(label="下載 CSV", file_name="CWA_E-A0015-001.csv")
307
 
308
+ # 綁定:快速鍵 更新時間欄位
309
+ btn_12h.click(lambda: set_time_range(hours=12), outputs=[time_from, time_to])
310
+ btn_24h.click(lambda: set_time_range(hours=24), outputs=[time_from, time_to])
311
+ btn_3d.click(lambda: set_time_range(days=3), outputs=[time_from, time_to])
312
+ btn_5d.click(lambda: set_time_range(days=5), outputs=[time_from, time_to])
313
 
314
+ # 綁定:查詢 表格 / 圖 / CSV
315
+ def _on_query(tfrom, tto, sort_sel):
316
+ md, trend_png, map_png, csv_bytes = query_and_render(tfrom, tto, sort_sel)
317
+ # DownloadButton 需要返回 bytes-like 物件
318
+ return md, trend_png, map_png, csv_bytes
319
 
320
+ run_btn.click(
321
+ _on_query,
322
+ inputs=[time_from, time_to, sort_dd],
323
+ outputs=[table_out, trend_out, map_out, dl_btn]
324
+ )
325
 
326
+ # 注意:Hugging Face Spaces 會自動呼叫 demo.launch()
327
  if __name__ == "__main__":
328
+ demo.launch()