File size: 12,637 Bytes
d470cce
38b76c5
b3cbfc1
 
38b76c5
8f56933
d470cce
8f56933
38b76c5
d470cce
8f56933
 
769f92b
 
 
 
8f56933
 
769f92b
8f56933
 
 
b3cbfc1
 
8f56933
38b76c5
8f56933
38b76c5
b3cbfc1
38b76c5
 
 
 
 
b3cbfc1
8f56933
 
 
 
 
 
 
b3cbfc1
 
 
8f56933
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b3cbfc1
769f92b
 
 
8f56933
 
38b76c5
769f92b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f56933
769f92b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f56933
769f92b
8f56933
 
 
 
38b76c5
 
 
 
 
769f92b
 
38b76c5
8f56933
 
 
 
 
 
 
 
 
38b76c5
8f56933
 
d470cce
 
38b76c5
769f92b
8f56933
d470cce
 
8f56933
38b76c5
8f56933
b3cbfc1
8f56933
769f92b
 
8f56933
 
 
 
769f92b
8f56933
 
 
 
 
 
 
 
 
 
 
b3cbfc1
769f92b
 
d470cce
769f92b
8f56933
 
 
 
d470cce
8f56933
316b3ca
8f56933
38b76c5
d470cce
8f56933
 
38b76c5
8f56933
769f92b
8f56933
 
 
 
 
769f92b
8f56933
769f92b
b3cbfc1
 
8f56933
 
b3cbfc1
8f56933
 
38b76c5
769f92b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8f56933
 
b3cbfc1
 
8f56933
769f92b
8f56933
 
 
 
 
 
 
 
 
 
 
 
 
769f92b
b3cbfc1
8f56933
 
 
38b76c5
8f56933
 
 
769f92b
8f56933
769f92b
8f56933
 
769f92b
8f56933
 
 
 
 
 
769f92b
 
 
 
 
8f56933
769f92b
38b76c5
8f56933
769f92b
d470cce
 
38b76c5
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
import requests
import gradio as gr
import pandas as pd
import plotly.express as px
from datetime import datetime, timedelta
import time

# --- 全局設定與 CSS 樣式 ---
USGS_API_BASE_URL = "https://earthquake.usgs.gov/fdsnws/event/1/query"

# 自訂 CSS 來美化介面
CUSTOM_CSS = """
/* ... (CSS 樣式與之前相同,為節省空間此處省略) ... */
#main-title { text-align: center; font-family: 'Arial Black', sans-serif; color: #2c3e50; font-size: 2.5em; padding-top: 10px; }
#control-panel { border-radius: 15px; background-color: #f8f9fa; padding: 20px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
.stats-card { border-radius: 10px; padding: 15px; text-align: center; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
.stats-card h3 { font-size: 1.2em; color: #34495e; }
.stats-card p { font-size: 1.5em; font-weight: bold; color: #e74c3c; }
#footer { text-align: center; color: #7f8c8d; font-size: 0.9em; padding: 15px; }
"""

# --- 核心資料處理函式 ---

def process_earthquake_data(data):
    """將 API 的 JSON 資料轉換為 DataFrame, Markdown 和統計摘要"""
    if not data or 'features' not in data or not data['features']:
        return None, "在指定的條件下,找不到任何地震資料。", None

    earthquake_list = []
    for feature in data['features']:
        properties = feature['properties']
        geometry = feature['geometry']
        event_time_utc = datetime.fromtimestamp(properties['time'] / 1000)
        
        earthquake_list.append({
            'latitude': geometry['coordinates'][1],
            'longitude': geometry['coordinates'][0],
            'magnitude': properties['mag'],
            'depth': geometry['coordinates'][2],
            'place': properties['place'],
            'time_utc': event_time_utc,
            'url': properties['url']
        })

    df = pd.DataFrame(earthquake_list)
    if df.empty:
        return None, "在指定的條件下,找不到任何地震資料。", None

    strongest_quake = df.loc[df['magnitude'].idxmax()]
    deepest_quake = df.loc[df['depth'].idxmax()]
    stats = {
        "count": len(df),
        "avg_mag": f"{df['magnitude'].mean():.2f}",
        "strongest_mag": f"{strongest_quake['magnitude']:.2f}",
        "strongest_loc": strongest_quake['place'],
        "deepest_depth": f"{deepest_quake['depth']:.2f} km",
        "deepest_loc": deepest_quake['place']
    }
    
    df_display = df.copy()
    df_display['time_utc'] = df_display['time_utc'].dt.strftime('%Y-%m-%d %H:%M:%S')
    md_table = df_display[['time_utc', 'place', 'magnitude', 'depth']].to_markdown(index=False)

    return df, md_table, stats

# --- 🎨 全新升級的地圖函式 ---
def create_earthquake_map(df, map_style):
    """使用 Plotly 繪製更 Fancy 的互動式地震分佈圖"""
    if df is None or df.empty:
        return None

    # 建立一個包含豐富 HTML 格式的懸停資訊欄位
    df['hover_text'] = df.apply(
        lambda row: (
            f"<b>📍 {row['place']}</b><br><br>"
            f"<b>時間:</b> {row['time_utc'].strftime('%Y-%m-%d %H:%M:%S')} UTC<br>"
            f"<b>芮氏規模:</b> {row['magnitude']:.2f}<br>"
            f"<b>深度:</b> {row['depth']:.2f} km<br>"
            f"<a href='{row['url']}' target='_blank'>查看 USGS 詳細報告</a>"
        ),
        axis=1
    )

    # 設置地圖中心點
    center_lat = df['latitude'].mean()
    center_lon = df['longitude'].mean()

    # 使用 px.scatter_mapbox 來獲得更豐富的底圖
    fig = px.scatter_mapbox(
        df,
        lat="latitude",
        lon="longitude",
        size="magnitude",           # 點的大小與規模相關
        color="magnitude",          # 點的顏色也與規模相關
        hover_name="hover_text",    # 使用我們自訂的 HTML 懸停文字
        hover_data={"hover_text": False, "latitude": False, "longitude": False, "magnitude": False}, # 隱藏預設的懸停資料
        color_continuous_scale=px.colors.sequential.YlOrRd, # 使用黃-橙-紅的火焰色階
        size_max=50,                # 最大的點的尺寸
        zoom=3,                     # 初始縮放等級
        center={"lat": center_lat, "lon": center_lon},
        mapbox_style=map_style      # 從 UI 傳入的地圖樣式!
    )

    fig.update_layout(
        title={
            'text': "<b>地震分佈視覺化儀表板</b>",
            'y':0.95,
            'x':0.5,
            'xanchor': 'center',
            'yanchor': 'top'
        },
        margin={"r": 0, "t": 40, "l": 0, "b": 0},
        legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
        mapbox=dict(
            bearing=0,
            pitch=0
        ),
        # 移除 colorbar 的標題,讓介面更乾淨
        coloraxis_colorbar=dict(
            title=""
        )
    )

    return fig

# --- Gradio 主要執行函式 ---
def query_and_visualize(
    starttime_str: str,
    endtime_str: str,
    min_magnitude: float,
    use_geo: bool,
    min_lat: float, max_lat: float,
    min_lon: float, max_lon: float,
    map_style: str # 新增地圖樣式參數
):
    """主函式,接收 UI 輸入,回傳所有 UI 更新"""
    yield {
        submit_btn: gr.update(value="⏳ 查詢中...", interactive=False),
        map_plot: None,
        stats_group: gr.update(visible=False),
        download_group: gr.update(visible=False),
        md_output: "正在從 USGS 獲取資料,請稍候..."
    }
    
    params = {
        "format": "geojson", "starttime": starttime_str, "endtime": endtime_str,
        "minmagnitude": min_magnitude, "limit": 2000, "orderby": "time"
    }

    if use_geo:
        # ... (驗證邏輯與之前相同) ...
        params.update({"minlatitude": min_lat, "maxlatitude": max_lat, "minlongitude": min_lon, "maxlongitude": max_lon})

    try:
        response = requests.get(USGS_API_BASE_URL, params=params, timeout=25)
        response.raise_for_status()
        df, md_table, stats = process_earthquake_data(response.json())
        
        if df is not None:
            # 傳入 map_style
            map_figure = create_earthquake_map(df, map_style)
            csv_path = "earthquake_data.csv"
            df.to_csv(csv_path, index=False)
            
            yield {
                # ... (其他元件更新與之前相同) ...
                submit_btn: gr.update(value="查詢完成", interactive=True),
                map_plot: map_figure,
                md_output: md_table,
                stats_group: gr.update(visible=True),
                download_group: gr.update(visible=True),
                file_download: gr.update(value=csv_path, visible=True),
                stats_count_val: str(stats['count']),
                stats_avg_mag_val: stats['avg_mag'],
                stats_strongest_val: f"{stats['strongest_mag']} @ {stats['strongest_loc']}",
                stats_deepest_val: f"{stats['deepest_depth']} @ {stats['deepest_loc']}"
            }
        else:
             # ... (錯誤處理與之前相同) ...
             yield { submit_btn: gr.update(value="開始查詢", interactive=True), md_output: "在指定的條件下,找不到任何地震資料。" }
    except Exception as e:
         # ... (錯誤處理與之前相同) ...
        yield { submit_btn: gr.update(value="查詢失敗", interactive=True), md_output: f"❌ 發生錯誤: {e}" }
    
    time.sleep(2)
    yield { submit_btn: gr.update(value="開始查詢", interactive=True) }

# --- Gradio UI 介面設計 ---
with gr.Blocks(theme=gr.themes.Soft(primary_hue="sky", secondary_hue="neutral"), css=CUSTOM_CSS) as demo:
    gr.Markdown("# 🌋 地震視覺化儀 (Quake Visualizer)", elem_id="main-title")
    
    with gr.Row():
        with gr.Column(scale=2, elem_id="control-panel"):
            gr.Markdown("### ⚙️ 控制面板")
            
            with gr.Accordion("時間與規模", open=True):
                # ... (時間與規模元件與之前相同) ...
                now = datetime.now()
                yesterday = now - timedelta(days=1)
                starttime_input = gr.Textbox(label="開始日期 (YYYY-MM-DD)", value=yesterday.strftime('%Y-%m-%d'))
                endtime_input = gr.Textbox(label="結束日期 (YYYY-MM-DD)", value=now.strftime('%Y-%m-%d'))
                magnitude_slider = gr.Slider(minimum=0, maximum=10, step=0.1, label="最小芮氏規模", value=2.5)

            with gr.Accordion("地理範圍 (可選)", open=False):
                # ... (地理範圍元件與之前相同) ...
                use_geo_checkbox = gr.Checkbox(label="啟用地理範圍篩選", value=False)
                with gr.Row():
                    min_lat_input = gr.Number(label="最小緯度", value=21.5, interactive=False)
                    max_lat_input = gr.Number(label="最大緯度", value=25.5, interactive=False)
                with gr.Row():
                    min_lon_input = gr.Number(label="最小經度", value=120.0, interactive=False)
                    max_lon_input = gr.Number(label="最大經度", value=122.5, interactive=False)
            
            # --- ✨ 新增地圖樣式選擇器 ---
            with gr.Accordion("地圖樣式", open=True):
                 map_style_dropdown = gr.Dropdown(
                    label="選擇地圖底圖",
                    choices=[
                        "open-street-map",
                        "carto-darkmatter",
                        "carto-positron",
                        "stamen-terrain",
                        "stamen-toner",
                        "stamen-watercolor"
                    ],
                    value="carto-darkmatter" # 預設使用深色模式
                )

            submit_btn = gr.Button("開始查詢", variant="primary")

        with gr.Column(scale=5):
            with gr.Tabs():
                with gr.TabItem("📊 視覺化儀表板"):
                    # ... (統計卡片與下載元件與之前相同) ...
                    with gr.Row(visible=False) as stats_group:
                        with gr.Column(elem_classes="stats-card"):
                            gr.Markdown("<h3>總計地震數</h3>")
                            stats_count_val = gr.Markdown("<p>0</p>")
                        with gr.Column(elem_classes="stats-card"):
                            gr.Markdown("<h3>平均規模</h3>")
                            stats_avg_mag_val = gr.Markdown("<p>0</p>")
                        with gr.Column(elem_classes="stats-card"):
                            gr.Markdown("<h3>最強地震</h3>")
                            stats_strongest_val = gr.Markdown("<p>-</p>")
                        with gr.Column(elem_classes="stats-card"):
                            gr.Markdown("<h3>最深地震</h3>")
                            stats_deepest_val = gr.Markdown("<p>-</p>")

                    map_plot = gr.Plot()
                    
                    with gr.Row(visible=False) as download_group:
                        file_download = gr.File(label="下載 CSV 資料", visible=True)

                with gr.TabItem("📄 詳細數據"):
                    md_output = gr.Markdown("點擊「開始查詢」後,結果將顯示於此。")
    
    # ... (頁腳與之前相同) ...
    gr.Markdown("---", elem_id="footer")
    gr.Markdown("由 Gradio 與 Plotly 強力驅動 | 資料來源:美國地制調查局 (USGS)", elem_id="footer")

    # --- 事件監聽與互動邏輯 ---
    # ... (地理範圍鎖定邏輯與之前相同) ...
    def toggle_geo_inputs(enabled):
        return {
            min_lat_input: gr.update(interactive=enabled), max_lat_input: gr.update(interactive=enabled),
            min_lon_input: gr.update(interactive=enabled), max_lon_input: gr.update(interactive=enabled)
        }
    use_geo_checkbox.change(fn=toggle_geo_inputs, inputs=use_geo_checkbox, outputs=[min_lat_input, max_lat_input, min_lon_input, max_lon_input])

    # 組合所有輸入元件
    all_inputs = [starttime_input, endtime_input, magnitude_slider, use_geo_checkbox, min_lat_input, max_lat_input, min_lon_input, max_lon_input, map_style_dropdown]
    all_outputs = [submit_btn, map_plot, md_output, stats_group, download_group, file_download, stats_count_val, stats_avg_mag_val, stats_strongest_val, stats_deepest_val]

    # 綁定主查詢函式
    submit_btn.click(fn=query_and_visualize, inputs=all_inputs, outputs=all_outputs)
    
    # 頁面載入時自動觸發一次查詢
    demo.load(fn=query_and_visualize, inputs=all_inputs, outputs=all_outputs)

if __name__ == "__main__":
    demo.launch()