feat: add monitor panel component with consolidated CSS root styles
This commit is contained in:
@@ -90,6 +90,27 @@
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 400ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--transition: var(--transition-base);
|
||||
|
||||
/* ── Legacy Variable Aliases (bridge for older CSS modules) ── */
|
||||
/* These map old shorthand names → current design token names. */
|
||||
--accent-cyan: var(--color-accent-primary);
|
||||
--accent-blue: var(--color-accent-secondary);
|
||||
--accent-green: var(--color-signal-success);
|
||||
--bg-primary: var(--color-bg-base);
|
||||
--bg-secondary: var(--color-bg-raised);
|
||||
--bg-card: var(--color-bg-card);
|
||||
--bg-glass: var(--color-bg-card);
|
||||
--border-glass: var(--color-border-default);
|
||||
--border-subtle: var(--color-border-subtle);
|
||||
--text-primary: var(--color-text-primary);
|
||||
--text-secondary: var(--color-text-secondary);
|
||||
--text-muted: var(--color-text-muted);
|
||||
--risk-high: var(--color-risk-high);
|
||||
--risk-medium: var(--color-risk-medium);
|
||||
--risk-low: var(--color-risk-low);
|
||||
--shadow-lg: var(--shadow-elevation-2);
|
||||
--glass-blur: 10px;
|
||||
|
||||
/* ── shadcn/ui Tokens (used by Tailwind @apply border-border) ── */
|
||||
--background: 223 53% 4%;
|
||||
@@ -139,6 +160,20 @@
|
||||
--glass-opacity-1: 0.86;
|
||||
--glass-opacity-2: 0.92;
|
||||
--glass-opacity-3: 0.96;
|
||||
|
||||
/* ── Legacy alias overrides for light theme ── */
|
||||
--accent-cyan: var(--color-accent-primary);
|
||||
--accent-blue: var(--color-accent-secondary);
|
||||
--bg-primary: var(--color-bg-base);
|
||||
--bg-secondary: var(--color-bg-raised);
|
||||
--bg-card: var(--color-bg-card);
|
||||
--bg-glass: var(--color-bg-card);
|
||||
--border-glass: var(--color-border-default);
|
||||
--border-subtle: var(--color-border-subtle);
|
||||
--text-primary: var(--color-text-primary);
|
||||
--text-secondary: var(--color-text-secondary);
|
||||
--text-muted: var(--color-text-muted);
|
||||
--shadow-lg: var(--shadow-elevation-2);
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
MONITOR PANEL — Market Monitor board
|
||||
All colours reference design tokens so dark/light theme works.
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.root :global(.monitor-panel) {
|
||||
background: var(--color-bg-base);
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 20px 24px;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.root :global(.monitor-header) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.monitor-title) {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.root :global(.monitor-controls) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.monitor-loading-indicator) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.root :global(.monitor-loading-dot) {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent-primary);
|
||||
animation: monitorPulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.root :global(.monitor-notify-btn) {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.root :global(.monitor-notify-btn:hover) {
|
||||
border-color: var(--color-border-hover);
|
||||
background: var(--color-bg-raised);
|
||||
}
|
||||
|
||||
.root :global(.monitor-notify-btn.muted) {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.root :global(.monitor-time) {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Grid ── */
|
||||
.root :global(.monitor-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* ── Skeleton card ── */
|
||||
.root :global(.monitor-skeleton-card) {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
min-height: 148px;
|
||||
animation: monitorShimmer 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.root :global(.monitor-skeleton-line) {
|
||||
background: var(--color-border-default);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── City card ── */
|
||||
.root :global(.monitor-card) {
|
||||
background: var(--color-bg-raised);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
transition: border-color var(--transition-base);
|
||||
}
|
||||
|
||||
.root :global(.monitor-card.new-high) {
|
||||
border-color: rgba(124, 58, 237, 0.3);
|
||||
}
|
||||
|
||||
/* ── Card header ── */
|
||||
.root :global(.monitor-card-head) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.root :global(.monitor-city-name) {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.root :global(.monitor-airport-name) {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.root :global(.monitor-obs-time) {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.root :global(.monitor-new-high-badge) {
|
||||
font-size: 12px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(124, 58, 237, 0.18);
|
||||
color: #a78bfa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Temperature display ── */
|
||||
.root :global(.monitor-temp-display) {
|
||||
margin: 8px 0 10px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.root :global(.monitor-temp-value) {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.root :global(.monitor-temp-value.new-high) {
|
||||
color: #c084fc;
|
||||
}
|
||||
|
||||
.root :global(.monitor-temp-value.warm) {
|
||||
color: var(--color-signal-warning);
|
||||
}
|
||||
|
||||
.root :global(.monitor-temp-unit) {
|
||||
font-size: 20px;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.root :global(.monitor-temp-missing) {
|
||||
font-size: 30px;
|
||||
color: var(--color-border-default);
|
||||
}
|
||||
|
||||
/* ── Stats row ── */
|
||||
.root :global(.monitor-stats) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.root :global(.monitor-high-row) {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.monitor-stat-label) {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.root :global(.monitor-high-value) {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.root :global(.monitor-high-time) {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: 2px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.root :global(.monitor-stat-missing) {
|
||||
color: var(--color-border-default);
|
||||
}
|
||||
|
||||
.root :global(.monitor-trend) {
|
||||
margin-left: auto;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.root :global(.monitor-trend.rising) {
|
||||
color: var(--color-signal-success);
|
||||
}
|
||||
|
||||
.root :global(.monitor-trend.falling) {
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.root :global(.monitor-trend.flat) {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.root :global(.monitor-obs-row) {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.root :global(.monitor-obs-age) {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Runway sub-section ── */
|
||||
.root :global(.monitor-divider) {
|
||||
height: 1px;
|
||||
background: var(--color-border-subtle);
|
||||
margin: 10px 0 8px;
|
||||
}
|
||||
|
||||
.root :global(.monitor-rw-row) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.root :global(.monitor-rw-label) {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.root :global(.monitor-rw-temp) {
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
LIGHT THEME OVERRIDES
|
||||
Scoped to .scan-terminal.light so the panel responds to the
|
||||
terminal's built-in theme toggle (not html.light).
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-panel) {
|
||||
background: #F7F9FC;
|
||||
color: #0F172A;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-header) {
|
||||
border-bottom-color: #E2E8F0;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-title) {
|
||||
color: #0F172A;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-time),
|
||||
.root :global(.scan-terminal.light .monitor-stat-label),
|
||||
.root :global(.scan-terminal.light .monitor-obs-time),
|
||||
.root :global(.scan-terminal.light .monitor-obs-age),
|
||||
.root :global(.scan-terminal.light .monitor-rw-label),
|
||||
.root :global(.scan-terminal.light .monitor-high-time),
|
||||
.root :global(.scan-terminal.light .monitor-loading-indicator) {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-card) {
|
||||
background: #FFFFFF;
|
||||
border-color: #E2E8F0;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-card.new-high) {
|
||||
border-color: rgba(124, 58, 237, 0.24);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-skeleton-card) {
|
||||
background: #FFFFFF;
|
||||
border-color: #E2E8F0;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-skeleton-line) {
|
||||
background: #E2E8F0;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-city-name) {
|
||||
color: #0F172A;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-airport-name),
|
||||
.root :global(.scan-terminal.light .monitor-high-value),
|
||||
.root :global(.scan-terminal.light .monitor-rw-temp) {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-temp-value) {
|
||||
color: #0F172A;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-temp-unit),
|
||||
.root :global(.scan-terminal.light .monitor-temp-missing),
|
||||
.root :global(.scan-terminal.light .monitor-stat-missing) {
|
||||
color: #94A3B8;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-divider) {
|
||||
background: #E2E8F0;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-notify-btn) {
|
||||
border-color: rgba(148, 163, 184, 0.3);
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-notify-btn:hover) {
|
||||
border-color: rgba(37, 99, 235, 0.38);
|
||||
background: #EEF2F7;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-trend.rising) {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .monitor-trend.falling) {
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
/* ── Animations ── */
|
||||
@keyframes monitorPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
@keyframes monitorShimmer {
|
||||
0%, 100% { opacity: 0.55; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
@@ -11,13 +11,8 @@ const MONITOR_KEYS = [
|
||||
"miami", "san francisco", "houston", "dallas", "austin", "seattle",
|
||||
] as const;
|
||||
|
||||
/** Max concurrent city-detail requests while loading the monitor panel. */
|
||||
const MONITOR_FETCH_CONCURRENCY = 6;
|
||||
|
||||
/** Minimum elapsed time (ms) before triggering another auto-refresh cycle. */
|
||||
const MONITOR_REFRESH_INTERVAL_MS = 60_000;
|
||||
|
||||
/** How long (ms) fresh data is considered valid — avoids re-fetch on tab switch. */
|
||||
const MONITOR_FRESHNESS_TTL_MS = 45_000;
|
||||
|
||||
type MonitorCity = {
|
||||
@@ -25,24 +20,20 @@ type MonitorCity = {
|
||||
detail: CityDetail | undefined;
|
||||
};
|
||||
|
||||
function fmt(v: number | null | undefined): string {
|
||||
return v != null ? v.toFixed(1) : "--";
|
||||
}
|
||||
|
||||
function trendIcon(detail: CityDetail | undefined): { s: string; c: string } {
|
||||
if (!detail?.airport_current) return { s: "→", c: "flat" };
|
||||
function trendClass(detail: CityDetail | undefined): "rising" | "falling" | "flat" {
|
||||
if (!detail?.airport_current) return "flat";
|
||||
const ac = detail.airport_current;
|
||||
const cur = ac.temp ?? detail.current?.temp ?? null;
|
||||
const max = ac.max_so_far ?? null;
|
||||
if (cur != null && max != null && cur >= max + 0.3) return { s: "↑", c: "rising" };
|
||||
if (cur != null && max != null && cur < max - 1.0) return { s: "↓", c: "falling" };
|
||||
return { s: "→", c: "flat" };
|
||||
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(t: "rising" | "falling" | "flat"): string {
|
||||
return t === "rising" ? "↑" : t === "falling" ? "↓" : "→";
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an array of async tasks with capped concurrency.
|
||||
* Resolves when every task has settled (success or error).
|
||||
*/
|
||||
async function runConcurrent<T>(
|
||||
tasks: Array<() => Promise<T>>,
|
||||
concurrency: number,
|
||||
@@ -54,36 +45,37 @@ async function runConcurrent<T>(
|
||||
if (cancelled()) return;
|
||||
const task = queue.shift();
|
||||
if (!task) break;
|
||||
try {
|
||||
await task();
|
||||
} catch {
|
||||
// Ignore individual city failures; the panel shows "--" gracefully.
|
||||
}
|
||||
try { await task(); } catch { /* individual city failures show "--" */ }
|
||||
}
|
||||
});
|
||||
await Promise.allSettled(workers);
|
||||
}
|
||||
|
||||
// Module-level timestamp — survives tab switches so we don't re-fetch stale data.
|
||||
let lastRefreshCompletedAt = 0;
|
||||
|
||||
function SkeletonCard() {
|
||||
return (
|
||||
<div style={{
|
||||
background: "#161822", border: "1px solid #1e2130",
|
||||
borderRadius: 12, padding: "20px 24px", minHeight: 148,
|
||||
}}>
|
||||
<div style={{ display: "flex", gap: 10, marginBottom: 14, alignItems: "center" }}>
|
||||
<div style={{ height: 14, width: "45%", background: "#1e2534", borderRadius: 4 }} />
|
||||
<div style={{ height: 12, width: "25%", background: "#1a1f2b", borderRadius: 4 }} />
|
||||
</div>
|
||||
<div style={{ height: 52, width: "55%", background: "#1e2534", borderRadius: 6, marginBottom: 16 }} />
|
||||
<div style={{ height: 12, width: "70%", background: "#1a1f2b", borderRadius: 4, marginBottom: 8 }} />
|
||||
<div style={{ height: 12, width: "40%", background: "#1a1f2b", borderRadius: 4 }} />
|
||||
<div className="monitor-skeleton-card">
|
||||
<div className="monitor-skeleton-line" style={{ height: 14, width: "45%", marginBottom: 14 }} />
|
||||
<div className="monitor-skeleton-line" style={{ height: 52, width: "55%", marginBottom: 16 }} />
|
||||
<div className="monitor-skeleton-line" style={{ height: 12, width: "70%" }} />
|
||||
<div className="monitor-skeleton-line" style={{ height: 12, width: "40%", marginTop: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AIRPORT_NAMES: Record<string, string> = {
|
||||
seoul: "Incheon", busan: "Gimhae", tokyo: "Haneda",
|
||||
ankara: "Esenboğa", helsinki: "Vantaa", amsterdam: "Schiphol",
|
||||
istanbul: "Airport", paris: "Le Bourget",
|
||||
"hong kong": "Observatory", "lau fau shan": "Lau Fau Shan",
|
||||
taipei: "Songshan", "new york": "LaGuardia",
|
||||
"los angeles": "LAX", chicago: "O'Hare", denver: "Buckley",
|
||||
atlanta: "Hartsfield", miami: "MIA", "san francisco": "SFO",
|
||||
houston: "Hobby", dallas: "Love Field", austin: "Bergstrom",
|
||||
seattle: "SeaTac",
|
||||
};
|
||||
|
||||
export default function MonitorPanel() {
|
||||
const store = useDashboardStore();
|
||||
const details = store.cityDetailsByName;
|
||||
@@ -103,12 +95,9 @@ export default function MonitorPanel() {
|
||||
return localStorage.getItem("monitor_notify") !== "off";
|
||||
});
|
||||
|
||||
/** Fetch all MONITOR_KEYS cities concurrently in batches. */
|
||||
const refreshAll = useCallback(
|
||||
async (force: boolean) => {
|
||||
if (fetchingRef.current) return;
|
||||
|
||||
// Skip if data is still fresh (e.g. after a tab switch).
|
||||
if (
|
||||
!force &&
|
||||
lastRefreshCompletedAt > 0 &&
|
||||
@@ -117,55 +106,49 @@ export default function MonitorPanel() {
|
||||
setInitialLoadDone(true);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchingRef.current = true;
|
||||
|
||||
const tasks = MONITOR_KEYS.map((k) => () =>
|
||||
store.ensureCityDetail(k, force, "panel"),
|
||||
);
|
||||
|
||||
await runConcurrent(tasks, MONITOR_FETCH_CONCURRENCY, () => cancelledRef.current);
|
||||
|
||||
if (!cancelledRef.current) {
|
||||
lastRefreshCompletedAt = Date.now();
|
||||
setInitialLoadDone(true);
|
||||
}
|
||||
|
||||
fetchingRef.current = false;
|
||||
},
|
||||
[store.ensureCityDetail],
|
||||
);
|
||||
|
||||
// Initial load (non-force, honours freshness TTL) + periodic auto-refresh.
|
||||
useEffect(() => {
|
||||
cancelledRef.current = false;
|
||||
void refreshAll(false);
|
||||
|
||||
const t = setInterval(() => {
|
||||
if (!document.hidden) void refreshAll(true);
|
||||
}, MONITOR_REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [refreshAll]);
|
||||
|
||||
const cities: MonitorCity[] = useMemo(() => {
|
||||
return MONITOR_KEYS.map((k) => ({ key: k, detail: details[k] }));
|
||||
}, [details]);
|
||||
const cities: MonitorCity[] = useMemo(
|
||||
() => MONITOR_KEYS.map((k) => ({ key: k, detail: details[k] })),
|
||||
[details],
|
||||
);
|
||||
|
||||
// Sort by current temp descending.
|
||||
const sorted = useMemo(() => {
|
||||
return [...cities].sort((a, b) => {
|
||||
const ta = a.detail?.airport_current?.temp ?? a.detail?.current?.temp ?? null;
|
||||
const tb = b.detail?.airport_current?.temp ?? b.detail?.current?.temp ?? null;
|
||||
if (ta == null && tb == null) return 0;
|
||||
if (ta == null) return 1;
|
||||
if (tb == null) return -1;
|
||||
return tb - ta;
|
||||
});
|
||||
}, [cities]);
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...cities].sort((a, b) => {
|
||||
const ta = a.detail?.airport_current?.temp ?? a.detail?.current?.temp ?? null;
|
||||
const tb = b.detail?.airport_current?.temp ?? b.detail?.current?.temp ?? null;
|
||||
if (ta == null && tb == null) return 0;
|
||||
if (ta == null) return 1;
|
||||
if (tb == null) return -1;
|
||||
return tb - ta;
|
||||
}),
|
||||
[cities],
|
||||
);
|
||||
|
||||
const toggleNotify = () => {
|
||||
const next = !notify;
|
||||
@@ -176,27 +159,25 @@ export default function MonitorPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
// Check for new highs and fire browser notifications.
|
||||
useEffect(() => {
|
||||
if (!notify || typeof Notification === "undefined" || Notification.permission !== "granted") return;
|
||||
const today = new Date().toDateString();
|
||||
let notified: Record<string, unknown> = {};
|
||||
try { notified = JSON.parse(localStorage.getItem("monitor_notified_highs") || "{}"); } catch {}
|
||||
if (notified._day !== today) notified = { _day: today };
|
||||
|
||||
for (const c of sorted) {
|
||||
const ac = c.detail?.airport_current;
|
||||
const cur = ac?.temp ?? c.detail?.current?.temp ?? null;
|
||||
const max = ac?.max_so_far ?? null;
|
||||
if (cur != null && max != null && cur >= max + 0.3) {
|
||||
const key = `${c.key}|${cur}`;
|
||||
const today = new Date().toDateString();
|
||||
let notified: Record<string, unknown> = {};
|
||||
try {
|
||||
notified = JSON.parse(localStorage.getItem("monitor_notified_highs") || "{}");
|
||||
} catch {}
|
||||
if (notified._day !== today) notified = { _day: today };
|
||||
if (!notified[key]) {
|
||||
notified[key] = true;
|
||||
localStorage.setItem("monitor_notified_highs", JSON.stringify(notified));
|
||||
const name = c.detail?.display_name || c.key;
|
||||
new Notification(`🔴 New High — ${name}`, {
|
||||
body: `${cur}°C\nNew daily high.`,
|
||||
body: `${cur}°C · New daily high.`,
|
||||
tag: key,
|
||||
requireInteraction: true,
|
||||
});
|
||||
@@ -205,67 +186,29 @@ export default function MonitorPanel() {
|
||||
}
|
||||
}, [sorted, notify]);
|
||||
|
||||
const airportName = (key: string): string => {
|
||||
const m: Record<string, string> = {
|
||||
seoul: "Incheon", busan: "Gimhae", tokyo: "Haneda",
|
||||
ankara: "Esenboğa", helsinki: "Vantaa", amsterdam: "Schiphol",
|
||||
istanbul: "Airport", paris: "Le Bourget",
|
||||
"hong kong": "Observatory", "lau fau shan": "Lau Fau Shan",
|
||||
taipei: "Songshan",
|
||||
"new york": "LaGuardia", "los angeles": "LAX", chicago: "O'Hare",
|
||||
denver: "Buckley", atlanta: "Hartsfield", miami: "MIA",
|
||||
"san francisco": "SFO", houston: "Hobby", dallas: "Love Field",
|
||||
austin: "Bergstrom", seattle: "SeaTac",
|
||||
};
|
||||
return m[key] || "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ background: "#0f1117", height: "100%", overflow: "auto", padding: "20px 24px" }}>
|
||||
<div style={{
|
||||
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
||||
marginBottom: 24, paddingBottom: 12, borderBottom: "1px solid #1e2130",
|
||||
}}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 600, color: "#e8eaed" }}>🔥 市场监控</h2>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div className="monitor-panel">
|
||||
<div className="monitor-header">
|
||||
<h2 className="monitor-title">🔥 市场监控</h2>
|
||||
<div className="monitor-controls">
|
||||
{!initialLoadDone && (
|
||||
<span style={{ fontSize: 12, color: "#5a6170", display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span style={{
|
||||
display: "inline-block", width: 8, height: 8, borderRadius: "50%",
|
||||
background: "#3b82f6", animation: "monitor-pulse 1.2s ease-in-out infinite",
|
||||
}} />
|
||||
<span className="monitor-loading-indicator">
|
||||
<span className="monitor-loading-dot" />
|
||||
加载中…
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className={`monitor-notify-btn${notify ? "" : " muted"}`}
|
||||
onClick={toggleNotify}
|
||||
style={{
|
||||
background: "none", border: "1px solid #2a2e40", borderRadius: 6,
|
||||
padding: "2px 8px", fontSize: 16, cursor: "pointer", opacity: notify ? 1 : 0.4,
|
||||
}}
|
||||
title={notify ? "关闭价格提醒" : "开启价格提醒"}
|
||||
>
|
||||
{notify ? "🔔" : "🔕"}
|
||||
</button>
|
||||
<span style={{ fontSize: 13, color: "#5a6170" }}>{time}</span>
|
||||
<span className="monitor-time">{time}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes monitor-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
@keyframes monitor-shimmer {
|
||||
0% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0.5; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))",
|
||||
gap: 14,
|
||||
}}>
|
||||
<div className="monitor-grid">
|
||||
{!initialLoadDone
|
||||
? MONITOR_KEYS.map((k) => <SkeletonCard key={k} />)
|
||||
: sorted.map((c) => {
|
||||
@@ -276,83 +219,77 @@ export default function MonitorPanel() {
|
||||
const obs = ac?.obs_time ?? c.detail?.local_time ?? "";
|
||||
const age = ac?.obs_age_min ?? null;
|
||||
const newHigh = cur != null && max != null && cur >= max + 0.3;
|
||||
const warm = cur != null && cur >= 30;
|
||||
const tr = trendIcon(c.detail);
|
||||
const rw = c.detail?.amos?.runway_obs;
|
||||
const rwPairs = rw?.runway_pairs || [];
|
||||
const rwTemps = rw?.temperatures || [];
|
||||
const warm = !newHigh && cur != null && cur >= 30;
|
||||
const tr = trendClass(c.detail);
|
||||
const rwPairs = c.detail?.amos?.runway_obs?.runway_pairs ?? [];
|
||||
const rwTemps = c.detail?.amos?.runway_obs?.temperatures ?? [];
|
||||
|
||||
return (
|
||||
<div key={c.key} style={{
|
||||
background: "#161822", border: `1px solid ${newHigh ? "rgba(124,58,237,0.3)" : "#1e2130"}`,
|
||||
borderRadius: 12, padding: "20px 24px",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14, fontSize: 15, flexWrap: "wrap" }}>
|
||||
<span style={{ color: "#e0e3e8", fontWeight: 700 }}>{c.detail?.display_name || c.key}</span>
|
||||
<span style={{ color: "#6a7180" }}>/ {airportName(c.key)}</span>
|
||||
<span style={{ marginLeft: "auto", color: "#4a5160", fontSize: 14 }}>{obs}</span>
|
||||
<div
|
||||
key={c.key}
|
||||
className={`monitor-card${newHigh ? " new-high" : ""}`}
|
||||
>
|
||||
<div className="monitor-card-head">
|
||||
<span className="monitor-city-name">
|
||||
{c.detail?.display_name || c.key}
|
||||
</span>
|
||||
<span className="monitor-airport-name">/ {AIRPORT_NAMES[c.key]}</span>
|
||||
<span className="monitor-obs-time">{obs}</span>
|
||||
{newHigh && (
|
||||
<span style={{ fontSize: 12, padding: "1px 6px", borderRadius: 4,
|
||||
background: "rgba(124,58,237,.18)", color: "#a78bfa", fontWeight: 600 }}>
|
||||
◆新高
|
||||
</span>
|
||||
<span className="monitor-new-high-badge">◆新高</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ margin: "8px 0 10px", lineHeight: 1.15 }}>
|
||||
<div className="monitor-temp-display">
|
||||
{cur != null ? (
|
||||
<>
|
||||
<span style={{ fontSize: 48, fontWeight: 700, letterSpacing: "-.03em",
|
||||
color: newHigh ? "#c084fc" : warm ? "#f59e0b" : "#e8eaed" }}>
|
||||
<span className={`monitor-temp-value${newHigh ? " new-high" : warm ? " warm" : ""}`}>
|
||||
{cur.toFixed(1)}
|
||||
</span>
|
||||
<span style={{ fontSize: 20, color: "#5a6170", marginLeft: 3 }}>°C</span>
|
||||
<span className="monitor-temp-unit">°C</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ fontSize: 30, color: "#3a4050" }}>--</span>
|
||||
<span className="monitor-temp-missing">--</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 14 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 8 }}>
|
||||
<span style={{ color: "#4a5160" }}>High</span>
|
||||
<div className="monitor-stats">
|
||||
<div className="monitor-high-row">
|
||||
<span className="monitor-stat-label">High</span>
|
||||
{max != null ? (
|
||||
<>
|
||||
<span style={{ color: "#9aa0b0" }}>{max.toFixed(1)}°C</span>
|
||||
{mtt && <span style={{ fontSize: 12, color: "#4a5160", marginLeft: 2 }}>{mtt}</span>}
|
||||
<span className="monitor-high-value">{max.toFixed(1)}°C</span>
|
||||
{mtt && <span className="monitor-high-time">{mtt}</span>}
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: "#3a4050" }}>--</span>
|
||||
<span className="monitor-stat-missing">--</span>
|
||||
)}
|
||||
<span style={{ marginLeft: "auto", fontSize: 18, fontWeight: 700,
|
||||
color: tr.c === "rising" ? "#34d399" : tr.c === "falling" ? "#60a5fa" : "#5a6170" }}>
|
||||
{tr.s}
|
||||
</span>
|
||||
<span className={`monitor-trend ${tr}`}>{trendSymbol(tr)}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
|
||||
<span style={{ color: "#4a5160" }}>Obs</span>
|
||||
<div className="monitor-obs-row">
|
||||
<span className="monitor-stat-label">Obs</span>
|
||||
{age != null ? (
|
||||
<span style={{ color: "#5a6170", fontSize: 13 }}>{age} min ago</span>
|
||||
<span className="monitor-obs-age">{age} min ago</span>
|
||||
) : (
|
||||
<span style={{ color: "#3a4050" }}>--</span>
|
||||
<span className="monitor-stat-missing">--</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rwPairs.length > 0 && rwTemps.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ height: 1, background: "#1e2130", marginBottom: 8 }} />
|
||||
<>
|
||||
<div className="monitor-divider" />
|
||||
{rwPairs.map((p, i) => {
|
||||
const t = rwTemps[i]?.[0];
|
||||
if (t == null) return null;
|
||||
return (
|
||||
<div key={i} style={{ display: "flex", justifyContent: "space-between", fontSize: 13, marginBottom: 2 }}>
|
||||
<span style={{ color: "#4a5160" }}>{p[0]}/{p[1]}</span>
|
||||
<span style={{ color: "#7a8290" }}>{t.toFixed(1)}°C</span>
|
||||
<div key={i} className="monitor-rw-row">
|
||||
<span className="monitor-rw-label">{p[0]}/{p[1]}</span>
|
||||
<span className="monitor-rw-temp">{t.toFixed(1)}°C</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ import scanTerminalMobileStyles from "./ScanTerminalMobile.module.css";
|
||||
import scanTerminalOpportunityStyles from "./ScanTerminalOpportunity.module.css";
|
||||
import scanTerminalShellStyles from "./ScanTerminalShell.module.css";
|
||||
import scanTerminalStateStyles from "./ScanTerminalState.module.css";
|
||||
import monitorPanelStyles from "./monitoring/MonitorPanel.module.css";
|
||||
|
||||
export const scanRootClass = clsx(
|
||||
dashboardHomeStyles.root,
|
||||
@@ -48,4 +49,5 @@ export const scanRootClass = clsx(
|
||||
modalChromeStyles.root,
|
||||
futureForecastModalStyles.root,
|
||||
historyModalStyles.root,
|
||||
monitorPanelStyles.root,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user