feat: implement MonitorPanel component with real-time airport weather tracking and concurrency-controlled polling

This commit is contained in:
2569718930@qq.com
2026-05-14 02:41:51 +08:00
parent f5acae3c81
commit 8bfe23138c
@@ -88,11 +88,26 @@ function trendSymbol(tr: "rising" | "falling" | "flat") {
* Resolve today's observed high temperature.
* Aligned with Telegram bot (_get_airport_daily_high):
* only airport_current.max_so_far — no fallback.
*
* Exception — HKO cities (Hong Kong / Lau Fau Shan):
* The HKO observatory IS the settlement station, so current.max_so_far
* is equivalent to the airport obs. Use it as a fallback when
* airport_current.max_so_far is absent.
*/
function resolveMaxSoFar(detail: CityDetail | undefined): number | null {
function resolveMaxSoFar(
detail: CityDetail | undefined,
key?: string,
): number | null {
const v = detail?.airport_current?.max_so_far ?? null;
if (v == null) return null;
return Math.round(v * 10) / 10;
if (v != null) return Math.round(v * 10) / 10;
// HKO fallback: current.max_so_far is authoritative for HKO stations
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;
}
return null;
}
/* ── Airport names ───────────────────────────────────────────── */
@@ -126,6 +141,17 @@ function airportLabel(key: string, isEn: boolean) {
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<MonitorKey>(["hong kong", "lau fau shan"]);
function obsSourceLabel(key: MonitorKey, isEn: boolean): string {
if (HKO_OBS_CITIES.has(key)) return isEn ? "HKO Obs" : "天文台观测";
return isEn ? "Airport METAR" : "机场报文";
}
/* ── Skeleton Card ───────────────────────────────────────────── */
function SkeletonCard({ label }: { label: string }) {
return (
@@ -283,7 +309,7 @@ export default function MonitorPanel({
for (const { key, detail } of sorted) {
const ac = detail?.airport_current;
const cur = ac?.temp ?? detail?.current?.temp ?? null;
const max = resolveMaxSoFar(detail); // same best-source logic as card display
const max = resolveMaxSoFar(detail, key); // HKO cities fall back to current.max_so_far
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)}`;
@@ -361,8 +387,8 @@ export default function MonitorPanel({
const ac = detail.airport_current;
const cur = ac?.temp ?? detail.current?.temp ?? null;
const max = resolveMaxSoFar(detail); // best-source today's high
const mtt = ac?.max_temp_time ?? null;
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 obs = ac?.obs_time ?? detail.local_time ?? "";
const age = ac?.obs_age_min ?? null;
const freshness = freshnessLevel(age);
@@ -425,7 +451,7 @@ export default function MonitorPanel({
{/* Stats */}
<div className="monitor-stats">
<div className="monitor-high-row">
<span className="monitor-stat-label">{t("High", "高温", lang)}</span>
<span className="monitor-stat-label">{t("Today's High", "今日实测高温", lang)}</span>
{max != null ? (
<>
<span className="monitor-high-value">{max.toFixed(1)}{tempSymbol}</span>
@@ -438,7 +464,7 @@ export default function MonitorPanel({
</div>
<div className="monitor-obs-row">
<span className="monitor-stat-label">
{t("Airport METAR", "机场报文", lang)}
{obsSourceLabel(key, isEn)}
</span>
<span className={`monitor-obs-age ${freshness}`}>
{age != null ? (