feat: implement MonitorPanel component for real-time weather monitoring and temperature tracking
This commit is contained in:
@@ -579,7 +579,6 @@
|
|||||||
color: #2563EB;
|
color: #2563EB;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Animations ── */
|
|
||||||
@keyframes monitorPulse {
|
@keyframes monitorPulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.3; }
|
50% { opacity: 0.3; }
|
||||||
@@ -589,3 +588,34 @@
|
|||||||
0%, 100% { opacity: 0.55; }
|
0%, 100% { opacity: 0.55; }
|
||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Value-change flash (green pulse on new temperature) ── */
|
||||||
|
@keyframes monitorTempFlash {
|
||||||
|
0% { color: inherit; text-shadow: none; }
|
||||||
|
25% { color: #4ade80; text-shadow: 0 0 12px rgba(74, 222, 128, 0.55); }
|
||||||
|
100% { color: inherit; text-shadow: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes monitorHeadFlash {
|
||||||
|
0% { background: transparent; }
|
||||||
|
25% { background: rgba(74, 222, 128, 0.10); }
|
||||||
|
100% { background: transparent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.monitor-temp-value.flashed) {
|
||||||
|
animation: monitorTempFlash 0.9s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.monitor-card-head.flashed) {
|
||||||
|
animation: monitorHeadFlash 0.9s ease-out;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Last-refreshed timestamp ── */
|
||||||
|
.root :global(.monitor-last-refreshed) {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-accent-primary, #4da3ff);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
opacity: 0.85;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|||||||
@@ -195,6 +195,12 @@ export default function MonitorPanel({
|
|||||||
const cancelledRef = useRef(false);
|
const cancelledRef = useRef(false);
|
||||||
const globalFetchingRef = useRef(false);
|
const globalFetchingRef = useRef(false);
|
||||||
|
|
||||||
|
/* Flash state: tracks cities whose temperature just changed */
|
||||||
|
const [flashingKeys, setFlashingKeys] = useState<ReadonlySet<string>>(new Set());
|
||||||
|
const [lastRefreshed, setLastRefreshed] = useState<string | null>(null);
|
||||||
|
const prevTempsRef = useRef<Partial<Record<MonitorKey, number>>>({});
|
||||||
|
const flashTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTime(new Date().toLocaleTimeString());
|
setTime(new Date().toLocaleTimeString());
|
||||||
const timer = setInterval(() => setTime(new Date().toLocaleTimeString()), 30_000);
|
const timer = setInterval(() => setTime(new Date().toLocaleTimeString()), 30_000);
|
||||||
@@ -206,6 +212,30 @@ export default function MonitorPanel({
|
|||||||
return localStorage.getItem("monitor_notify") !== "off";
|
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 = detail?.airport_current?.temp ?? detail?.current?.temp ?? null;
|
||||||
|
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 */
|
/* Per-city fetch with loading-key tracking */
|
||||||
const fetchCity = useCallback(
|
const fetchCity = useCallback(
|
||||||
async (key: MonitorKey, force: boolean) => {
|
async (key: MonitorKey, force: boolean) => {
|
||||||
@@ -251,6 +281,9 @@ export default function MonitorPanel({
|
|||||||
await Promise.allSettled(workers);
|
await Promise.allSettled(workers);
|
||||||
|
|
||||||
globalFetchingRef.current = false;
|
globalFetchingRef.current = false;
|
||||||
|
setLastRefreshed(
|
||||||
|
new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[fetchCity],
|
[fetchCity],
|
||||||
);
|
);
|
||||||
@@ -355,6 +388,11 @@ export default function MonitorPanel({
|
|||||||
>
|
>
|
||||||
{notify ? "🔔" : "🔕"}
|
{notify ? "🔔" : "🔕"}
|
||||||
</button>
|
</button>
|
||||||
|
{lastRefreshed && (
|
||||||
|
<span className="monitor-last-refreshed" title={isEn ? "Last data refresh" : "上次数据刷新"}>
|
||||||
|
{isEn ? `↻ ${lastRefreshed}` : `↻ ${lastRefreshed}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="monitor-time">{time}</span>
|
<span className="monitor-time">{time}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -398,7 +436,7 @@ export default function MonitorPanel({
|
|||||||
const tr = trendClass(detail);
|
const tr = trendClass(detail);
|
||||||
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 ?? [];
|
||||||
|
const isFlashing = flashingKeys.has(key);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -419,7 +457,7 @@ export default function MonitorPanel({
|
|||||||
{isRefreshing && <div className="monitor-refresh-bar" />}
|
{isRefreshing && <div className="monitor-refresh-bar" />}
|
||||||
|
|
||||||
{/* Card header */}
|
{/* Card header */}
|
||||||
<div className="monitor-card-head">
|
<div className={`monitor-card-head${isFlashing ? " flashed" : ""}`}>
|
||||||
<span className="monitor-city-name">{detail.display_name || key}</span>
|
<span className="monitor-city-name">{detail.display_name || key}</span>
|
||||||
<span className="monitor-airport-name">/ {airportLabel(key, isEn)}</span>
|
<span className="monitor-airport-name">/ {airportLabel(key, isEn)}</span>
|
||||||
<FreshnessDot
|
<FreshnessDot
|
||||||
@@ -438,7 +476,7 @@ export default function MonitorPanel({
|
|||||||
<div className="monitor-temp-display">
|
<div className="monitor-temp-display">
|
||||||
{cur != null ? (
|
{cur != null ? (
|
||||||
<>
|
<>
|
||||||
<span className={`monitor-temp-value${newHigh ? " new-high" : warm ? " warm" : ""}`}>
|
<span className={`monitor-temp-value${newHigh ? " new-high" : warm ? " warm" : ""}${isFlashing ? " flashed" : ""}`}>
|
||||||
{cur.toFixed(1)}
|
{cur.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
<span className="monitor-temp-unit">{tempSymbol}</span>
|
<span className="monitor-temp-unit">{tempSymbol}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user