Spaces:
Sleeping
Sleeping
| import html | |
| import json | |
| import re | |
| from functools import lru_cache | |
| from pathlib import Path | |
| import folium | |
| import gradio as gr | |
| from huggingface_hub import hf_hub_download, list_repo_files | |
| HERE = Path(__file__).resolve().parent | |
| FIXTURES = HERE / "fixtures" | |
| STATUS_DATASET_ID = "oceanicdayi/eew_status" | |
| EVENT_DATASET_ID = "oceanicdayi/eew_hermes_dashboard" | |
| WAVEFORM_DATASET_ID = "oceanicdayi/eew_hermes_dashboard" | |
| DEFAULT_STATUS = "status/eew_status_report.json" | |
| DEFAULT_STATUS_ALT = "eew_status_report.json" | |
| EVENT_STATUS_PREFIX = "status/" | |
| EVENT_STATUS_PATH = "status/eew_status_report.json" | |
| REP_SUMMARY_PATH = "status/rep_summary_detailed.md" | |
| WAVEFORM_PREFIX = "tsmip/waveform/" | |
| WAVEFORM_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp") | |
| MAX_WAVEFORM_IMAGES = 48 | |
| LAT_KEYS = { | |
| "lat", "latitude", "event_lat", "event_latitude", "epicenter_lat", "epicenter_latitude", | |
| "origin_lat", "origin_latitude", "hypo_lat", "hypocenter_lat", "eq_lat", "eqlat", "evlat", | |
| } | |
| LON_KEYS = { | |
| "lon", "lng", "longitude", "event_lon", "event_lng", "event_longitude", | |
| "epicenter_lon", "epicenter_lng", "epicenter_longitude", "origin_lon", "origin_lng", | |
| "hypo_lon", "hypocenter_lon", "eq_lon", "eqlon", "evlon", | |
| } | |
| MAG_KEYS = {"magnitude", "mag", "ml", "mw", "m", "mall", "mpd", "mtc", "M", "Mpd", "Mtc", "Mall"} | |
| DEPTH_KEYS = {"depth", "depth_km", "dep", "hypo_depth", "hypocenter_depth"} | |
| TIME_KEYS = {"time", "origin_time", "event_time", "timestamp", "datetime", "report_time", "trigger_time"} | |
| LOCATION_KEYS = {"location", "area", "epicenter", "place", "region", "description"} | |
| SOURCE_KEYS = {"source_file", "file", "filename", "source"} | |
| def esc(value): | |
| return html.escape("" if value is None else str(value)) | |
| def to_float(value): | |
| try: | |
| if value is None: | |
| return None | |
| text = str(value).strip().replace(",", "") | |
| match = re.search(r"-?\d+(?:\.\d+)?", text) | |
| if not match: | |
| return None | |
| return float(match.group(0)) | |
| except Exception: | |
| return None | |
| def first_value(obj, keys): | |
| if not isinstance(obj, dict): | |
| return None | |
| lower_map = {str(k).lower(): k for k in obj.keys()} | |
| for key in keys: | |
| actual = lower_map.get(str(key).lower()) | |
| if actual is not None: | |
| value = obj.get(actual) | |
| if value not in (None, ""): | |
| return value | |
| return None | |
| def status_files(): | |
| try: | |
| return list_repo_files(STATUS_DATASET_ID, repo_type="dataset") | |
| except Exception: | |
| return [] | |
| def event_status_files(): | |
| try: | |
| return list_repo_files(EVENT_DATASET_ID, repo_type="dataset") | |
| except Exception: | |
| return [] | |
| def waveform_files(): | |
| try: | |
| return list_repo_files(WAVEFORM_DATASET_ID, repo_type="dataset") | |
| except Exception: | |
| return [] | |
| def status_choices(): | |
| files = status_files() | |
| choices = [] | |
| for name in [DEFAULT_STATUS, DEFAULT_STATUS_ALT]: | |
| if name in files and name not in choices: | |
| choices.append(name) | |
| if not choices: | |
| choices.append(DEFAULT_STATUS) | |
| choices += [f for f in sorted(files, reverse=True) if f.endswith(".json") and f not in choices] | |
| return choices or ["fixtures://normal_event.json"] | |
| def event_status_choices(): | |
| files = event_status_files() | |
| choices = [f for f in sorted(files, reverse=True) if f.startswith(EVENT_STATUS_PREFIX) and f.lower().endswith(".json")] | |
| if EVENT_STATUS_PATH in choices: | |
| choices.remove(EVENT_STATUS_PATH) | |
| choices.insert(0, EVENT_STATUS_PATH) | |
| return choices or [EVENT_STATUS_PATH] | |
| def waveform_image_files(): | |
| files = waveform_files() | |
| return [ | |
| f for f in sorted(files) | |
| if f.startswith(WAVEFORM_PREFIX) and f.lower().endswith(WAVEFORM_IMAGE_EXTS) | |
| ] | |
| def refresh_status(): | |
| status_files.cache_clear() | |
| choices = status_choices() | |
| return gr.update(choices=choices, value=choices[0]), f"已重新讀取 {STATUS_DATASET_ID}:{len(choices)} 筆" | |
| def refresh_all_event_files(): | |
| event_status_files.cache_clear() | |
| choices = event_status_choices() | |
| return f"已重新讀取 {EVENT_DATASET_ID}/{EVENT_STATUS_PREFIX}:{len(choices)} 個 JSON 檔,會以表格同時顯示所有可解析事件。" | |
| def refresh_waveform_files(): | |
| waveform_files.cache_clear() | |
| files = waveform_image_files() | |
| return f"已重新讀取 {WAVEFORM_DATASET_ID}/{WAVEFORM_PREFIX}:找到 {len(files)} 張波形圖片。" | |
| def load_system_status(source): | |
| if source and source.startswith("fixtures://"): | |
| with open(FIXTURES / source.split("://", 1)[1], "r", encoding="utf-8") as f: | |
| return json.load(f), source | |
| errors = [] | |
| for name in [source, DEFAULT_STATUS, DEFAULT_STATUS_ALT]: | |
| if not name: | |
| continue | |
| try: | |
| path = hf_hub_download(repo_id=STATUS_DATASET_ID, filename=name, repo_type="dataset") | |
| with open(path, "r", encoding="utf-8") as f: | |
| return json.load(f), name | |
| except Exception as exc: | |
| errors.append(str(exc)) | |
| raise RuntimeError("; ".join(errors[-2:])) | |
| def load_event_status(source): | |
| path = hf_hub_download(repo_id=EVENT_DATASET_ID, filename=source, repo_type="dataset") | |
| with open(path, "r", encoding="utf-8") as f: | |
| return json.load(f), source | |
| def load_rep_summary_markdown(): | |
| try: | |
| path = hf_hub_download(repo_id=EVENT_DATASET_ID, filename=REP_SUMMARY_PATH, repo_type="dataset") | |
| with open(path, "r", encoding="utf-8") as f: | |
| text = f.read() | |
| return f"### 解析文字資訊\n\n來源:`{EVENT_DATASET_ID}/{REP_SUMMARY_PATH}`\n\n" + text | |
| except Exception as exc: | |
| return f"### 解析文字資訊\n\n⚠️ 無法讀取 `{EVENT_DATASET_ID}/{REP_SUMMARY_PATH}`:{exc}" | |
| def status_level(text): | |
| t = str(text).lower() | |
| if any(k in t for k in ["alive", "up", "ok", "running", "healthy", "success", "available", "exists", "運行", "正常"]): | |
| return "ok" | |
| if any(k in t for k in ["warn", "warning", "partial", "degraded", "警告"]): | |
| return "warn" | |
| if any(k in t for k in ["down", "fail", "error", "exited", "stopped", "missing", "停止"]): | |
| return "bad" | |
| return "neutral" | |
| def normalize_status(info): | |
| if isinstance(info, dict): | |
| if "alive" in info or "is_alive" in info: | |
| value = info.get("alive", info.get("is_alive")) | |
| return "alive" if str(value).strip().lower() in {"true", "1", "yes", "alive"} else "not alive" | |
| return info.get("status") or info.get("state") or info.get("health") or info.get("ok") or "unknown" | |
| return info | |
| def module_items(payload): | |
| rows = [] | |
| for key in ["containers", "container_status", "container", "docker", "modules", "module_status", "services", "checks"]: | |
| value = payload.get(key) | |
| if isinstance(value, dict): | |
| for name, info in value.items(): | |
| rows.append((str(name), str(normalize_status(info)))) | |
| elif isinstance(value, list): | |
| for i, info in enumerate(value): | |
| if isinstance(info, dict): | |
| name = info.get("name") or info.get("module") or info.get("container") or f"module_{i + 1}" | |
| rows.append((str(name), str(normalize_status(info)))) | |
| else: | |
| rows.append((f"module_{i + 1}", str(info))) | |
| header = payload.get("latest_rep_header") or {} | |
| if isinstance(header, dict) and header.get("file"): | |
| rows.append(("Earthworm EEW header", "available")) | |
| rep_files = payload.get("latest_rep_files") | |
| if isinstance(rep_files, list): | |
| rows.append((".rep files", f"{len(rep_files)} files" if rep_files else "missing")) | |
| warning = payload.get("earthworm_log_warning") | |
| rows.append(("Earthworm log", "warning" if warning else "ok")) | |
| return rows or [("status payload", "loaded")] | |
| def render_status(source): | |
| try: | |
| payload, label = load_system_status(source) | |
| except Exception as exc: | |
| with open(FIXTURES / "normal_event.json", "r", encoding="utf-8") as f: | |
| payload = json.load(f) | |
| label = f"fallback: {exc}" | |
| alive_cards = [] | |
| info_cards = [] | |
| for name, status in module_items(payload): | |
| if str(status).strip().lower() == "alive": | |
| alive_cards.append(f"<div class='alive-card'><span class='lamp'></span><div><b>{esc(name)}</b><small>{esc(status)}</small></div></div>") | |
| else: | |
| cls = status_level(status) | |
| info_cards.append(f"<div class='info-card {cls}'><div><b>{esc(name)}</b><small>{esc(status)}</small></div><span class='tag'>{esc(status)}</span></div>") | |
| alive_html = "".join(alive_cards) or "<div class='empty'>目前沒有 alive 型式狀態。</div>" | |
| info_html = "".join(info_cards) or "<div class='empty'>沒有其他狀態資訊。</div>" | |
| source_text = f"{STATUS_DATASET_ID} / {label}" | |
| return f""" | |
| <style> | |
| .status-wrap{{display:grid;gap:16px}} | |
| .status-hero{{position:relative;overflow:hidden;display:flex;justify-content:space-between;align-items:center;gap:18px;border-radius:24px;padding:24px 22px;color:#fff!important;background:radial-gradient(circle at 86% 24%,rgba(125,211,252,.45),transparent 28%),linear-gradient(135deg,#1d4ed8 0%,#2563eb 45%,#0284c7 100%);box-shadow:0 18px 38px rgba(37,99,235,.28),0 6px 18px rgba(15,23,42,.14)}} | |
| .status-hero h2{{margin:4px 0 12px!important;font-size:32px!important;line-height:1.08!important;color:#fff!important;font-weight:950!important;text-shadow:0 2px 10px rgba(15,23,42,.22)}} | |
| .hero-kicker{{font-size:12px;font-weight:900;letter-spacing:.12em;color:rgba(255,255,255,.76)!important}} | |
| .src{{display:inline-block;max-width:100%;padding:9px 12px;border-radius:14px;background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.26);color:#fff!important;font-size:14px!important;line-height:1.55!important;font-weight:700!important;word-break:break-word}} | |
| .src span{{color:rgba(255,255,255,.82)!important;margin-right:6px}} | |
| .hero-icon{{flex:0 0 auto;width:76px;height:76px;border-radius:999px;border:8px solid rgba(255,255,255,.22);display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,.60)!important;font-size:38px;font-weight:900}} | |
| .section{{background:#fff;border:1px solid #e5e7eb;border-radius:18px;padding:14px;box-shadow:0 8px 22px rgba(15,23,42,.06)}} | |
| .section-title{{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;color:#0f172a;font-weight:900;font-size:17px}} | |
| .section-note{{font-size:12px;color:#64748b;font-weight:600}} | |
| .alive-grid,.info-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}} | |
| .alive-card{{display:flex;gap:12px;align-items:center;padding:15px;border-radius:16px;background:linear-gradient(180deg,#f0fdf4,#ffffff);border:1px solid rgba(34,197,94,.28)}} | |
| .lamp{{width:24px;height:24px;border-radius:99px;background:#22c55e;box-shadow:0 0 0 7px rgba(34,197,94,.16),0 0 20px rgba(34,197,94,.70);flex:0 0 auto}} | |
| .info-card{{display:flex;justify-content:space-between;gap:12px;align-items:center;padding:14px;border-radius:16px;background:#f8fafc;border:1px solid #e2e8f0}} | |
| .info-card.ok{{border-color:#dbeafe}}.info-card.warn{{background:#fffbeb;border-color:#fde68a}}.info-card.bad{{background:#fef2f2;border-color:#fecaca}} | |
| b{{display:block;color:#0f172a}}small{{display:block;color:#64748b;margin-top:4px;word-break:break-all}} | |
| .alive-card b{{font-size:18px!important}}.alive-card small{{font-size:14px!important;color:#14532d!important}} | |
| .tag{{font-size:12px;font-weight:800;border-radius:999px;padding:6px 10px;background:#e2e8f0;color:#334155;white-space:nowrap;max-width:140px;overflow:hidden;text-overflow:ellipsis}} | |
| .info-card.ok .tag{{background:#dbeafe;color:#1d4ed8}}.info-card.warn .tag{{background:#fef3c7;color:#b45309}}.info-card.bad .tag{{background:#fee2e2;color:#b91c1c}} | |
| .empty{{padding:14px;border-radius:14px;background:#f8fafc;color:#64748b;border:1px dashed #cbd5e1}} | |
| @media(max-width:640px){{.alive-grid,.info-grid{{grid-template-columns:1fr}}.status-hero{{padding:22px 18px;align-items:flex-start}}.status-hero h2{{font-size:28px!important}}.hero-icon{{width:58px;height:58px;border-width:6px;font-size:30px}}.src{{font-size:13px!important}}}} | |
| </style> | |
| <div class='status-wrap'> | |
| <div class='status-hero'><div><div class='hero-kicker'>EEW STATUS</div><h2>系統狀態</h2><div class='src'><span>來源</span>{esc(source_text)}</div></div><div class='hero-icon'>⌁</div></div> | |
| <div class='section'><div class='section-title'>Alive 模組綠燈 <span class='section-note'>僅 alive 使用燈號</span></div><div class='alive-grid'>{alive_html}</div></div> | |
| <div class='section'><div class='section-title'>其他狀態資訊 <span class='section-note'>非 alive 不使用燈號</span></div><div class='info-grid'>{info_html}</div></div> | |
| </div> | |
| """ | |
| def parse_rep_event(payload): | |
| header = payload.get("latest_rep_header") or {} | |
| lines = header.get("head") or [] | |
| if not isinstance(lines, list): | |
| return None | |
| event = {"source_file": header.get("file"), "stations": []} | |
| for i, line in enumerate(lines): | |
| text = str(line) | |
| if text.strip().lower().startswith("year") and i + 1 < len(lines): | |
| parts = str(lines[i + 1]).split() | |
| if len(parts) >= 14: | |
| year, month, day, hour, minute = parts[0:5] | |
| sec = parts[5] | |
| event.update({ | |
| "time": f"{int(year):04d}/{int(month):02d}/{int(day):02d} {int(hour):02d}:{int(minute):02d}:{float(sec):05.2f}", | |
| "lat": to_float(parts[6]), | |
| "lon": to_float(parts[7]), | |
| "depth_km": to_float(parts[8]), | |
| "magnitude": to_float(parts[9]), | |
| "mpd": to_float(parts[12]) if len(parts) > 12 else None, | |
| "mtc": to_float(parts[13]) if len(parts) > 13 else None, | |
| "process_time": to_float(parts[14]) if len(parts) > 14 else None, | |
| }) | |
| if text.strip().lower().startswith("sta"): | |
| for row in lines[i + 1:]: | |
| cols = str(row).split() | |
| if len(cols) >= 6: | |
| sta_lat = to_float(cols[4]) | |
| sta_lon = to_float(cols[5]) | |
| if sta_lat is not None and sta_lon is not None: | |
| event["stations"].append({"station": cols[0], "lat": sta_lat, "lon": sta_lon}) | |
| return event if event.get("lat") is not None and event.get("lon") is not None else None | |
| def score_event_candidate(obj, path, lat, lon): | |
| path_text = ".".join(path).lower() | |
| score = 0 | |
| keys = {str(k).lower() for k in obj.keys()} | |
| if keys.intersection({k.lower() for k in MAG_KEYS}): | |
| score += 6 | |
| if keys.intersection({k.lower() for k in DEPTH_KEYS}): | |
| score += 5 | |
| if keys.intersection({k.lower() for k in TIME_KEYS}): | |
| score += 3 | |
| if any(word in path_text for word in ["event", "eew", "earthquake", "latest", "report", "hypo"]): | |
| score += 3 | |
| if any(word in path_text for word in ["station", "stations", "sta"]): | |
| score -= 5 | |
| if 18 <= lat <= 28 and 118 <= lon <= 124: | |
| score += 2 | |
| return score | |
| def collect_station_candidates(obj, path=None, out=None): | |
| if path is None: | |
| path = [] | |
| if out is None: | |
| out = [] | |
| if isinstance(obj, dict): | |
| lat = to_float(first_value(obj, LAT_KEYS)) | |
| lon = to_float(first_value(obj, LON_KEYS)) | |
| path_text = ".".join(path).lower() | |
| if lat is not None and lon is not None and any(word in path_text for word in ["station", "stations", "sta"]): | |
| name = first_value(obj, {"station", "sta", "name", "code", "id"}) or f"STA{len(out) + 1}" | |
| out.append({"station": str(name), "lat": lat, "lon": lon}) | |
| for key, value in obj.items(): | |
| collect_station_candidates(value, path + [str(key)], out) | |
| elif isinstance(obj, list): | |
| for idx, value in enumerate(obj[:300]): | |
| collect_station_candidates(value, path + [str(idx)], out) | |
| return out | |
| def recursive_event_candidates(obj, path=None, out=None): | |
| if path is None: | |
| path = [] | |
| if out is None: | |
| out = [] | |
| if isinstance(obj, dict): | |
| lat = to_float(first_value(obj, LAT_KEYS)) | |
| lon = to_float(first_value(obj, LON_KEYS)) | |
| if lat is not None and lon is not None: | |
| score = score_event_candidate(obj, path, lat, lon) | |
| out.append({ | |
| "time": first_value(obj, TIME_KEYS), | |
| "lat": lat, | |
| "lon": lon, | |
| "depth_km": to_float(first_value(obj, DEPTH_KEYS)), | |
| "magnitude": to_float(first_value(obj, MAG_KEYS)), | |
| "location": first_value(obj, LOCATION_KEYS), | |
| "source_file": first_value(obj, SOURCE_KEYS), | |
| "stations": [], | |
| "_score": score, | |
| }) | |
| for key, value in obj.items(): | |
| recursive_event_candidates(value, path + [str(key)], out) | |
| elif isinstance(obj, list): | |
| for idx, value in enumerate(obj[:300]): | |
| recursive_event_candidates(value, path + [str(idx)], out) | |
| return out | |
| def events_from_payload(payload): | |
| parsed = parse_rep_event(payload) | |
| if parsed: | |
| return [parsed] | |
| candidates = recursive_event_candidates(payload) | |
| filtered = [item for item in candidates if item.get("_score", 0) >= 0] | |
| if not filtered and candidates: | |
| filtered = sorted(candidates, key=lambda item: item.get("_score", 0), reverse=True)[:1] | |
| stations = collect_station_candidates(payload) | |
| events = [] | |
| seen = set() | |
| for item in sorted(filtered, key=lambda e: e.get("_score", 0), reverse=True): | |
| key = ( | |
| round(item.get("lat") or 0, 4), | |
| round(item.get("lon") or 0, 4), | |
| str(item.get("time") or ""), | |
| str(item.get("magnitude") or ""), | |
| ) | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| item.pop("_score", None) | |
| item["stations"] = stations | |
| events.append(item) | |
| return events | |
| def load_all_events(): | |
| files = event_status_choices() | |
| events = [] | |
| unresolved = [] | |
| for filename in files: | |
| try: | |
| payload, label = load_event_status(filename) | |
| parsed_events = events_from_payload(payload) | |
| if parsed_events: | |
| for idx, event in enumerate(parsed_events, start=1): | |
| event["source_json"] = label | |
| event["event_index"] = idx | |
| events.append(event) | |
| else: | |
| unresolved.append({"file": label, "reason": "無可解析座標"}) | |
| except Exception as exc: | |
| unresolved.append({"file": filename, "reason": str(exc)}) | |
| return events, unresolved, files | |
| def event_table_html(events, unresolved, files): | |
| rows = [] | |
| for idx, event in enumerate(events, start=1): | |
| mag = event.get("magnitude") | |
| dep = event.get("depth_km") | |
| lat = event.get("lat") | |
| lon = event.get("lon") | |
| rows.append( | |
| "<tr>" | |
| f"<td>{idx}</td>" | |
| f"<td>{esc(event.get('source_json') or event.get('source_file') or '—')}</td>" | |
| f"<td>{esc(event.get('time') or '—')}</td>" | |
| f"<td>{esc(f'M {mag:.2f}' if mag is not None else '—')}</td>" | |
| f"<td>{esc(f'{dep:.1f}' if dep is not None else '—')}</td>" | |
| f"<td>{esc(f'{lat:.4f}' if lat is not None else '—')}</td>" | |
| f"<td>{esc(f'{lon:.4f}' if lon is not None else '—')}</td>" | |
| f"<td>{esc(event.get('location') or '—')}</td>" | |
| f"<td>{len(event.get('stations') or [])}</td>" | |
| "</tr>" | |
| ) | |
| body = "".join(rows) or "<tr><td colspan='9'>目前 status 資料夾內沒有可解析的地震事件經緯度資料。</td></tr>" | |
| unresolved_html = "" | |
| if unresolved: | |
| items = "".join(f"<li><b>{esc(item['file'])}</b>:{esc(item['reason'])}</li>" for item in unresolved) | |
| unresolved_html = f"<details class='unresolved'><summary>未解析檔案 {len(unresolved)} 筆</summary><ul>{items}</ul></details>" | |
| return f""" | |
| <style> | |
| .eq-wrap{{display:grid;gap:14px}} | |
| .eq-hero{{border-radius:22px;padding:20px;color:#fff;background:linear-gradient(135deg,#7f1d1d,#dc2626 55%,#f97316);box-shadow:0 14px 28px rgba(127,29,29,.18)}} | |
| .eq-hero h2{{margin:0 0 8px!important;color:#fff!important;font-size:28px!important;font-weight:950!important}} | |
| .eq-hero p{{margin:0;color:rgba(255,255,255,.88)!important;font-weight:700!important}} | |
| .table-wrap{{overflow:auto;border:1px solid #e5e7eb;border-radius:18px;background:#fff;box-shadow:0 8px 22px rgba(15,23,42,.06)}} | |
| .eq-table{{width:100%;border-collapse:collapse;min-width:920px}} | |
| .eq-table th{{position:sticky;top:0;background:#f8fafc;color:#0f172a;text-align:left;font-size:13px;padding:12px;border-bottom:1px solid #e5e7eb;white-space:nowrap}} | |
| .eq-table td{{padding:12px;border-bottom:1px solid #eef2f7;color:#1f2937;font-size:14px;vertical-align:top;white-space:nowrap}} | |
| .eq-table td:nth-child(2),.eq-table td:nth-child(8){{white-space:normal;min-width:180px}} | |
| .unresolved{{background:#fff7ed;border:1px solid #fed7aa;border-radius:16px;padding:12px;color:#9a3412}} | |
| </style> | |
| <div class='eq-wrap'> | |
| <div class='eq-hero'><h2>全部地震事件表格</h2><p>已掃描 {len(files)} 個 status JSON;成功解析 {len(events)} 筆事件。</p></div> | |
| <div class='table-wrap'><table class='eq-table'><thead><tr><th>#</th><th>來源檔案</th><th>發震時間</th><th>規模</th><th>深度 km</th><th>緯度</th><th>經度</th><th>位置 / 描述</th><th>測站數</th></tr></thead><tbody>{body}</tbody></table></div> | |
| {unresolved_html} | |
| </div> | |
| """ | |
| def all_events_folium_map(events): | |
| valid = [event for event in events if event.get("lat") is not None and event.get("lon") is not None] | |
| if not valid: | |
| return "<div style='padding:16px;border-radius:16px;background:#fff7ed;border:1px solid #fed7aa;color:#9a3412;font-weight:800'>無可繪製的地震事件座標</div>" | |
| center_lat = sum(event["lat"] for event in valid) / len(valid) | |
| center_lon = sum(event["lon"] for event in valid) / len(valid) | |
| fmap = folium.Map(location=[center_lat, center_lon], zoom_start=7, tiles="OpenStreetMap", control_scale=True) | |
| all_station_keys = set() | |
| for idx, event in enumerate(valid, start=1): | |
| lat = event["lat"] | |
| lon = event["lon"] | |
| mag = event.get("magnitude") | |
| dep = event.get("depth_km") | |
| mag_text = f"{mag:.2f}" if isinstance(mag, (int, float)) else "—" | |
| dep_text = f"{dep:.1f}" if isinstance(dep, (int, float)) else "—" | |
| radius = max(8, min(24, 6 + (mag or 0) * 2.5)) | |
| popup = folium.Popup( | |
| f"<b>事件 {idx}</b><br>來源:{esc(event.get('source_json') or '—')}<br>時間:{esc(event.get('time') or '—')}<br>規模:M {mag_text}<br>深度:{dep_text} km<br>座標:{lat:.4f}, {lon:.4f}", | |
| max_width=360, | |
| ) | |
| folium.CircleMarker( | |
| location=[lat, lon], radius=radius, color="#b91c1c", weight=3, fill=True, | |
| fill_color="#ef4444", fill_opacity=0.72, tooltip=f"地震事件 {idx}", popup=popup, | |
| ).add_to(fmap) | |
| folium.Marker(location=[lat, lon], icon=folium.Icon(color="red", icon="exclamation-sign"), tooltip=f"地震事件 {idx}").add_to(fmap) | |
| for station in event.get("stations") or []: | |
| sta_lat = station.get("lat") | |
| sta_lon = station.get("lon") | |
| if sta_lat is None or sta_lon is None: | |
| continue | |
| station_key = (round(sta_lat, 4), round(sta_lon, 4), str(station.get("station", ""))) | |
| if station_key in all_station_keys: | |
| continue | |
| all_station_keys.add(station_key) | |
| folium.CircleMarker( | |
| location=[sta_lat, sta_lon], radius=4, color="#1d4ed8", weight=2, fill=True, | |
| fill_color="#3b82f6", fill_opacity=0.65, tooltip=f"測站:{esc(station.get('station', 'station'))}", | |
| ).add_to(fmap) | |
| folium.LayerControl().add_to(fmap) | |
| map_html = fmap.get_root().render() | |
| return f"<iframe srcdoc=\"{html.escape(map_html, quote=True)}\" style=\"width:100%;height:600px;border:0;border-radius:18px;box-shadow:0 8px 22px rgba(15,23,42,.08);\"></iframe>" | |
| def render_all_events(): | |
| events, unresolved, files = load_all_events() | |
| return event_table_html(events, unresolved, files), all_events_folium_map(events), load_rep_summary_markdown() | |
| def waveform_url(filename): | |
| return f"https://huggingface.co/datasets/{WAVEFORM_DATASET_ID}/resolve/main/{filename}" | |
| def waveform_thumb(filename, idx): | |
| title = Path(filename).name | |
| return f""" | |
| <a class='wave-thumb' href='{esc(waveform_url(filename))}' target='_blank'> | |
| <img src='{esc(waveform_url(filename))}' alt='{esc(title)}' loading='lazy'/> | |
| <span>{idx:02d}</span> | |
| </a> | |
| """ | |
| def waveform_card(filename, idx): | |
| title = Path(filename).name | |
| url = waveform_url(filename) | |
| return f""" | |
| <div class='wave-card'> | |
| <a class='wave-card-img' href='{esc(url)}' target='_blank'><img src='{esc(url)}' alt='{esc(title)}' loading='lazy'/></a> | |
| <div class='wave-card-body'> | |
| <b>{idx:02d}. {esc(title)}</b> | |
| <small>{esc(filename)}</small> | |
| <a href='{esc(url)}' target='_blank'>開啟原圖</a> | |
| </div> | |
| </div> | |
| """ | |
| def render_waveform_gallery(): | |
| waveform_files.cache_clear() | |
| files = waveform_image_files() | |
| shown = files[:MAX_WAVEFORM_IMAGES] | |
| featured = shown[0] if shown else None | |
| feature_html = "" | |
| if featured: | |
| feature_html = f""" | |
| <div class='wave-feature'> | |
| <div class='feature-copy'><span>FEATURED</span><h3>{esc(Path(featured).name)}</h3><p>{esc(featured)}</p><a href='{esc(waveform_url(featured))}' target='_blank'>開啟重點波形原圖</a></div> | |
| <a class='feature-img' href='{esc(waveform_url(featured))}' target='_blank'><img src='{esc(waveform_url(featured))}' alt='{esc(Path(featured).name)}'/></a> | |
| </div> | |
| """ | |
| else: | |
| feature_html = "<div class='wave-empty'>目前資料夾內沒有可展示的波形圖片。</div>" | |
| thumbs = "".join(waveform_thumb(filename, idx) for idx, filename in enumerate(shown, start=1)) | |
| cards = "".join(waveform_card(filename, idx) for idx, filename in enumerate(shown, start=1)) | |
| more_note = "" if len(files) <= len(shown) else f"<div class='wave-note'>另有 {len(files) - len(shown)} 張未顯示,可至 Hugging Face 資料夾查看。</div>" | |
| html_block = f""" | |
| <style> | |
| .wave-wrap{{display:grid;gap:18px}} | |
| .wave-hero{{display:flex;justify-content:space-between;gap:16px;align-items:flex-end;border-radius:24px;padding:22px;color:#fff;background:linear-gradient(135deg,#111827,#334155 52%,#0ea5e9);box-shadow:0 14px 28px rgba(15,23,42,.18)}} | |
| .wave-hero h2{{margin:0 0 8px!important;color:#fff!important;font-size:30px!important;font-weight:950!important}} | |
| .wave-hero p{{margin:0;color:rgba(255,255,255,.86)!important;font-weight:700!important;word-break:break-word}} | |
| .wave-stats{{display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end}} | |
| .wave-pill{{background:rgba(255,255,255,.16);border:1px solid rgba(255,255,255,.22);border-radius:999px;padding:8px 12px;color:#fff;font-weight:900;white-space:nowrap}} | |
| .wave-feature{{display:grid;grid-template-columns:minmax(220px,.75fr) minmax(280px,1.45fr);gap:14px;background:#fff;border:1px solid #e5e7eb;border-radius:22px;padding:14px;box-shadow:0 10px 26px rgba(15,23,42,.08)}} | |
| .feature-copy{{background:linear-gradient(180deg,#f8fafc,#eef6ff);border-radius:18px;padding:18px;display:flex;flex-direction:column;justify-content:center;gap:8px}} | |
| .feature-copy span{{color:#2563eb;font-size:12px;font-weight:950;letter-spacing:.12em}} | |
| .feature-copy h3{{margin:0;color:#0f172a;font-size:22px;line-height:1.2;word-break:break-word}} | |
| .feature-copy p{{margin:0;color:#64748b;font-size:13px;word-break:break-all}} | |
| .feature-copy a,.wave-card-body a{{color:#2563eb;text-decoration:none;font-weight:900;font-size:13px}} | |
| .feature-img{{display:block;background:#f8fafc;border-radius:18px;overflow:hidden;border:1px solid #e5e7eb}} | |
| .feature-img img{{width:100%;height:auto;display:block}} | |
| .wave-strip{{display:flex;gap:10px;overflow-x:auto;padding:4px 2px 10px;scroll-snap-type:x mandatory}} | |
| .wave-thumb{{position:relative;flex:0 0 150px;height:92px;background:#f8fafc;border:1px solid #e5e7eb;border-radius:14px;overflow:hidden;scroll-snap-align:start;box-shadow:0 4px 12px rgba(15,23,42,.05)}} | |
| .wave-thumb img{{width:100%;height:100%;object-fit:cover;display:block}} | |
| .wave-thumb span{{position:absolute;top:7px;left:7px;background:rgba(15,23,42,.78);color:#fff;border-radius:999px;padding:3px 7px;font-size:11px;font-weight:900}} | |
| .wave-section-title{{display:flex;justify-content:space-between;align-items:center;font-weight:950;color:#0f172a;font-size:18px}} | |
| .wave-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(310px,1fr));gap:16px;align-items:start}} | |
| .wave-card{{background:#fff;border:1px solid #e5e7eb;border-radius:20px;overflow:hidden;box-shadow:0 8px 22px rgba(15,23,42,.06)}} | |
| .wave-card-img{{display:block;background:#f8fafc;border-bottom:1px solid #e5e7eb}} | |
| .wave-card-img img{{width:100%;height:auto;display:block}} | |
| .wave-card-body{{padding:13px;display:grid;gap:6px}} | |
| .wave-card-body b{{color:#0f172a;font-size:15px;word-break:break-word}} | |
| .wave-card-body small{{color:#64748b;word-break:break-all;font-size:12px}} | |
| .wave-empty,.wave-note{{padding:14px;border-radius:16px;background:#fff7ed;border:1px solid #fed7aa;color:#9a3412;font-weight:800}} | |
| @media(max-width:760px){{.wave-hero{{display:block}}.wave-stats{{justify-content:flex-start;margin-top:12px}}.wave-feature{{grid-template-columns:1fr}}.wave-grid{{grid-template-columns:1fr}}.wave-hero h2{{font-size:25px!important}}.wave-thumb{{flex-basis:130px;height:82px}}}} | |
| </style> | |
| <div class='wave-wrap'> | |
| <div class='wave-hero'><div><h2>TSMIP 波形展示</h2><p>每次載入皆重新讀取:{esc(WAVEFORM_DATASET_ID)}/{esc(WAVEFORM_PREFIX)}</p></div><div class='wave-stats'><span class='wave-pill'>共 {len(files)} 張</span><span class='wave-pill'>顯示 {len(shown)} 張</span><span class='wave-pill'>上限 {MAX_WAVEFORM_IMAGES}</span></div></div> | |
| {feature_html} | |
| <div><div class='wave-section-title'>快速檢視 <span>{len(shown)} 張</span></div><div class='wave-strip'>{thumbs}</div></div> | |
| <div><div class='wave-section-title'>完整圖庫 <span>Gallery</span></div><div class='wave-grid'>{cards}</div></div> | |
| {more_note} | |
| </div> | |
| """ | |
| return f"✅ 已重新讀取並載入波形展示:{WAVEFORM_DATASET_ID}/{WAVEFORM_PREFIX},共 {len(files)} 張圖片。", html_block | |
| status_opts = status_choices() | |
| with gr.Blocks(title="EEW Dashboard") as demo: | |
| gr.Markdown("# EEW Dashboard\n系統狀態、全部地震事件與 TSMIP 波形展示。波形展示會每次重新讀取 `tsmip/waveform/`,並以重點圖、縮圖列與圖庫排版呈現。") | |
| with gr.Tab("系統狀態"): | |
| with gr.Row(): | |
| s = gr.Dropdown(choices=status_opts, value=status_opts[0], label="Status file") | |
| sr = gr.Button("重新讀取狀態清單") | |
| sl = gr.Button("載入狀態") | |
| sm = gr.Markdown() | |
| lights = gr.HTML() | |
| sr.click(refresh_status, outputs=[s, sm]) | |
| sl.click(render_status, inputs=s, outputs=lights) | |
| s.change(render_status, inputs=s, outputs=lights) | |
| demo.load(render_status, inputs=s, outputs=lights) | |
| with gr.Tab("地震事件"): | |
| with gr.Row(): | |
| er = gr.Button("重新讀取事件清單") | |
| el = gr.Button("載入全部事件") | |
| em = gr.Markdown(f"事件資料來源:`{EVENT_DATASET_ID}/{EVENT_STATUS_PREFIX}`;解析文字資訊:`{EVENT_DATASET_ID}/{REP_SUMMARY_PATH}`") | |
| event_table = gr.HTML() | |
| event_map = gr.HTML() | |
| event_summary = gr.Markdown() | |
| er.click(refresh_all_event_files, outputs=em) | |
| el.click(render_all_events, outputs=[event_table, event_map, event_summary]) | |
| demo.load(render_all_events, outputs=[event_table, event_map, event_summary]) | |
| with gr.Tab("靜態波形"): | |
| wm = gr.Markdown(f"波形資料夾來源:`{WAVEFORM_DATASET_ID}/{WAVEFORM_PREFIX}`") | |
| with gr.Row(): | |
| wr = gr.Button("重新讀取波形資料夾") | |
| wp = gr.Button("載入波形展示") | |
| wave_msg = gr.Markdown() | |
| wave_gallery = gr.HTML() | |
| wr.click(refresh_waveform_files, outputs=wave_msg) | |
| wp.click(render_waveform_gallery, outputs=[wave_msg, wave_gallery]) | |
| demo.load(render_waveform_gallery, outputs=[wave_msg, wave_gallery]) | |
| if __name__ == "__main__": | |
| demo.launch() | |