"use client"; import { useMemo, useState, useEffect, useRef, useCallback } from "react"; import { useCityDetails, useDashboardActions } from "@/hooks/useDashboardStore"; import { useI18n } from "@/hooks/useI18n"; import type { CityDetail, ObservationFreshness } from "@/lib/dashboard-types"; import { getMonitorFreshnessLevel, getObservationFreshness, shouldRefreshMonitorCity, type MonitorFreshnessLevel, } from "@/lib/source-freshness"; import { getMonitorRefreshRequest, MONITOR_REFRESH_INTERVAL_MS, type MonitorRefreshTrigger, } from "./monitor-refresh-policy"; import { resolveMonitorTemperature } from "./monitor-temperature"; /* ── Constants ───────────────────────────────────────────────── */ const MONITOR_KEYS = [ "seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris", "hong kong", "lau fau shan", "taipei", "new york", "los angeles", "chicago", "denver", "atlanta", "miami", "san francisco", "houston", "dallas", "austin", "seattle", ] as const; type MonitorKey = (typeof MONITOR_KEYS)[number]; type MonitorRefreshRequest = ReturnType; const CONCURRENCY = 6; const KOREA_RUNWAY_MONITOR_KEYS = new Set(["seoul", "busan"]); /* ── Helpers ─────────────────────────────────────────────────── */ type Lang = { isEn: boolean }; function t(en: string, zh: string, { isEn }: Lang) { return isEn ? en : zh; } type Freshness = MonitorFreshnessLevel; function freshnessDotTitle( level: Freshness, ageMin: number | null | undefined, isEn: boolean, freshness?: ObservationFreshness | null, ): string { const age = ageMin != null ? (isEn ? `${ageMin} min ago` : `${ageMin} 分钟前`) : "--"; const source = freshness?.source_label ? `${freshness.source_label} · ` : ""; const cadence = freshness?.native_update_interval_sec != null ? Math.round(freshness.native_update_interval_sec / 60) : null; const cadenceText = cadence != null ? isEn ? ` · native cadence ${cadence} min` : ` · 源端约 ${cadence} 分钟更新` : ""; if (isEn) { return level === "fresh" ? `${source}Fresh · ${age}${cadenceText}` : level === "aging" ? `${source}Waiting / aging · ${age}${cadenceText}` : level === "stale" ? `${source}Stale · ${age}${cadenceText}` : `${source}Unknown age${cadenceText}`; } return level === "fresh" ? `${source}数据新鲜 · ${age}${cadenceText}` : level === "aging" ? `${source}等待源端更新 / 数据变旧 · ${age}${cadenceText}` : level === "stale" ? `${source}数据陈旧 · ${age}${cadenceText}` : `${source}更新时间未知${cadenceText}`; } /* ── Audio alert (Web Audio API, no external file needed) ────── */ function playNewHighBeep(): void { try { const ctx = new AudioContext(); // Two-tone rising beep: signals a new temperature high const tones = [ { freq: 880, start: 0, dur: 0.12, peak: 0.40 }, { freq: 1100, start: 0.15, dur: 0.18, peak: 0.55 }, ]; for (const { freq, start, dur, peak } of tones) { const osc = ctx.createOscillator(); const g = ctx.createGain(); osc.type = "sine"; osc.frequency.value = freq; osc.connect(g); g.connect(ctx.destination); g.gain.setValueAtTime(0, ctx.currentTime + start); g.gain.linearRampToValueAtTime(peak, ctx.currentTime + start + 0.03); g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + start + dur); osc.start(ctx.currentTime + start); osc.stop(ctx.currentTime + start + dur + 0.01); } setTimeout(() => ctx.close(), 600); } catch { // AudioContext may be blocked before a user gesture — silently ignore } } function trendClass(detail: CityDetail | undefined, key?: string): "rising" | "falling" | "flat" { const ignoreRunway = key === "seoul" || key === "busan"; const { source } = resolveMonitorTemperature(detail, { ignoreRunway }); // Runway surface temp vs air temp comparison is meaningless if ( source === "amos_runway_median" || source === "amos_runway" || source === "amsc_awos_runway_max" || source === "amsc_awos_runway" ) return "flat"; const cur = resolveMonitorTemperature(detail, { ignoreRunway }).value; const max = resolveMaxSoFar(detail, key); if (cur != null && max != null && cur >= max + 0.3) return "rising"; if (cur != null && max != null && cur < max - 1.0) return "falling"; return "flat"; } function trendSymbol(tr: "rising" | "falling" | "flat") { return tr === "rising" ? "↑" : tr === "falling" ? "↓" : "→"; } /** * Resolve today's observed high temperature. * * HKO cities (Hong Kong / Lau Fau Shan): * current.max_so_far from HKO observatory IS the settlement anchor. * airport_current (METAR) is secondary — prefer HKO first. * * All other cities: * airport_current.max_so_far is the authoritative settlement reference. */ function resolveMaxSoFar( detail: CityDetail | undefined, key?: string, ): number | null { // HKO: settlement station takes priority over airport METAR if (key === "hong kong" || key === "lau fau shan") { const hko = detail?.current?.max_so_far ?? null; if (hko != null) return Math.round(hko * 10) / 10; } const v = detail?.airport_current?.max_so_far ?? null; if (v != null) return Math.round(v * 10) / 10; // Non-HKO fallback to current.max_so_far if (key !== "hong kong" && key !== "lau fau shan") { const cur = detail?.current?.max_so_far ?? null; if (cur != null) return Math.round(cur * 10) / 10; } return null; } /* ── Airport names ───────────────────────────────────────────── */ const AIRPORT_NAMES: Record = { seoul: { en: "Incheon", zh: "仁川" }, busan: { en: "Gimhae", zh: "金海" }, tokyo: { en: "Haneda", zh: "羽田" }, ankara: { en: "Esenboğa", zh: "埃森博阿" }, helsinki: { en: "Vantaa", zh: "万塔" }, amsterdam: { en: "Schiphol", zh: "史基浦" }, istanbul: { en: "Airport", zh: "机场站" }, paris: { en: "Le Bourget", zh: "勒布尔热" }, "hong kong": { en: "Observatory", zh: "天文台" }, "lau fau shan": { en: "Lau Fau Shan",zh: "流浮山" }, taipei: { en: "Songshan", zh: "松山" }, "new york": { en: "LaGuardia", zh: "拉瓜迪亚" }, "los angeles": { en: "LAX", zh: "洛杉矶" }, chicago: { en: "O'Hare", zh: "奥黑尔" }, denver: { en: "Buckley", zh: "巴克利" }, atlanta: { en: "Hartsfield", zh: "哈茨菲尔德" }, miami: { en: "MIA", zh: "迈阿密" }, "san francisco": { en: "SFO", zh: "旧金山" }, houston: { en: "Hobby", zh: "霍比" }, dallas: { en: "Love Field", zh: "勒芙机场" }, austin: { en: "Bergstrom", zh: "伯格斯特罗姆" }, seattle: { en: "SeaTac", zh: "西塔克" }, }; function airportLabel(key: string, isEn: boolean) { const e = AIRPORT_NAMES[key]; return e ? (isEn ? e.en : e.zh) : ""; } /** * Cities whose observation data comes from HKO ground stations, * NOT ICAO airport METAR. Display "天文台观测 / HKO Obs" for these. */ const HKO_OBS_CITIES = new Set(["hong kong", "lau fau shan"]); const SOURCE_LABELS: Record = { amsc_awos_runway_max: { en: "AMSC AWOS Runway", zh: "AMSC AWOS 跑道观测" }, amsc_awos_runway: { en: "AMSC AWOS Runway", zh: "AMSC AWOS 跑道观测" }, amsc_awos: { en: "AMSC AWOS", zh: "AMSC AWOS" }, amos_runway_median: { en: "AMOS Runway", zh: "AMOS 跑道温度" }, amos_runway: { en: "AMOS Runway", zh: "AMOS 跑道温度" }, amos: { en: "AMOS", zh: "AMOS" }, madis_hfmetar: { en: "NOAA MADIS", zh: "NOAA MADIS" }, mgm: { en: "MGM", zh: "MGM" }, jma_amedas: { en: "JMA AMeDAS", zh: "JMA AMeDAS" }, fmi: { en: "FMI", zh: "FMI" }, knmi: { en: "KNMI", zh: "KNMI" }, sg_mss: { en: "Singapore MSS", zh: "新加坡 MSS" }, airport_current: { en: "Airport METAR", zh: "机场报文" }, current: { en: "Obs", zh: "观测" }, }; function resolveSourceLabel( detail: CityDetail | undefined, key: MonitorKey, isEn: boolean, ): string { // Use airport_primary source label from high-freq pipeline const apSource = detail?.airport_primary?.source_code; if (apSource && SOURCE_LABELS[apSource]) { return isEn ? SOURCE_LABELS[apSource].en : SOURCE_LABELS[apSource].zh; } // Use temperature resolution source const { source } = resolveMonitorTemperature(detail, { ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(key), }); if (source && SOURCE_LABELS[source]) { return isEn ? SOURCE_LABELS[source].en : SOURCE_LABELS[source].zh; } if (HKO_OBS_CITIES.has(key)) return isEn ? "HKO Obs" : "天文台观测"; return isEn ? "Airport METAR" : "机场报文"; } /* ── Skeleton Card ───────────────────────────────────────────── */ function SkeletonCard({ label }: { label: string }) { return (
); } /* ── Freshness dot ───────────────────────────────────────────── */ function FreshnessDot({ level, title }: { level: Freshness; title: string }) { return ( ); } /* ── Main component ──────────────────────────────────────────── */ export default function MonitorPanel({ onCityClick, }: { onCityClick?: (cityName: string) => void; }) { const { ensureCityDetail } = useDashboardActions(); const { locale } = useI18n(); const isEn = locale === "en-US"; const lang: Lang = { isEn }; const { cityDetailsByName: details } = useCityDetails(); const detailsRef = useRef(details); detailsRef.current = details; const ensureCityDetailRef = useRef(ensureCityDetail); useEffect(() => { ensureCityDetailRef.current = ensureCityDetail; }, [ensureCityDetail]); const [time, setTime] = useState(""); const [fetchingKeys, setFetchingKeys] = useState>(new Set()); const cancelledRef = useRef(false); const globalFetchingRef = useRef(false); /* Flash state: tracks cities whose temperature just changed */ const [flashingKeys, setFlashingKeys] = useState>(new Set()); const [lastRefreshed, setLastRefreshed] = useState(null); const prevTempsRef = useRef>>({}); const flashTimersRef = useRef>>({}); useEffect(() => { setTime(new Date().toLocaleTimeString()); const timer = setInterval(() => setTime(new Date().toLocaleTimeString()), 30_000); return () => clearInterval(timer); }, []); const [notify, setNotify] = useState(() => { if (typeof window === "undefined") return false; return localStorage.getItem("monitor_notify") !== "off"; }); /* Detect temperature changes → trigger per-city flash + update lastRefreshed */ useEffect(() => { const changed: MonitorKey[] = []; for (const key of MONITOR_KEYS) { const detail = details[key]; const cur = resolveMonitorTemperature(detail, { ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(key), }).value; const prev = prevTempsRef.current[key]; if (cur != null && prev != null && cur !== prev) changed.push(key); if (cur != null) prevTempsRef.current[key] = cur; } if (changed.length === 0) return; setLastRefreshed( new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), ); setFlashingKeys((prev) => new Set([...prev, ...changed])); for (const key of changed) { clearTimeout(flashTimersRef.current[key]); flashTimersRef.current[key] = setTimeout(() => { setFlashingKeys((prev) => { const n = new Set(prev); n.delete(key); return n; }); }, 900); } }, [details]); /* Per-city fetch with loading-key tracking */ const fetchCity = useCallback( async (key: MonitorKey, request: MonitorRefreshRequest) => { setFetchingKeys((prev) => new Set([...prev, key])); try { await ensureCityDetailRef.current(key, request.force, request.depth); } catch { /* individual city errors are shown as "--" in the card */ } finally { setFetchingKeys((prev) => { const next = new Set(prev); next.delete(key); return next; }); } }, [], ); /* Refresh all cities, sorted by staleness (most stale first). */ const refreshAll = useCallback( async (trigger: MonitorRefreshTrigger) => { if (globalFetchingRef.current) return; globalFetchingRef.current = true; const request = getMonitorRefreshRequest(trigger); const now = new Date(); /* Sort keys: cities with no data first, then by source-aware freshness/staleness. */ const dueKeys = [...MONITOR_KEYS].filter((key) => shouldRefreshMonitorCity({ detail: detailsRef.current[key], now, trigger, }), ); const sorted = dueKeys.sort((a, b) => { const d = detailsRef.current; const freshA = getObservationFreshness(d[a]); const freshB = getObservationFreshness(d[b]); const ageA = freshA?.age_sec != null ? freshA.age_sec / 60 : d[a]?.airport_current?.obs_age_min ?? Infinity; const ageB = freshB?.age_sec != null ? freshB.age_sec / 60 : d[b]?.airport_current?.obs_age_min ?? Infinity; return ageB - ageA; // stale first }); const queue = sorted as MonitorKey[]; if (queue.length === 0) { globalFetchingRef.current = false; return; } const workers = Array.from({ length: CONCURRENCY }, async () => { while (queue.length > 0) { if (cancelledRef.current) return; const key = queue.shift(); if (!key) break; await fetchCity(key, request); } }); await Promise.allSettled(workers); globalFetchingRef.current = false; setLastRefreshed( new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), ); }, [fetchCity], ); useEffect(() => { cancelledRef.current = false; void refreshAll("initial"); const timer = setInterval(() => { if (!document.hidden) void refreshAll("interval"); }, MONITOR_REFRESH_INTERVAL_MS); return () => { cancelledRef.current = true; clearInterval(timer); }; }, [refreshAll]); /* City list sorted by current temp descending */ const sorted = useMemo(() => { return [...MONITOR_KEYS] .map((k) => ({ key: k, detail: details[k] })) .sort((a, b) => { const ta = resolveMonitorTemperature(a.detail, { ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(a.key), }).value; const tb = resolveMonitorTemperature(b.detail, { ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(b.key), }).value; if (ta == null && tb == null) return 0; if (ta == null) return 1; if (tb == null) return -1; return tb - ta; }); }, [details]); /* Summary counts for the header */ const loadedCount = useMemo( () => MONITOR_KEYS.filter((k) => details[k] != null).length, [details], ); const totalCount = MONITOR_KEYS.length; const allLoaded = loadedCount === totalCount; const toggleNotify = () => { const next = !notify; setNotify(next); localStorage.setItem("monitor_notify", next ? "on" : "off"); if (next && typeof Notification !== "undefined" && Notification.permission === "default") { Notification.requestPermission(); } }; /* Audio alert when current temp exceeds today's observed high by ≥ 0.3 °C */ useEffect(() => { if (!notify) return; const today = new Date().toDateString(); let alerted: Record = {}; try { alerted = JSON.parse(localStorage.getItem("monitor_alerted_highs") || "{}"); } catch {} if (alerted._day !== today) alerted = { _day: today }; for (const { key, detail } of sorted) { const { source, value: cur } = resolveMonitorTemperature(detail, { ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(key), }); // Skip runway cities: runway surface temp ≠ air temp if (source === "amos_runway_median" || source === "amos_runway") continue; const max = resolveMaxSoFar(detail, key); if (cur != null && max != null && cur >= max + 0.3) { // Key: city + rounded temp, so we only beep once per 0.1°C step const id = `${key}|${(Math.round(cur * 10) / 10).toFixed(1)}`; if (!alerted[id]) { alerted[id] = true; localStorage.setItem("monitor_alerted_highs", JSON.stringify(alerted)); playNewHighBeep(); } } } }, [sorted, notify]); /* ── Render ── */ return (
{/* Header */}

{t("🔥 Market Monitor", "🔥 市场监控", lang)}

{!allLoaded && (

{isEn ? `Loading ${loadedCount} / ${totalCount} cities…` : `正在加载 ${loadedCount} / ${totalCount} 个城市…`}

)}
{fetchingKeys.size > 0 && ( {isEn ? `Syncing ${fetchingKeys.size}` : `同步中 ${fetchingKeys.size}`} )} {lastRefreshed && ( {isEn ? `↻ ${lastRefreshed}` : `↻ ${lastRefreshed}`} )} {time}
{/* Freshness legend */}
{t("< 20 min", "< 20 分", lang)} {t("20–45 min", "20–45 分", lang)} {t("> 45 min", "> 45 分", lang)}
{/* Card grid — progressive: show skeleton only for cities not yet loaded */}
{sorted.map(({ key, detail }) => { const isRefreshing = fetchingKeys.has(key); /* City not yet loaded at all → skeleton */ if (!detail) { return ; } const ac = detail.airport_current; const tempInfo = resolveMonitorTemperature(detail, { ignoreRunway: KOREA_RUNWAY_MONITOR_KEYS.has(key), }); const cur = tempInfo.value; const curSource = tempInfo.source; const isRunwayTemp = curSource === "amos_runway_median" || curSource === "amos_runway" || curSource === "amsc_awos_runway_max" || curSource === "amsc_awos_runway"; const max = resolveMaxSoFar(detail, key); // HKO cities fall back to current.max_so_far const mtt = ac?.max_temp_time ?? detail.current?.max_temp_time ?? null; const freshnessInfo = getObservationFreshness(detail); const obs = freshnessInfo?.observed_at_local ?? ac?.obs_time ?? detail.current?.obs_time ?? detail.local_time ?? ""; const age = freshnessInfo?.age_sec != null ? Math.round(freshnessInfo.age_sec / 60) : ac?.obs_age_min ?? null; const freshness = getMonitorFreshnessLevel(freshnessInfo, age); const tempSymbol = detail.temp_symbol || "°C"; // °F for US cities // Runway surface temp vs air temp comparison is meaningless const newHigh = !isRunwayTemp && cur != null && max != null && cur >= max + 0.3; const warm = !newHigh && !isRunwayTemp && cur != null && cur >= 30; const tr = trendClass(detail, key); const rwPairs = detail.amos?.runway_obs?.runway_pairs ?? []; const rwTemps = detail.amos?.runway_obs?.temperatures ?? []; const showRunwayRows = !KOREA_RUNWAY_MONITOR_KEYS.has(key); const isFlashing = flashingKeys.has(key); return (
onCityClick(key) : undefined} onKeyDown={onCityClick ? (e) => { if (e.key === "Enter" || e.key === " ") onCityClick(key); } : undefined} title={onCityClick ? (isEn ? `Open ${detail.display_name || key} decision card` : `打开 ${detail.display_name || key} 决策卡`) : undefined} > {/* Refresh progress bar */} {isRefreshing &&
} {/* Card header */}
{detail.display_name || key} / {airportLabel(key, isEn)} {newHigh && ( {t("◆ New High", "◆新高", lang)} )} {obs}
{/* Temperature — runway cities skip the large value (runway surface ≠ air temp) */}
{isRunwayTemp ? ( {t("Runway Temp", "跑道温度", lang)} ) : cur != null ? ( <> {Number.isInteger(cur) ? cur.toFixed(0) : cur.toFixed(1)} {tempSymbol} ) : ( -- )}
{/* Stats */}
{t("Today's High", "今日实测高温", lang)} {max != null ? ( <> {Number.isInteger(max) ? max.toFixed(0) : max.toFixed(1)}{tempSymbol} {mtt && {mtt}} ) : ( -- )} {trendSymbol(tr)}
{resolveSourceLabel(detail, key, isEn)} {age != null ? ( age < 60 ? (isEn ? `${age} min ago` : `${age} 分钟未更新`) : (isEn ? `⚠ last ${obs || "?"}` : `⚠ 最后报文 ${obs || "--"}`) ) : ( -- )}
{/* Runway temps */} {showRunwayRows && rwPairs.length > 0 && rwTemps.length > 0 && ( <>
{rwPairs.map((p, i) => { const temp = rwTemps[i]?.[0]; if (temp == null) return null; return (
{p[0]}/{p[1]} {temp.toFixed(1)}°C
); })} )}
); })}
); }