cwadayi commited on
Commit
ff09573
·
verified ·
1 Parent(s): 0d085eb

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +86 -67
app.py CHANGED
@@ -1,4 +1,6 @@
 
1
  import json
 
2
  import tempfile
3
  from datetime import datetime, timedelta
4
  from dateutil import tz
@@ -18,20 +20,55 @@ from grafanalib.core import (
18
 
19
  TAIPEI = tz.gettz("Asia/Taipei")
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  # -----------------------------
22
  # Demo / Data loading
23
  # -----------------------------
24
  def make_demo_dataframe() -> pd.DataFrame:
25
- """隨機示範資料:含經緯度"""
26
  t0 = datetime.now(tz=TAIPEI) - timedelta(minutes=60)
27
  times = [t0 + timedelta(minutes=i) for i in range(61)]
28
  amp = np.random.rand(len(times))
29
  cnt = np.random.randint(0, 11, size=len(times))
30
-
31
- # 台灣範圍大致經緯度
32
  lats = np.random.uniform(21.8, 25.3, size=len(times))
33
  lons = np.random.uniform(120.0, 122.0, size=len(times))
34
-
35
  df = pd.DataFrame({
36
  "time": times,
37
  "amplitude": amp,
@@ -39,72 +76,61 @@ def make_demo_dataframe() -> pd.DataFrame:
39
  "lat": lats,
40
  "lon": lons
41
  })
42
- df["pid"] = np.arange(len(df)) # 點位 ID
43
  return df
44
 
45
 
46
- def load_csv(file: gr.File | None) -> pd.DataFrame:
47
- """ CSV(需含時間欄),回傳整理好的 DataFrame"""
48
- df = pd.read_csv(file.name)
49
  time_col = next((c for c in ["time", "timestamp", "datetime", "date"] if c in df.columns), None)
50
  if time_col is None:
51
- raise ValueError("CSV 檔需包含時間欄位")
52
  df[time_col] = pd.to_datetime(df[time_col])
53
  df = df.rename(columns={time_col: "time"})
54
  if getattr(df["time"].dt, "tz", None) is None:
55
  df["time"] = df["time"].dt.tz_localize(TAIPEI)
56
  else:
57
  df["time"] = df["time"].dt.tz_convert(TAIPEI)
 
58
 
59
- # 若無 lat/lon,給預設隨機(避免地圖空白)
 
 
 
 
 
60
  if "lat" not in df.columns or "lon" not in df.columns:
61
  n = len(df)
62
  df["lat"] = np.random.uniform(21.8, 25.3, size=n)
63
  df["lon"] = np.random.uniform(120.0, 122.0, size=n)
64
-
65
  if "pid" not in df.columns:
66
  df["pid"] = np.arange(len(df))
 
67
 
68
- return df.sort_values("time").reset_index(drop=True)
69
-
70
-
71
- def load_drive_csv(sheet_url: str) -> pd.DataFrame:
72
- """從 Google Drive 試算表讀資料"""
73
- if not sheet_url:
74
- raise ValueError("請提供 Google Sheets 連結")
75
- if "export?format=csv" not in sheet_url:
76
- sheet_url = sheet_url.split("/edit")[0] + "/export?format=csv"
77
-
78
- df = pd.read_csv(sheet_url)
79
- time_col = next((c for c in ["time", "timestamp", "datetime", "date"] if c in df.columns), None)
80
- if time_col is None:
81
- raise ValueError("Google Drive CSV 檔需包含時間欄位")
82
- df[time_col] = pd.to_datetime(df[time_col])
83
- df = df.rename(columns={time_col: "time"})
84
- if getattr(df["time"].dt, "tz", None) is None:
85
- df["time"] = df["time"].dt.tz_localize(TAIPEI)
86
- else:
87
- df["time"] = df["time"].dt.tz_convert(TAIPEI)
88
 
89
- # 若無 lat/lon,給預設隨機(避免地圖空白)
 
 
 
 
90
  if "lat" not in df.columns or "lon" not in df.columns:
91
  n = len(df)
92
  df["lat"] = np.random.uniform(21.8, 25.3, size=n)
93
  df["lon"] = np.random.uniform(120.0, 122.0, size=n)
94
-
95
  if "pid" not in df.columns:
96
  df["pid"] = np.arange(len(df))
97
-
98
- return df.sort_values("time").reset_index(drop=True)
99
 
100
 
101
  def load_data(source: str, file: gr.File | None = None, sheet_url: str = "") -> pd.DataFrame:
102
- """依來源載入資料"""
103
  if source == "drive":
 
 
104
  return load_drive_csv(sheet_url)
105
  elif source == "upload":
106
  if file is None:
107
- raise ValueError("請上傳檔案")
108
  return load_csv(file)
109
  else:
110
  return make_demo_dataframe()
@@ -232,7 +258,6 @@ def render_rolling(df, col, window=5):
232
  # Folium helpers (map + legend)
233
  # -----------------------------
234
  def _to_hex_color(value: float, cmap=cm.viridis) -> str:
235
- """把 0~1 數值轉成 hex 顏色"""
236
  rgba = cmap(value)
237
  return "#{:02x}{:02x}{:02x}".format(int(rgba[0]*255), int(rgba[1]*255), int(rgba[2]*255))
238
 
@@ -244,23 +269,19 @@ def render_map_folium(
244
  cmap_name: str = "viridis",
245
  tiles: str = "OpenStreetMap",
246
  ) -> str:
247
- """用 folium 建立互動地圖,帶顏色圖例與 #ID 提示"""
248
  center_lat, center_lon = df["lat"].mean(), df["lon"].mean()
249
  m = folium.Map(location=[center_lat, center_lon], zoom_start=7, tiles=tiles)
250
 
251
  vmin, vmax = df[value_col].min(), df[value_col].max()
252
  cmap = getattr(cm, cmap_name)
253
 
254
- # 建立 folium colormap (legend)
255
  colormap = bcm.LinearColormap(
256
  [_to_hex_color(i, cmap) for i in np.linspace(0, 1, 256)],
257
- vmin=vmin,
258
- vmax=vmax,
259
  )
260
  colormap.caption = f"{value_col} (color scale)"
261
  colormap.add_to(m)
262
 
263
- # 加點資料(彈窗含 #ID,方便對應右側選擇器)
264
  for _, row in df.iterrows():
265
  norm_val = (row[value_col] - vmin) / (vmax - vmin + 1e-9)
266
  popup_html = (
@@ -288,7 +309,6 @@ def render_map_folium(
288
  # Detail helpers
289
  # -----------------------------
290
  def make_point_choices(df: pd.DataFrame) -> list[str]:
291
- """建立下拉清單文字,例如: '#23 | 12:34:56 | amp=0.42'"""
292
  labels = []
293
  for _, r in df.iterrows():
294
  t = pd.to_datetime(r["time"]).strftime("%H:%M:%S")
@@ -297,7 +317,6 @@ def make_point_choices(df: pd.DataFrame) -> list[str]:
297
 
298
 
299
  def pick_detail(df: pd.DataFrame, choice: str) -> pd.DataFrame:
300
- """依下拉選擇的 '#ID | ...' 找出對應列"""
301
  if not choice:
302
  return pd.DataFrame()
303
  try:
@@ -332,35 +351,29 @@ def pipeline(source, file, sheet_url, series_choice, dual_axis, rolling_window,
332
  map_html = render_map_folium(df, value_col=chosen[0], size_col="count",
333
  cmap_name=cmap_choice, tiles=tiles_choice)
334
 
335
- # 準備點位下拉與預設詳細資料
336
  point_choices = make_point_choices(df)
337
  default_choice = point_choices[0] if point_choices else ""
338
  detail_df = pick_detail(df, default_choice)
339
 
340
- # 也輸出 demo.csv
341
  demo_df = make_demo_dataframe()
342
  with tempfile.NamedTemporaryFile(delete=False, suffix=".csv", mode="w", encoding="utf-8") as f:
343
  demo_df.to_csv(f, index=False)
344
  demo_csv_path = f.name
345
 
346
  return (
347
- fig1, fig2, fig3, map_html, # 圖/地圖/JSON
348
- dash_json_str, json_path, df_with_roll, # JSON 內容/下載、資料預覽
349
- demo_csv_path, # demo.csv 下載
350
- gr.Dropdown(choices=point_choices, value=default_choice), # 點位下拉
351
- detail_df, # 詳細資料
352
  )
353
 
354
 
355
  def regenerate_demo(series_choice, dual_axis, rolling_window, cmap_choice, tiles_choice, current_choice):
356
- # 重新隨機 demo,並嘗試保留相同 index 的選擇(若存在)
357
- result = pipeline("demo", None, "", series_choice, dual_axis, rolling_window, cmap_choice, tiles_choice)
358
- # result 的倒數二個回傳是 Dropdown 與 detail_df,我們已在 pipeline 設好預設
359
- return result
360
 
361
 
362
  def update_detail(df: pd.DataFrame, choice: str):
363
- """使用目前資料與下拉選擇更新詳細資料表"""
364
  return pick_detail(df, choice)
365
 
366
 
@@ -368,11 +381,14 @@ def update_detail(df: pd.DataFrame, choice: str):
368
  # UI
369
  # -----------------------------
370
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
371
- gr.Markdown("## Grafana-like Demo + Folium Map + Colormap + Tiles + Legend + 點位詳情")
372
 
373
  source_radio = gr.Radio(["upload", "drive", "demo"], label="資料來源", value="demo")
374
- file_in = gr.File(label="上傳 CSV(僅在選擇 upload 時使用)", file_types=[".csv"])
375
- sheet_url_box = gr.Textbox(label="Google Sheets 連結", value="")
 
 
 
376
 
377
  series_multiselect = gr.CheckboxGroup(label="數值欄位", choices=[])
378
  dual_axis_chk = gr.Checkbox(label="第二面板啟用雙軸", value=False)
@@ -399,23 +415,28 @@ with gr.Blocks(theme=gr.themes.Soft()) as demo:
399
  demo_csv_file = gr.File(label="下載示範資料 demo.csv")
400
  df_view = gr.Dataframe(label="資料預覽(含 rolling)", wrap=True)
401
 
402
- gr.Markdown("### 🔎 點位詳情(在地圖彈窗看到的 **#ID** 可對應到這裡)")
403
  point_selector = gr.Dropdown(label="選擇點位(#ID | 時間 | 值)", choices=[], value=None)
404
  detail_view = gr.Dataframe(label="選取點詳細資料", wrap=True)
405
 
 
 
 
 
 
 
406
  def probe_columns(source, file, sheet_url):
407
  df = load_data(source, file, sheet_url)
408
  numeric_cols = [c for c in df.columns if c not in ["time", "lat", "lon", "pid"] and pd.api.types.is_numeric_dtype(df[c])]
409
- # 初步資料預覽也回給前端
410
  return gr.CheckboxGroup(choices=numeric_cols, value=numeric_cols[:2]), df
411
 
412
  source_radio.change(probe_columns, inputs=[source_radio, file_in, sheet_url_box], outputs=[series_multiselect, df_view])
413
  file_in.change(probe_columns, inputs=[source_radio, file_in, sheet_url_box], outputs=[series_multiselect, df_view])
414
  sheet_url_box.change(probe_columns, inputs=[source_radio, file_in, sheet_url_box], outputs=[series_multiselect, df_view])
 
415
 
416
- # 初始載入 demo
417
  demo.load(
418
- lambda: pipeline("demo", None, "", [], False, "5", "viridis", "OpenStreetMap"),
419
  inputs=None,
420
  outputs=[
421
  plot1, plot2, plot3, map_out,
@@ -425,7 +446,6 @@ with gr.Blocks(theme=gr.themes.Soft()) as demo:
425
  ]
426
  )
427
 
428
- # 產生與重新產生
429
  run_btn.click(
430
  pipeline,
431
  inputs=[source_radio, file_in, sheet_url_box, series_multiselect, dual_axis_chk, rolling_dd, cmap_dd, tiles_dd],
@@ -448,10 +468,9 @@ with gr.Blocks(theme=gr.themes.Soft()) as demo:
448
  ]
449
  )
450
 
451
- # 當使用者從下拉選擇點位 → 更新詳細資料
452
  point_selector.change(
453
  update_detail,
454
- inputs=[df_view, point_selector], # df_view 目前已包含最新資料
455
  outputs=[detail_view]
456
  )
457
 
 
1
+ # app.py
2
  import json
3
+ import re
4
  import tempfile
5
  from datetime import datetime, timedelta
6
  from dateutil import tz
 
20
 
21
  TAIPEI = tz.gettz("Asia/Taipei")
22
 
23
+ # -----------------------------
24
+ # Google Drive 連結處理
25
+ # -----------------------------
26
+ DRIVE_PRESETS = [
27
+ "https://drive.google.com/file/d/15yZ4QicICKZCnX6vjcD9JNXjnJmMFJD4/view?usp=drivesdk",
28
+ "https://drive.google.com/file/d/1dqazYh_YzNNMbkUpgLRKSE9Y3ioPhtFu/view?usp=drivesdk",
29
+ "https://drive.google.com/file/d/1A23f4q8DXHpoRIN5UQsDd6eM8jJ_Ruf8/view?usp=drivesdk",
30
+ ]
31
+
32
+ def normalize_drive_url(url: str) -> str:
33
+ """
34
+ 接受 Google Drive / Google Sheets 各式分享連結,回傳可直接給 pandas 讀取 CSV 的 URL。
35
+ 規則:
36
+ - Sheets: .../spreadsheets/d/<ID>/edit → .../export?format=csv
37
+ - Drive File: .../file/d/<ID>/view → https://drive.google.com/uc?export=download&id=<ID>
38
+ 其他 http(s) 連結原樣返回。
39
+ """
40
+ if not isinstance(url, str) or not url.strip():
41
+ raise ValueError("請提供有效的 Google Drive / Google Sheets 連結")
42
+
43
+ url = url.strip()
44
+
45
+ # Sheets
46
+ m = re.search(r"https://docs\.google\.com/spreadsheets/d/([a-zA-Z0-9-_]+)", url)
47
+ if m:
48
+ sheet_id = m.group(1)
49
+ return f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv"
50
+
51
+ # Drive file
52
+ m = re.search(r"https://drive\.google\.com/file/d/([a-zA-Z0-9-_]+)/", url)
53
+ if m:
54
+ file_id = m.group(1)
55
+ return f"https://drive.google.com/uc?export=download&id={file_id}"
56
+
57
+ # 其他:若已經是 export?format=csv 或一般 http(s) 連結,原樣回傳
58
+ return url
59
+
60
+
61
  # -----------------------------
62
  # Demo / Data loading
63
  # -----------------------------
64
  def make_demo_dataframe() -> pd.DataFrame:
65
+ """隨機示範資料:含經緯度 + pid"""
66
  t0 = datetime.now(tz=TAIPEI) - timedelta(minutes=60)
67
  times = [t0 + timedelta(minutes=i) for i in range(61)]
68
  amp = np.random.rand(len(times))
69
  cnt = np.random.randint(0, 11, size=len(times))
 
 
70
  lats = np.random.uniform(21.8, 25.3, size=len(times))
71
  lons = np.random.uniform(120.0, 122.0, size=len(times))
 
72
  df = pd.DataFrame({
73
  "time": times,
74
  "amplitude": amp,
 
76
  "lat": lats,
77
  "lon": lons
78
  })
79
+ df["pid"] = np.arange(len(df))
80
  return df
81
 
82
 
83
+ def _finalize_time(df: pd.DataFrame) -> pd.DataFrame:
84
+ """確保 time 欄位有時區、排序"""
 
85
  time_col = next((c for c in ["time", "timestamp", "datetime", "date"] if c in df.columns), None)
86
  if time_col is None:
87
+ raise ValueError("資料需包含時間欄位(time/timestamp/datetime/date 其一)")
88
  df[time_col] = pd.to_datetime(df[time_col])
89
  df = df.rename(columns={time_col: "time"})
90
  if getattr(df["time"].dt, "tz", None) is None:
91
  df["time"] = df["time"].dt.tz_localize(TAIPEI)
92
  else:
93
  df["time"] = df["time"].dt.tz_convert(TAIPEI)
94
+ return df.sort_values("time").reset_index(drop=True)
95
 
96
+
97
+ def load_csv(file: gr.File | None) -> pd.DataFrame:
98
+ """讀上傳 CSV"""
99
+ df = pd.read_csv(file.name)
100
+ df = _finalize_time(df)
101
+ # 若無 lat/lon,補隨機(避免地圖空白)
102
  if "lat" not in df.columns or "lon" not in df.columns:
103
  n = len(df)
104
  df["lat"] = np.random.uniform(21.8, 25.3, size=n)
105
  df["lon"] = np.random.uniform(120.0, 122.0, size=n)
 
106
  if "pid" not in df.columns:
107
  df["pid"] = np.arange(len(df))
108
+ return df
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
+ def load_drive_csv(sheet_or_file_url: str) -> pd.DataFrame:
112
+ """從 Google Sheets 或 Google Drive File 讀 CSV"""
113
+ url = normalize_drive_url(sheet_or_file_url)
114
+ df = pd.read_csv(url)
115
+ df = _finalize_time(df)
116
  if "lat" not in df.columns or "lon" not in df.columns:
117
  n = len(df)
118
  df["lat"] = np.random.uniform(21.8, 25.3, size=n)
119
  df["lon"] = np.random.uniform(120.0, 122.0, size=n)
 
120
  if "pid" not in df.columns:
121
  df["pid"] = np.arange(len(df))
122
+ return df
 
123
 
124
 
125
  def load_data(source: str, file: gr.File | None = None, sheet_url: str = "") -> pd.DataFrame:
126
+ """依來源載入資料:demo / upload / drive"""
127
  if source == "drive":
128
+ if not sheet_url:
129
+ raise ValueError("請選擇或輸入 Google 連結")
130
  return load_drive_csv(sheet_url)
131
  elif source == "upload":
132
  if file is None:
133
+ raise ValueError("請上傳 CSV 檔")
134
  return load_csv(file)
135
  else:
136
  return make_demo_dataframe()
 
258
  # Folium helpers (map + legend)
259
  # -----------------------------
260
  def _to_hex_color(value: float, cmap=cm.viridis) -> str:
 
261
  rgba = cmap(value)
262
  return "#{:02x}{:02x}{:02x}".format(int(rgba[0]*255), int(rgba[1]*255), int(rgba[2]*255))
263
 
 
269
  cmap_name: str = "viridis",
270
  tiles: str = "OpenStreetMap",
271
  ) -> str:
 
272
  center_lat, center_lon = df["lat"].mean(), df["lon"].mean()
273
  m = folium.Map(location=[center_lat, center_lon], zoom_start=7, tiles=tiles)
274
 
275
  vmin, vmax = df[value_col].min(), df[value_col].max()
276
  cmap = getattr(cm, cmap_name)
277
 
 
278
  colormap = bcm.LinearColormap(
279
  [_to_hex_color(i, cmap) for i in np.linspace(0, 1, 256)],
280
+ vmin=vmin, vmax=vmax
 
281
  )
282
  colormap.caption = f"{value_col} (color scale)"
283
  colormap.add_to(m)
284
 
 
285
  for _, row in df.iterrows():
286
  norm_val = (row[value_col] - vmin) / (vmax - vmin + 1e-9)
287
  popup_html = (
 
309
  # Detail helpers
310
  # -----------------------------
311
  def make_point_choices(df: pd.DataFrame) -> list[str]:
 
312
  labels = []
313
  for _, r in df.iterrows():
314
  t = pd.to_datetime(r["time"]).strftime("%H:%M:%S")
 
317
 
318
 
319
  def pick_detail(df: pd.DataFrame, choice: str) -> pd.DataFrame:
 
320
  if not choice:
321
  return pd.DataFrame()
322
  try:
 
351
  map_html = render_map_folium(df, value_col=chosen[0], size_col="count",
352
  cmap_name=cmap_choice, tiles=tiles_choice)
353
 
 
354
  point_choices = make_point_choices(df)
355
  default_choice = point_choices[0] if point_choices else ""
356
  detail_df = pick_detail(df, default_choice)
357
 
 
358
  demo_df = make_demo_dataframe()
359
  with tempfile.NamedTemporaryFile(delete=False, suffix=".csv", mode="w", encoding="utf-8") as f:
360
  demo_df.to_csv(f, index=False)
361
  demo_csv_path = f.name
362
 
363
  return (
364
+ fig1, fig2, fig3, map_html,
365
+ dash_json_str, json_path, df_with_roll,
366
+ demo_csv_path,
367
+ gr.Dropdown(choices=point_choices, value=default_choice),
368
+ detail_df,
369
  )
370
 
371
 
372
  def regenerate_demo(series_choice, dual_axis, rolling_window, cmap_choice, tiles_choice, current_choice):
373
+ return pipeline("demo", None, "", series_choice, dual_axis, rolling_window, cmap_choice, tiles_choice)
 
 
 
374
 
375
 
376
  def update_detail(df: pd.DataFrame, choice: str):
 
377
  return pick_detail(df, choice)
378
 
379
 
 
381
  # UI
382
  # -----------------------------
383
  with gr.Blocks(theme=gr.themes.Soft()) as demo:
384
+ gr.Markdown("## Grafana-like Demo + Folium Map(支援 Google Drive / Sheets 連結與預設來源)")
385
 
386
  source_radio = gr.Radio(["upload", "drive", "demo"], label="資料來源", value="demo")
387
+ file_in = gr.File(label="上傳 CSV(選 upload 時使用)", file_types=[".csv"])
388
+
389
+ with gr.Row():
390
+ preset_dd = gr.Dropdown(label="Google 預設來源(你提供的 3 個連結)", choices=DRIVE_PRESETS, value=DRIVE_PRESETS[0])
391
+ sheet_url_box = gr.Textbox(label="Google 連結(可自貼)", value=DRIVE_PRESETS[0])
392
 
393
  series_multiselect = gr.CheckboxGroup(label="數值欄位", choices=[])
394
  dual_axis_chk = gr.Checkbox(label="第二面板啟用雙軸", value=False)
 
415
  demo_csv_file = gr.File(label="下載示範資料 demo.csv")
416
  df_view = gr.Dataframe(label="資料預覽(含 rolling)", wrap=True)
417
 
418
+ gr.Markdown("### 🔎 點位詳情(對應地圖彈窗中的 #ID")
419
  point_selector = gr.Dropdown(label="選擇點位(#ID | 時間 | 值)", choices=[], value=None)
420
  detail_view = gr.Dataframe(label="選取點詳細資料", wrap=True)
421
 
422
+ # 預設下拉 → 同步到文字框,方便改
423
+ def use_preset(preset_url):
424
+ return normalize_drive_url(preset_url) if preset_url else ""
425
+
426
+ preset_dd.change(use_preset, inputs=[preset_dd], outputs=[sheet_url_box])
427
+
428
  def probe_columns(source, file, sheet_url):
429
  df = load_data(source, file, sheet_url)
430
  numeric_cols = [c for c in df.columns if c not in ["time", "lat", "lon", "pid"] and pd.api.types.is_numeric_dtype(df[c])]
 
431
  return gr.CheckboxGroup(choices=numeric_cols, value=numeric_cols[:2]), df
432
 
433
  source_radio.change(probe_columns, inputs=[source_radio, file_in, sheet_url_box], outputs=[series_multiselect, df_view])
434
  file_in.change(probe_columns, inputs=[source_radio, file_in, sheet_url_box], outputs=[series_multiselect, df_view])
435
  sheet_url_box.change(probe_columns, inputs=[source_radio, file_in, sheet_url_box], outputs=[series_multiselect, df_view])
436
+ preset_dd.change(probe_columns, inputs=[source_radio, file_in, sheet_url_box], outputs=[series_multiselect, df_view])
437
 
 
438
  demo.load(
439
+ lambda: pipeline("drive", None, DRIVE_PRESETS[0], [], False, "5", "viridis", "OpenStreetMap"),
440
  inputs=None,
441
  outputs=[
442
  plot1, plot2, plot3, map_out,
 
446
  ]
447
  )
448
 
 
449
  run_btn.click(
450
  pipeline,
451
  inputs=[source_radio, file_in, sheet_url_box, series_multiselect, dual_axis_chk, rolling_dd, cmap_dd, tiles_dd],
 
468
  ]
469
  )
470
 
 
471
  point_selector.change(
472
  update_detail,
473
+ inputs=[df_view, point_selector],
474
  outputs=[detail_view]
475
  )
476