feat: add MonitorPanel component for real-time airport weather tracking and automated refresh coordination
This commit is contained in:
@@ -73,9 +73,8 @@ function playNewHighBeep(): void {
|
|||||||
|
|
||||||
function trendClass(detail: CityDetail | undefined): "rising" | "falling" | "flat" {
|
function trendClass(detail: CityDetail | undefined): "rising" | "falling" | "flat" {
|
||||||
if (!detail?.airport_current) return "flat";
|
if (!detail?.airport_current) return "flat";
|
||||||
const ac = detail.airport_current;
|
const cur = detail.airport_current.temp ?? detail.current?.temp ?? null;
|
||||||
const cur = ac.temp ?? detail.current?.temp ?? null;
|
const max = resolveMaxSoFar(detail);
|
||||||
const max = ac.max_so_far ?? null;
|
|
||||||
if (cur != null && max != null && cur >= max + 0.3) return "rising";
|
if (cur != null && max != null && cur >= max + 0.3) return "rising";
|
||||||
if (cur != null && max != null && cur < max - 1.0) return "falling";
|
if (cur != null && max != null && cur < max - 1.0) return "falling";
|
||||||
return "flat";
|
return "flat";
|
||||||
@@ -85,6 +84,43 @@ function trendSymbol(tr: "rising" | "falling" | "flat") {
|
|||||||
return tr === "rising" ? "↑" : tr === "falling" ? "↓" : "→";
|
return tr === "rising" ? "↑" : tr === "falling" ? "↓" : "→";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve today's observed high temperature from airport METAR sources only.
|
||||||
|
*
|
||||||
|
* Priority (all METAR / airport station sources):
|
||||||
|
* 1. airport_current.max_so_far — backend rolling max (most authoritative)
|
||||||
|
* 2. max(airport_primary_today_obs) — client-derived from airport primary station's today series
|
||||||
|
* 3. max(metar_today_obs) — client-derived from METAR today obs series
|
||||||
|
*
|
||||||
|
* The settlement station (current.max_so_far) is intentionally excluded —
|
||||||
|
* it may be a different microclimate and is not the airport METAR reading.
|
||||||
|
*/
|
||||||
|
function resolveMaxSoFar(detail: CityDetail | undefined): number | null {
|
||||||
|
// ① Backend rolling max from airport METAR — primary and most reliable
|
||||||
|
const backendMax = detail?.airport_current?.max_so_far ?? null;
|
||||||
|
if (backendMax != null) return backendMax;
|
||||||
|
|
||||||
|
// ② Client-derived max from airport primary station's today obs series
|
||||||
|
const primaryObs = detail?.airport_primary_today_obs ?? [];
|
||||||
|
if (primaryObs.length) {
|
||||||
|
const temps = primaryObs
|
||||||
|
.map((o) => o.temp)
|
||||||
|
.filter((t): t is number => t != null);
|
||||||
|
if (temps.length) return Math.max(...temps);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ Client-derived max from METAR today obs series
|
||||||
|
const metarObs = detail?.metar_today_obs ?? [];
|
||||||
|
if (metarObs.length) {
|
||||||
|
const temps = metarObs
|
||||||
|
.map((o) => o.temp)
|
||||||
|
.filter((t): t is number => t != null);
|
||||||
|
if (temps.length) return Math.max(...temps);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Airport names ───────────────────────────────────────────── */
|
/* ── Airport names ───────────────────────────────────────────── */
|
||||||
const AIRPORT_NAMES: Record<string, { en: string; zh: string }> = {
|
const AIRPORT_NAMES: Record<string, { en: string; zh: string }> = {
|
||||||
seoul: { en: "Incheon", zh: "仁川" },
|
seoul: { en: "Incheon", zh: "仁川" },
|
||||||
@@ -269,7 +305,7 @@ export default function MonitorPanel() {
|
|||||||
for (const { key, detail } of sorted) {
|
for (const { key, detail } of sorted) {
|
||||||
const ac = detail?.airport_current;
|
const ac = detail?.airport_current;
|
||||||
const cur = ac?.temp ?? detail?.current?.temp ?? null;
|
const cur = ac?.temp ?? detail?.current?.temp ?? null;
|
||||||
const max = ac?.max_so_far ?? null;
|
const max = resolveMaxSoFar(detail); // same best-source logic as card display
|
||||||
if (cur != null && max != null && cur >= max + 0.3) {
|
if (cur != null && max != null && cur >= max + 0.3) {
|
||||||
// Key: city + rounded temp, so we only beep once per 0.1°C step
|
// Key: city + rounded temp, so we only beep once per 0.1°C step
|
||||||
const id = `${key}|${(Math.round(cur * 10) / 10).toFixed(1)}`;
|
const id = `${key}|${(Math.round(cur * 10) / 10).toFixed(1)}`;
|
||||||
@@ -347,7 +383,7 @@ export default function MonitorPanel() {
|
|||||||
|
|
||||||
const ac = detail.airport_current;
|
const ac = detail.airport_current;
|
||||||
const cur = ac?.temp ?? detail.current?.temp ?? null;
|
const cur = ac?.temp ?? detail.current?.temp ?? null;
|
||||||
const max = ac?.max_so_far ?? null;
|
const max = resolveMaxSoFar(detail); // best-source today's high
|
||||||
const mtt = ac?.max_temp_time ?? null;
|
const mtt = ac?.max_temp_time ?? null;
|
||||||
const obs = ac?.obs_time ?? detail.local_time ?? "";
|
const obs = ac?.obs_time ?? detail.local_time ?? "";
|
||||||
const age = ac?.obs_age_min ?? null;
|
const age = ac?.obs_age_min ?? null;
|
||||||
@@ -358,6 +394,7 @@ export default function MonitorPanel() {
|
|||||||
const rwPairs = detail.amos?.runway_obs?.runway_pairs ?? [];
|
const rwPairs = detail.amos?.runway_obs?.runway_pairs ?? [];
|
||||||
const rwTemps = detail.amos?.runway_obs?.temperatures ?? [];
|
const rwTemps = detail.amos?.runway_obs?.temperatures ?? [];
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
@@ -415,10 +452,16 @@ export default function MonitorPanel() {
|
|||||||
<span className={`monitor-trend ${tr}`}>{trendSymbol(tr)}</span>
|
<span className={`monitor-trend ${tr}`}>{trendSymbol(tr)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="monitor-obs-row">
|
<div className="monitor-obs-row">
|
||||||
<span className="monitor-stat-label">{t("Obs", "观测", lang)}</span>
|
<span className="monitor-stat-label">
|
||||||
|
{t("Airport METAR", "机场报文", lang)}
|
||||||
|
</span>
|
||||||
<span className={`monitor-obs-age ${freshness}`}>
|
<span className={`monitor-obs-age ${freshness}`}>
|
||||||
{age != null
|
{age != null
|
||||||
? (isEn ? `${age} min ago` : `${age} 分钟前`)
|
? (isEn
|
||||||
|
? `${age} min ago`
|
||||||
|
: age < 60
|
||||||
|
? `${age} 分钟未更新`
|
||||||
|
: `${Math.round(age / 60)} 小时未更新`)
|
||||||
: <span className="monitor-stat-missing">--</span>}
|
: <span className="monitor-stat-missing">--</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user