feat: implement PolyWeather dashboard layout with integrated map, weather visualization, and detail panels

This commit is contained in:
2569718930@qq.com
2026-04-23 22:35:34 +08:00
parent e2eb5eb429
commit 79a4333aaf
10 changed files with 1029 additions and 773 deletions
+19 -19
View File
@@ -21,16 +21,16 @@
--color-text-muted: #64748b; --color-text-muted: #64748b;
--color-text-disabled: #475569; --color-text-disabled: #475569;
/* ── Accent Colors ── */ /* ── Accent Colors (Fintech 3-Color) ── */
--color-accent-primary: #00e5ff; --color-accent-primary: #00e0a4;
--color-accent-secondary: #6366f1; --color-accent-secondary: #7b61ff;
--color-accent-tertiary: #a78bfa; --color-accent-tertiary: #a78bfa;
/* ── Signal / Semantic Colors ── */ /* ── Signal / Semantic Colors ── */
--color-signal-success: #22c55e; --color-signal-success: #00e0a4;
--color-signal-warning: #f59e0b; --color-signal-warning: #ffb020;
--color-signal-danger: #ef4444; --color-signal-danger: #ff4d6a;
--color-signal-info: #3b82f6; --color-signal-info: #7b61ff;
/* ── Risk Colors (aliased from signal) ── */ /* ── Risk Colors (aliased from signal) ── */
--color-risk-high: var(--color-signal-danger); --color-risk-high: var(--color-signal-danger);
@@ -38,16 +38,16 @@
--color-risk-low: var(--color-signal-success); --color-risk-low: var(--color-signal-success);
/* ── Border ── */ /* ── Border ── */
--color-border-default: rgba(99, 102, 241, 0.12); --color-border-default: rgba(123, 97, 255, 0.12);
--color-border-hover: rgba(99, 102, 241, 0.28); --color-border-hover: rgba(123, 97, 255, 0.28);
--color-border-subtle: rgba(255, 255, 255, 0.06); --color-border-subtle: rgba(255, 255, 255, 0.06);
/* ── Shadow / Elevation ── */ /* ── Shadow / Elevation ── */
--shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.3); --shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-elevation-2: 0 8px 24px rgba(0, 0, 0, 0.45); --shadow-elevation-2: 0 8px 24px rgba(0, 0, 0, 0.45);
--shadow-elevation-3: 0 20px 60px rgba(0, 0, 0, 0.6); --shadow-elevation-3: 0 20px 60px rgba(0, 0, 0, 0.6);
--shadow-glow-accent: 0 0 20px rgba(0, 229, 255, 0.2); --shadow-glow-accent: 0 0 20px rgba(0, 224, 164, 0.25);
--shadow-glow-secondary: 0 0 20px rgba(99, 102, 241, 0.25); --shadow-glow-secondary: 0 0 20px rgba(123, 97, 255, 0.25);
/* ── Typography ── */ /* ── Typography ── */
--font-data: --font-data:
@@ -96,7 +96,7 @@
--foreground: 210 40% 98%; --foreground: 210 40% 98%;
--card: 223 46% 8%; --card: 223 46% 8%;
--card-foreground: 210 40% 98%; --card-foreground: 210 40% 98%;
--primary: 187 100% 50%; --primary: 159 100% 44%;
--primary-foreground: 222 47% 8%; --primary-foreground: 222 47% 8%;
--secondary: 224 30% 14%; --secondary: 224 30% 14%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 210 40% 98%;
@@ -114,17 +114,17 @@
background: background:
radial-gradient( radial-gradient(
circle at 10% -10%, circle at 10% -10%,
rgba(0, 229, 255, 0.12), rgba(0, 224, 164, 0.1),
transparent 40% transparent 40%
), ),
radial-gradient( radial-gradient(
circle at 90% 0%, circle at 90% 0%,
rgba(99, 102, 241, 0.1), rgba(123, 97, 255, 0.08),
transparent 36% transparent 36%
), ),
radial-gradient( radial-gradient(
circle at 80% 100%, circle at 80% 100%,
rgba(8, 47, 73, 0.4), rgba(0, 224, 164, 0.06),
transparent 48% transparent 48%
), ),
var(--color-bg-base); var(--color-bg-base);
@@ -188,7 +188,7 @@
.map-pill.active { .map-pill.active {
transform: translateY(-2px) scale(1.05); transform: translateY(-2px) scale(1.05);
box-shadow: box-shadow:
0 8px 24px rgba(0, 229, 255, 0.35), 0 8px 24px rgba(0, 224, 164, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.35); inset 0 1px 0 rgba(255, 255, 255, 0.35);
} }
@@ -354,11 +354,11 @@
width: 6px; width: 6px;
} }
.custom-scrollbar::-webkit-scrollbar-thumb { .custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.2); background: rgba(123, 97, 255, 0.2);
border-radius: var(--radius-full); border-radius: var(--radius-full);
} }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.35); background: rgba(123, 97, 255, 0.35);
} }
} }
@@ -368,6 +368,6 @@
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
} }
50% { 50% {
box-shadow: 0 10px 26px rgba(0, 229, 255, 0.3); box-shadow: 0 10px 26px rgba(0, 224, 164, 0.3);
} }
} }
+112 -101
View File
@@ -16,14 +16,14 @@
--text-secondary: var(--color-text-secondary); --text-secondary: var(--color-text-secondary);
--text-muted: var(--color-text-muted); --text-muted: var(--color-text-muted);
/* Accents — upgraded primary cyan to match new token */ /* Accents — Fintech 3-Color Model */
--accent-cyan: #22d3ee; --accent-cyan: #00e0a4;
--accent-blue: #3b82f6; --accent-blue: #7b61ff;
--accent-green: #34d399; --accent-green: #00e0a4;
--accent-orange: #f59e0b; --accent-orange: #ffb020;
--accent-red: #fb7185; --accent-red: #ff4d6a;
--accent-yellow: #fbbf24; --accent-yellow: #ffb020;
--accent-purple: #7c3aed; --accent-purple: #7b61ff;
/* Risk colors */ /* Risk colors */
--risk-high: var(--color-risk-high); --risk-high: var(--color-risk-high);
@@ -64,10 +64,14 @@
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
background-image: background-image:
radial-gradient(circle at 18% 18%, rgba(30, 64, 175, 0.1), transparent 24%), radial-gradient(
circle at 18% 18%,
rgba(0, 224, 164, 0.08),
transparent 24%
),
radial-gradient( radial-gradient(
circle at 82% 12%, circle at 82% 12%,
rgba(8, 145, 178, 0.08), rgba(123, 97, 255, 0.06),
transparent 20% transparent 20%
), ),
linear-gradient(180deg, #040914 0%, #02050d 100%); linear-gradient(180deg, #040914 0%, #02050d 100%);
@@ -86,8 +90,16 @@
background: #edf5ff; background: #edf5ff;
color: #0b1726; color: #0b1726;
background-image: background-image:
radial-gradient(circle at 18% 18%, rgba(14, 165, 233, 0.16), transparent 25%), radial-gradient(
radial-gradient(circle at 82% 12%, rgba(59, 130, 246, 0.12), transparent 22%), circle at 18% 18%,
rgba(0, 224, 164, 0.12),
transparent 25%
),
radial-gradient(
circle at 82% 12%,
rgba(123, 97, 255, 0.1),
transparent 22%
),
linear-gradient(180deg, #f8fbff 0%, #e9f3ff 100%); linear-gradient(180deg, #f8fbff 0%, #e9f3ff 100%);
} }
@@ -124,7 +136,7 @@
box-shadow: box-shadow:
0 32px 100px rgba(0, 0, 0, 0.5), 0 32px 100px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.04),
inset 0 0 0 1px rgba(34, 211, 238, 0.04); inset 0 0 0 1px rgba(0, 224, 164, 0.04);
} }
.weatherAura { .weatherAura {
@@ -158,8 +170,8 @@
), ),
radial-gradient( radial-gradient(
circle at 50% 60%, circle at 50% 60%,
rgba(34, 211, 238, 0.08) 0%, rgba(0, 224, 164, 0.08) 0%,
rgba(92, 142, 255, 0) 48% rgba(123, 97, 255, 0) 48%
); );
} }
@@ -246,7 +258,7 @@
height: 30px; height: 30px;
flex-shrink: 0; flex-shrink: 0;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(34, 211, 238, 0.22); border: 1px solid rgba(0, 224, 164, 0.22);
background: rgba(8, 15, 28, 0.78); background: rgba(8, 15, 28, 0.78);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -254,7 +266,7 @@
overflow: hidden; overflow: hidden;
box-shadow: box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.08), inset 0 0 0 1px rgba(148, 163, 184, 0.08),
0 0 20px rgba(34, 211, 238, 0.12); 0 0 20px rgba(0, 224, 164, 0.12);
} }
.root :global(.brand-mark img) { .root :global(.brand-mark img) {
@@ -368,8 +380,8 @@
.root :global(.lang-btn.active) { .root :global(.lang-btn.active) {
color: var(--text-primary); color: var(--text-primary);
background: rgba(0, 229, 255, 0.12); background: rgba(0, 224, 164, 0.12);
box-shadow: inset 0 0 0 1px rgba(0, 229, 255, 0.2); box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.2);
} }
.root :global(.live-badge) { .root :global(.live-badge) {
@@ -378,8 +390,8 @@
gap: 5px; gap: 5px;
padding: 4px 10px; padding: 4px 10px;
border-radius: 9999px; border-radius: 9999px;
background: rgba(34, 197, 94, 0.08); background: rgba(0, 224, 164, 0.08);
border: 1px solid rgba(34, 197, 94, 0.22); border: 1px solid rgba(0, 224, 164, 0.22);
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
color: var(--accent-green); color: var(--accent-green);
@@ -399,11 +411,11 @@
0%, 0%,
100% { 100% {
opacity: 1; opacity: 1;
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4); box-shadow: 0 0 0 0 rgba(0, 224, 164, 0.4);
} }
50% { 50% {
opacity: 0.7; opacity: 0.7;
box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); box-shadow: 0 0 0 6px rgba(0, 224, 164, 0);
} }
} }
@@ -421,8 +433,8 @@
justify-content: center; justify-content: center;
} }
.root :global(.refresh-btn:hover) { .root :global(.refresh-btn:hover) {
background: rgba(0, 229, 255, 0.1); background: rgba(0, 224, 164, 0.1);
border-color: rgba(0, 229, 255, 0.3); border-color: rgba(0, 224, 164, 0.3);
color: var(--accent-cyan); color: var(--accent-cyan);
} }
.root :global(.refresh-btn.spinning) { .root :global(.refresh-btn.spinning) {
@@ -433,7 +445,7 @@
height: 32px; height: 32px;
padding: 3px; padding: 3px;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(34, 211, 238, 0.2); border: 1px solid rgba(0, 224, 164, 0.2);
background: rgba(8, 15, 27, 0.82); background: rgba(8, 15, 27, 0.82);
color: rgba(148, 163, 184, 0.86); color: rgba(148, 163, 184, 0.86);
display: inline-flex; display: inline-flex;
@@ -456,12 +468,12 @@
.root :global(.locale-switch span.active) { .root :global(.locale-switch span.active) {
color: #f8fafc; color: #f8fafc;
background: rgba(34, 211, 238, 0.16); background: rgba(0, 224, 164, 0.16);
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.24); box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.24);
} }
.root :global(.locale-switch:hover) { .root :global(.locale-switch:hover) {
border-color: rgba(34, 211, 238, 0.34); border-color: rgba(0, 224, 164, 0.34);
background: rgba(11, 22, 40, 0.94); background: rgba(11, 22, 40, 0.94);
} }
@@ -485,13 +497,13 @@
.root :global(.header-utility-btn:hover) { .root :global(.header-utility-btn:hover) {
color: #f8fafc; color: #f8fafc;
border-color: rgba(34, 211, 238, 0.34); border-color: rgba(0, 224, 164, 0.34);
background: rgba(11, 22, 40, 0.94); background: rgba(11, 22, 40, 0.94);
} }
.root :global(.header-utility-btn.active) { .root :global(.header-utility-btn.active) {
color: #f8fafc; color: #f8fafc;
border-color: rgba(59, 130, 246, 0.34); border-color: rgba(123, 97, 255, 0.34);
} }
.root :global(.header-utility-btn.more) { .root :global(.header-utility-btn.more) {
@@ -564,14 +576,14 @@
} }
.root :global(.city-count) { .root :global(.city-count) {
background: rgba(99, 102, 241, 0.2); background: rgba(123, 97, 255, 0.2);
color: var(--accent-blue); color: var(--accent-blue);
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
padding: 2px 8px; padding: 2px 8px;
border-radius: 9999px; border-radius: 9999px;
border: 1px solid rgba(99, 102, 241, 0.15); border: 1px solid rgba(123, 97, 255, 0.15);
} }
.root :global(.city-list-items) { .root :global(.city-list-items) {
@@ -675,7 +687,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(99, 102, 241, 0.26); background: rgba(123, 97, 255, 0.26);
color: var(--text-primary); color: var(--text-primary);
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
@@ -717,17 +729,17 @@
} }
.root :global(.city-item:hover) { .root :global(.city-item:hover) {
background: rgba(10, 26, 44, 0.84); background: rgba(10, 26, 44, 0.84);
border-color: rgba(34, 211, 238, 0.18); border-color: rgba(0, 224, 164, 0.18);
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.03); box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.03);
} }
.root :global(.city-item.active) { .root :global(.city-item.active) {
background: background:
linear-gradient(135deg, rgba(34, 211, 238, 0.12), rgba(30, 64, 175, 0.1)), linear-gradient(135deg, rgba(0, 224, 164, 0.12), rgba(123, 97, 255, 0.1)),
rgba(9, 18, 32, 0.92); rgba(9, 18, 32, 0.92);
border-color: rgba(34, 211, 238, 0.24); border-color: rgba(0, 224, 164, 0.24);
box-shadow: box-shadow:
0 0 20px rgba(34, 211, 238, 0.08), 0 0 20px rgba(0, 224, 164, 0.08),
inset 0 0 0 1px rgba(34, 211, 238, 0.08); inset 0 0 0 1px rgba(0, 224, 164, 0.08);
} }
.root :global(.city-item-main) { .root :global(.city-item-main) {
@@ -1270,7 +1282,11 @@
border: 1px solid rgba(34, 211, 238, 0.14); border: 1px solid rgba(34, 211, 238, 0.14);
border-radius: 18px; border-radius: 18px;
background: background:
radial-gradient(circle at 88% 24%, rgba(245, 158, 11, 0.12), transparent 24%), radial-gradient(
circle at 88% 24%,
rgba(245, 158, 11, 0.12),
transparent 24%
),
linear-gradient(180deg, rgba(8, 16, 30, 0.62), rgba(4, 10, 22, 0.36)); linear-gradient(180deg, rgba(8, 16, 30, 0.62), rgba(4, 10, 22, 0.36));
} }
@@ -1631,7 +1647,11 @@
.root :global(.home-card-section.intraday) { .root :global(.home-card-section.intraday) {
border-color: rgba(34, 211, 238, 0.2); border-color: rgba(34, 211, 238, 0.2);
background: background:
radial-gradient(circle at 0% 35%, rgba(34, 211, 238, 0.12), transparent 44%), radial-gradient(
circle at 0% 35%,
rgba(34, 211, 238, 0.12),
transparent 44%
),
linear-gradient(180deg, rgba(7, 14, 26, 0.94), rgba(9, 17, 30, 0.82)); linear-gradient(180deg, rgba(7, 14, 26, 0.94), rgba(9, 17, 30, 0.82));
} }
@@ -1702,49 +1722,6 @@
filter: drop-shadow(0 0 10px rgba(56, 189, 248, 0.28)); filter: drop-shadow(0 0 10px rgba(56, 189, 248, 0.28));
} }
.root :global(.home-intraday-hotspot) {
position: absolute;
width: 18px;
height: 18px;
transform: translate(-50%, -50%);
border: 0;
border-radius: 999px;
background: transparent;
cursor: pointer;
}
.root :global(.home-intraday-hotspot:focus-visible) {
outline: 1px solid rgba(34, 211, 238, 0.6);
outline-offset: 1px;
}
.root :global(.home-intraday-tooltip) {
position: absolute;
transform: translate(-50%, -100%);
pointer-events: none;
display: inline-flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
border: 1px solid rgba(34, 211, 238, 0.22);
border-radius: 10px;
background: rgba(7, 15, 28, 0.96);
box-shadow: 0 10px 26px rgba(2, 6, 23, 0.34);
white-space: nowrap;
}
.root :global(.home-intraday-tooltip strong) {
color: #f8fafc;
font-size: 12px;
font-weight: 900;
}
.root :global(.home-intraday-tooltip span) {
color: rgba(148, 163, 184, 0.88);
font-size: 10px;
font-weight: 700;
}
.root :global(.home-intraday-reports) { .root :global(.home-intraday-reports) {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1842,7 +1819,11 @@
.root :global(.home-card-section.probability) { .root :global(.home-card-section.probability) {
border-color: rgba(251, 113, 133, 0.18); border-color: rgba(251, 113, 133, 0.18);
background: background:
radial-gradient(circle at 100% 0%, rgba(251, 113, 133, 0.08), transparent 42%), radial-gradient(
circle at 100% 0%,
rgba(251, 113, 133, 0.08),
transparent 42%
),
linear-gradient(180deg, rgba(7, 14, 26, 0.94), rgba(9, 17, 30, 0.82)); linear-gradient(180deg, rgba(7, 14, 26, 0.94), rgba(9, 17, 30, 0.82));
} }
@@ -2342,12 +2323,11 @@
padding: 14px 16px; padding: 14px 16px;
border-radius: 18px; border-radius: 18px;
border: 1px solid rgba(34, 211, 238, 0.18); border: 1px solid rgba(34, 211, 238, 0.18);
background: background: linear-gradient(
linear-gradient( 135deg,
135deg, rgba(8, 15, 28, 0.84),
rgba(8, 15, 28, 0.84), rgba(13, 30, 52, 0.82)
rgba(13, 30, 52, 0.82) );
);
} }
.root :global(.opportunity-hero-edgeblock span) { .root :global(.opportunity-hero-edgeblock span) {
@@ -2783,8 +2763,16 @@
/* ── Light Theme Dashboard Overrides ── */ /* ── Light Theme Dashboard Overrides ── */
:global(html.light) .root { :global(html.light) .root {
background: background:
radial-gradient(circle at 22% 12%, rgba(14, 165, 233, 0.16), transparent 28%), radial-gradient(
radial-gradient(circle at 78% 0%, rgba(59, 130, 246, 0.12), transparent 24%), circle at 22% 12%,
rgba(14, 165, 233, 0.16),
transparent 28%
),
radial-gradient(
circle at 78% 0%,
rgba(59, 130, 246, 0.12),
transparent 24%
),
linear-gradient(180deg, #f8fbff 0%, #e7f1fb 100%); linear-gradient(180deg, #f8fbff 0%, #e7f1fb 100%);
} }
@@ -2893,7 +2881,11 @@
:global(html.light) .root :global(.home-intelligence-panel), :global(html.light) .root :global(.home-intelligence-panel),
:global(html.light) .root :global(.home-opportunity-strip) { :global(html.light) .root :global(.home-opportunity-strip) {
background: background:
radial-gradient(circle at 12% 0%, rgba(14, 165, 233, 0.08), transparent 34%), radial-gradient(
circle at 12% 0%,
rgba(14, 165, 233, 0.08),
transparent 34%
),
rgba(248, 252, 255, 0.9); rgba(248, 252, 255, 0.9);
border-color: rgba(36, 68, 104, 0.16); border-color: rgba(36, 68, 104, 0.16);
box-shadow: 0 28px 70px rgba(31, 64, 104, 0.14); box-shadow: 0 28px 70px rgba(31, 64, 104, 0.14);
@@ -2914,7 +2906,11 @@
:global(html.light) .root :global(.home-forecast-item), :global(html.light) .root :global(.home-forecast-item),
:global(html.light) .root :global(.home-intraday-reports span) { :global(html.light) .root :global(.home-intraday-reports span) {
background: background:
radial-gradient(circle at 100% 0%, rgba(14, 165, 233, 0.08), transparent 36%), radial-gradient(
circle at 100% 0%,
rgba(14, 165, 233, 0.08),
transparent 36%
),
rgba(255, 255, 255, 0.92); rgba(255, 255, 255, 0.92);
border-color: rgba(36, 68, 104, 0.14); border-color: rgba(36, 68, 104, 0.14);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
@@ -2922,8 +2918,11 @@
} }
:global(html.light) .root :global(.home-weather-hero) { :global(html.light) .root :global(.home-weather-hero) {
background: background: linear-gradient(
linear-gradient(135deg, rgba(238, 246, 255, 0.98), rgba(216, 233, 248, 0.92)); 135deg,
rgba(238, 246, 255, 0.98),
rgba(216, 233, 248, 0.92)
);
border-color: rgba(36, 68, 104, 0.14); border-color: rgba(36, 68, 104, 0.14);
} }
@@ -7774,7 +7773,11 @@
width: 38px; width: 38px;
height: 38px; height: 38px;
border-radius: 12px; border-radius: 12px;
background: linear-gradient(135deg, rgba(34, 211, 238, 0.9), rgba(59, 130, 246, 0.86)); background: linear-gradient(
135deg,
rgba(34, 211, 238, 0.9),
rgba(59, 130, 246, 0.86)
);
color: #04111d; color: #04111d;
font-size: 13px; font-size: 13px;
font-weight: 900; font-weight: 900;
@@ -7975,7 +7978,11 @@
border: 1px solid rgba(34, 211, 238, 0.22); border: 1px solid rgba(34, 211, 238, 0.22);
border-radius: 12px; border-radius: 12px;
padding: 9px 14px; padding: 9px 14px;
background: linear-gradient(135deg, rgba(8, 145, 178, 0.24), rgba(37, 99, 235, 0.24)); background: linear-gradient(
135deg,
rgba(8, 145, 178, 0.24),
rgba(37, 99, 235, 0.24)
);
color: #e0f2fe; color: #e0f2fe;
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
@@ -8004,7 +8011,11 @@
:global(html.light) .root :global(.home-ai-launcher), :global(html.light) .root :global(.home-ai-launcher),
:global(html.light) .root :global(.home-ai-panel) { :global(html.light) .root :global(.home-ai-panel) {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(240, 248, 255, 0.94)); background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.96),
rgba(240, 248, 255, 0.94)
);
border-color: rgba(48, 77, 112, 0.16); border-color: rgba(48, 77, 112, 0.16);
} }
+148 -95
View File
@@ -62,8 +62,8 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
tension: 0.28, tension: 0.28,
}, },
{ {
backgroundColor: "#22d3ee", backgroundColor: "#00E0A4",
borderColor: "#22d3ee", borderColor: "#00E0A4",
borderWidth: 0, borderWidth: 0,
data: chartData.datasets.metarPoints, data: chartData.datasets.metarPoints,
fill: false, fill: false,
@@ -84,7 +84,7 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
legend: { display: false }, legend: { display: false },
tooltip: { tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.95)", backgroundColor: "rgba(15, 23, 42, 0.95)",
borderColor: "rgba(34, 211, 238, 0.25)", borderColor: "rgba(0, 224, 164, 0.25)",
borderWidth: 1, borderWidth: 1,
}, },
}, },
@@ -153,9 +153,13 @@ export function DetailPanel() {
const isAuthenticated = store.proAccess.authenticated; const isAuthenticated = store.proAccess.authenticated;
const panelRef = useRef<HTMLElement | null>(null); const panelRef = useRef<HTMLElement | null>(null);
const [heavyContentReady, setHeavyContentReady] = useState(false); const [heavyContentReady, setHeavyContentReady] = useState(false);
const isOverlayOpen = Boolean(store.futureModalDate) || store.historyState.isOpen; const isOverlayOpen =
const isVisible = store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen; Boolean(store.futureModalDate) || store.historyState.isOpen;
const hasBasicPanelContent = Boolean(detail || selectedSummary || selectedCityItem); const isVisible =
store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen;
const hasBasicPanelContent = Boolean(
detail || selectedSummary || selectedCityItem,
);
const panelDisplayName = const panelDisplayName =
detail?.display_name || detail?.display_name ||
selectedSummary?.display_name || selectedSummary?.display_name ||
@@ -196,8 +200,8 @@ export function DetailPanel() {
const heroAirportLabel = detail?.risk?.airport || basicAirportLabel; const heroAirportLabel = detail?.risk?.airport || basicAirportLabel;
const isSparsePanelDetail = Boolean( const isSparsePanelDetail = Boolean(
detail && detail &&
(detail.detail_depth !== "full" || (detail.detail_depth !== "full" ||
(detail.forecast?.daily?.length ?? 0) <= 1), (detail.forecast?.daily?.length ?? 0) <= 1),
); );
const isPanelSyncing = store.loadingState.cityDetail; const isPanelSyncing = store.loadingState.cityDetail;
const isShowingCachedDetailDuringSync = Boolean(detail && isPanelSyncing); const isShowingCachedDetailDuringSync = Boolean(detail && isPanelSyncing);
@@ -249,7 +253,10 @@ export function DetailPanel() {
if (!isVisible) { if (!isVisible) {
panel.setAttribute("inert", ""); panel.setAttribute("inert", "");
if (typeof document !== "undefined" && panel.contains(document.activeElement)) { if (
typeof document !== "undefined" &&
panel.contains(document.activeElement)
) {
const active = document.activeElement; const active = document.activeElement;
if (active instanceof HTMLElement) { if (active instanceof HTMLElement) {
active.blur(); active.blur();
@@ -288,7 +295,11 @@ export function DetailPanel() {
return () => { return () => {
canceled = true; canceled = true;
if (win && idleId != null && typeof win.cancelIdleCallback === "function") { if (
win &&
idleId != null &&
typeof win.cancelIdleCallback === "function"
) {
win.cancelIdleCallback(idleId); win.cancelIdleCallback(idleId);
} }
if (timeoutId != null && typeof window !== "undefined") { if (timeoutId != null && typeof window !== "undefined") {
@@ -298,7 +309,10 @@ export function DetailPanel() {
}, [detail, isVisible]); }, [detail, isVisible]);
return ( return (
<aside ref={panelRef} className={clsx("detail-panel", isVisible && "visible")}> <aside
ref={panelRef}
className={clsx("detail-panel", isVisible && "visible")}
>
<div className="panel-header"> <div className="panel-header">
<button <button
type="button" type="button"
@@ -311,7 +325,7 @@ export function DetailPanel() {
> >
× ×
</button> </button>
<div className="panel-title-area"> <div className="panel-title-area">
<div className="panel-title-stack"> <div className="panel-title-stack">
<div className="panel-overline"> <div className="panel-overline">
<span>{locale === "en-US" ? "City briefing" : "城市简报"}</span> <span>{locale === "en-US" ? "City briefing" : "城市简报"}</span>
@@ -321,7 +335,11 @@ export function DetailPanel() {
<h2>{panelDisplayName}</h2> <h2>{panelDisplayName}</h2>
</div> </div>
{isPanelSyncing && ( {isPanelSyncing && (
<div className="panel-loading-hint" role="status" aria-live="polite"> <div
className="panel-loading-hint"
role="status"
aria-live="polite"
>
<span className="panel-loading-spinner" aria-hidden="true" /> <span className="panel-loading-spinner" aria-hidden="true" />
<span> <span>
{locale === "en-US" {locale === "en-US"
@@ -338,8 +356,12 @@ export function DetailPanel() {
<span className={clsx("risk-badge", panelRiskLevel)}> <span className={clsx("risk-badge", panelRiskLevel)}>
{getRiskBadgeLabel(panelRiskLevel, locale)} {getRiskBadgeLabel(panelRiskLevel, locale)}
</span> </span>
<span className="panel-meta-chip panel-meta-chip-strong">{heroSettlementLabel}</span> <span className="panel-meta-chip panel-meta-chip-strong">
<span className="panel-meta-chip panel-meta-chip-muted">{heroAirportLabel}</span> {heroSettlementLabel}
</span>
<span className="panel-meta-chip panel-meta-chip-muted">
{heroAirportLabel}
</span>
</div> </div>
<div className="panel-actions"> <div className="panel-actions">
{marketUrl ? ( {marketUrl ? (
@@ -365,12 +387,16 @@ export function DetailPanel() {
!isPro && "pro-locked", !isPro && "pro-locked",
)} )}
title={ title={
isPro ? t("detail.todayAnalysis") : `${t("detail.todayAnalysis")} (Pro)` isPro
? t("detail.todayAnalysis")
: `${t("detail.todayAnalysis")} (Pro)`
} }
onClick={() => handleFeatureAccess("today")} onClick={() => handleFeatureAccess("today")}
disabled={!store.selectedCity} disabled={!store.selectedCity}
> >
{isPro ? t("detail.todayAnalysis") : `${t("detail.todayAnalysis")} · Pro`} {isPro
? t("detail.todayAnalysis")
: `${t("detail.todayAnalysis")} · Pro`}
</button> </button>
<button <button
type="button" type="button"
@@ -379,7 +405,9 @@ export function DetailPanel() {
"panel-action-button-secondary", "panel-action-button-secondary",
!isPro && "pro-locked", !isPro && "pro-locked",
)} )}
title={isPro ? t("detail.history") : `${t("detail.history")} (Pro)`} title={
isPro ? t("detail.history") : `${t("detail.history")} (Pro)`
}
onClick={() => handleFeatureAccess("history")} onClick={() => handleFeatureAccess("history")}
disabled={!store.selectedCity} disabled={!store.selectedCity}
> >
@@ -389,7 +417,12 @@ export function DetailPanel() {
</div> </div>
</div> </div>
<div className={clsx("panel-body", isShowingCachedDetailDuringSync && "is-syncing")}> <div
className={clsx(
"panel-body",
isShowingCachedDetailDuringSync && "is-syncing",
)}
>
{isShowingCachedDetailDuringSync ? ( {isShowingCachedDetailDuringSync ? (
<div className="panel-sync-blocker" role="status" aria-live="polite"> <div className="panel-sync-blocker" role="status" aria-live="polite">
<span className="panel-loading-spinner" aria-hidden="true" /> <span className="panel-loading-spinner" aria-hidden="true" />
@@ -400,101 +433,121 @@ export function DetailPanel() {
</span> </span>
</div> </div>
) : null} ) : null}
<div className={clsx(isShowingCachedDetailDuringSync && "panel-content-stale")}> <div
{!hasBasicPanelContent ? ( className={clsx(
<section className="detail-summary-shell detail-empty-state"> isShowingCachedDetailDuringSync && "panel-content-stale",
<div className="detail-section-head"> )}
<div> >
<div className="detail-section-kicker"> {!hasBasicPanelContent ? (
{locale === "en-US" ? "No city selected" : "尚未选择城市"} <section className="detail-summary-shell detail-empty-state">
</div>
<h3>{locale === "en-US" ? "Pick a city to start." : "先选择一个城市。"}</h3>
</div>
</div>
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
{store.loadingState.cityDetail ? t("detail.loading") : t("detail.emptyHint")}
</div>
</section>
) : !detail ? (
<div className="detail-mini-meta" role="status" aria-live="polite">
{store.loadingState.cityDetail
? locale === "en-US"
? "Loading city cards..."
: "正在加载城市卡片..."
: locale === "en-US"
? "City cards will appear here."
: "城市卡片会显示在这里。"}
</div>
) : (
<>
<section className="detail-structured-section">
<div className="detail-section-head"> <div className="detail-section-head">
<div> <div>
<div className="detail-section-kicker">{locale === "en-US" ? "Profile" : "城市画像"}</div> <div className="detail-section-kicker">
<h3>{t("detail.profile")}</h3> {locale === "en-US" ? "No city selected" : "尚未选择城市"}
</div>
<h3>
{locale === "en-US"
? "Pick a city to start."
: "先选择一个城市。"}
</h3>
</div> </div>
</div> </div>
<div className="detail-grid"> <div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
{profileStats.map((item) => ( {store.loadingState.cityDetail
<article key={item.label} className="detail-card"> ? t("detail.loading")
<span className="detail-label">{item.label}</span> : t("detail.emptyHint")}
<span className="detail-value">{item.value}</span>
</article>
))}
</div> </div>
</section> </section>
) : !detail ? (
{officialLinks.length > 0 ? ( <div className="detail-mini-meta" role="status" aria-live="polite">
{store.loadingState.cityDetail
? locale === "en-US"
? "Loading city cards..."
: "正在加载城市卡片..."
: locale === "en-US"
? "City cards will appear here."
: "城市卡片会显示在这里。"}
</div>
) : (
<>
<section className="detail-structured-section"> <section className="detail-structured-section">
<div className="detail-section-head"> <div className="detail-section-head">
<div> <div>
<div className="detail-section-kicker"> <div className="detail-section-kicker">
{locale === "en-US" ? "Primary references" : "官方参考"} {locale === "en-US" ? "Profile" : "城市画像"}
</div> </div>
<h3>{locale === "en-US" ? "Settlement and observation references" : "结算与观测参考来源"}</h3> <h3>{t("detail.profile")}</h3>
</div> </div>
</div> </div>
<p className="detail-source-note"> <div className="detail-grid">
{locale === "en-US" {profileStats.map((item) => (
? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page." <article key={item.label} className="detail-card">
: "AGENCY = 国家气象机构,METAR = 机场实测报文,AIRPORT = 机场官网页面。"} <span className="detail-label">{item.label}</span>
</p> <span className="detail-value">{item.value}</span>
<div className="detail-source-list"> </article>
{officialLinks.map((link) => (
<a
key={`${link.label}-${link.href}`}
className="detail-source-link"
href={link.href}
target="_blank"
rel="noreferrer"
>
<span className="detail-source-kind">{link.kind.toUpperCase()}</span>
<span className="detail-source-label">{link.label}</span>
</a>
))} ))}
</div> </div>
</section> </section>
) : null}
<section className="detail-structured-section rounded-2xl"> {officialLinks.length > 0 ? (
<div className="detail-section-head"> <section className="detail-structured-section">
<div> <div className="detail-section-head">
<div className="detail-section-kicker"> <div>
{locale === "en-US" ? "Mini trend" : "温度微趋势"} <div className="detail-section-kicker">
{locale === "en-US" ? "Primary references" : "官方参考"}
</div>
<h3>
{locale === "en-US"
? "Settlement and observation references"
: "结算与观测参考来源"}
</h3>
</div>
</div> </div>
<h3>{t("detail.todayMiniTrend")}</h3> <p className="detail-source-note">
</div> {locale === "en-US"
</div> ? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page."
{heavyContentReady ? ( : "AGENCY = 国家气象机构,METAR = 机场实测报文,AIRPORT = 机场官网页面。"}
<DetailMiniTemperatureChart detail={detail} /> </p>
) : ( <div className="detail-source-list">
<div className="detail-mini-meta">{t("detail.loading")}</div> {officialLinks.map((link) => (
)} <a
</section> key={`${link.label}-${link.href}`}
className="detail-source-link"
href={link.href}
target="_blank"
rel="noreferrer"
>
<span className="detail-source-kind">
{link.kind.toUpperCase()}
</span>
<span className="detail-source-label">
{link.label}
</span>
</a>
))}
</div>
</section>
) : null}
{heavyContentReady ? <ForecastTable /> : null} <section className="detail-structured-section rounded-2xl">
</> <div className="detail-section-head">
)} <div>
<div className="detail-section-kicker">
{locale === "en-US" ? "Mini trend" : "温度微趋势"}
</div>
<h3>{t("detail.todayMiniTrend")}</h3>
</div>
</div>
{heavyContentReady ? (
<DetailMiniTemperatureChart detail={detail} />
) : (
<div className="detail-mini-meta">{t("detail.loading")}</div>
)}
</section>
{heavyContentReady ? <ForecastTable /> : null}
</>
)}
</div> </div>
</div> </div>
</aside> </aside>
@@ -36,7 +36,10 @@ import {
normalizeObservationSourceCode, normalizeObservationSourceCode,
normalizeObservationSourceLabel, normalizeObservationSourceLabel,
} from "@/lib/source-labels"; } from "@/lib/source-labels";
import type { IntradayMeteorologySignal, MarketScan } from "@/lib/dashboard-types"; import type {
IntradayMeteorologySignal,
MarketScan,
} from "@/lib/dashboard-types";
function normalizeMarketValue(value?: number | null) { function normalizeMarketValue(value?: number | null) {
if (value == null) return null; if (value == null) return null;
@@ -182,7 +185,9 @@ function parsePercentFromText(value?: string | number | null) {
} }
function formatConfidenceLabel(value?: string | null, locale = "zh-CN") { function formatConfidenceLabel(value?: string | null, locale = "zh-CN") {
const normalized = String(value || "").trim().toLowerCase(); const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "high") return locale === "en-US" ? "High" : "高"; if (normalized === "high") return locale === "en-US" ? "High" : "高";
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中"; if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
if (normalized === "low") return locale === "en-US" ? "Low" : "低"; if (normalized === "low") return locale === "en-US" ? "Low" : "低";
@@ -190,21 +195,29 @@ function formatConfidenceLabel(value?: string | null, locale = "zh-CN") {
} }
function formatSignalDirection(value?: string | null, locale = "zh-CN") { function formatSignalDirection(value?: string | null, locale = "zh-CN") {
const normalized = String(value || "").trim().toLowerCase(); const normalized = String(value || "")
if (normalized === "support") return locale === "en-US" ? "Support" : "支持升温"; .trim()
if (normalized === "suppress") return locale === "en-US" ? "Suppress" : "压制峰值"; .toLowerCase();
if (normalized === "support")
return locale === "en-US" ? "Support" : "支持升温";
if (normalized === "suppress")
return locale === "en-US" ? "Suppress" : "压制峰值";
return locale === "en-US" ? "Neutral" : "中性"; return locale === "en-US" ? "Neutral" : "中性";
} }
function formatSignalStrength(value?: string | null, locale = "zh-CN") { function formatSignalStrength(value?: string | null, locale = "zh-CN") {
const normalized = String(value || "").trim().toLowerCase(); const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "strong") return locale === "en-US" ? "Strong" : "强"; if (normalized === "strong") return locale === "en-US" ? "Strong" : "强";
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中"; if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
return locale === "en-US" ? "Weak" : "弱"; return locale === "en-US" ? "Weak" : "弱";
} }
function signalTone(signal?: IntradayMeteorologySignal | null) { function signalTone(signal?: IntradayMeteorologySignal | null) {
const direction = String(signal?.direction || "").trim().toLowerCase(); const direction = String(signal?.direction || "")
.trim()
.toLowerCase();
if (direction === "support") return "cyan"; if (direction === "support") return "cyan";
if (direction === "suppress") return "amber"; if (direction === "suppress") return "amber";
return "blue"; return "blue";
@@ -261,7 +274,7 @@ function getTrendMetricVisual(metric: {
return { return {
mode: "center" as const, mode: "center" as const,
percent: clamp(50 + (numeric / 4) * 50, 0, 100), percent: clamp(50 + (numeric / 4) * 50, 0, 100),
tone: numeric >= 0 ? "warm" as const : "cold" as const, tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
}; };
} }
@@ -269,7 +282,7 @@ function getTrendMetricVisual(metric: {
return { return {
mode: "center" as const, mode: "center" as const,
percent: clamp(50 + (numeric / 3) * 50, 0, 100), percent: clamp(50 + (numeric / 3) * 50, 0, 100),
tone: numeric >= 0 ? "warm" as const : "cold" as const, tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
}; };
} }
@@ -277,7 +290,7 @@ function getTrendMetricVisual(metric: {
return { return {
mode: "center" as const, mode: "center" as const,
percent: clamp(50 + (numeric / 4) * 50, 0, 100), percent: clamp(50 + (numeric / 4) * 50, 0, 100),
tone: numeric >= 0 ? "warm" as const : "cold" as const, tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
}; };
} }
@@ -285,7 +298,7 @@ function getTrendMetricVisual(metric: {
return { return {
mode: "center" as const, mode: "center" as const,
percent: clamp(50 + (numeric / 40) * 50, 0, 100), percent: clamp(50 + (numeric / 40) * 50, 0, 100),
tone: numeric >= 0 ? "cold" as const : "warm" as const, tone: numeric >= 0 ? ("cold" as const) : ("warm" as const),
}; };
} }
@@ -303,7 +316,8 @@ function DailyTemperatureChart({
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const detail = store.selectedDetail; const detail = store.selectedDetail;
const view = detail ? getFutureModalView(detail, dateStr, locale) : null; const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
const isToday = forceToday || (detail ? dateStr === detail.local_date : false); const isToday =
forceToday || (detail ? dateStr === detail.local_date : false);
const todayChartData = useMemo( const todayChartData = useMemo(
() => (detail && isToday ? getTemperatureChartData(detail, locale) : null), () => (detail && isToday ? getTemperatureChartData(detail, locale) : null),
[detail, isToday, locale], [detail, isToday, locale],
@@ -363,8 +377,8 @@ function DailyTemperatureChart({
} }
datasets.push({ datasets.push({
backgroundColor: "#22d3ee", backgroundColor: "#00E0A4",
borderColor: "#22d3ee", borderColor: "#00E0A4",
borderWidth: 0, borderWidth: 0,
data: todayChartData.datasets.metarSeries, data: todayChartData.datasets.metarSeries,
fill: false, fill: false,
@@ -385,8 +399,7 @@ function DailyTemperatureChart({
borderWidth: 1, borderWidth: 1,
data: todayChartData.datasets.airportMetarSeries, data: todayChartData.datasets.airportMetarSeries,
fill: false, fill: false,
label: label: locale === "en-US" ? "Airport METAR" : "机场 METAR",
locale === "en-US" ? "Airport METAR" : "机场 METAR",
order: 0, order: 0,
parsing: false, parsing: false,
pointHoverRadius: 6, pointHoverRadius: 6,
@@ -416,7 +429,7 @@ function DailyTemperatureChart({
Math.abs(todayChartData.datasets.offset) > 0.3 Math.abs(todayChartData.datasets.offset) > 0.3
) { ) {
datasets.push({ datasets.push({
borderColor: "rgba(99, 102, 241, 0.2)", borderColor: "rgba(123, 97, 255, 0.2)",
borderDash: [2, 4], borderDash: [2, 4],
borderWidth: 1, borderWidth: 1,
data: todayChartData.datasets.tempsSeries, data: todayChartData.datasets.tempsSeries,
@@ -484,7 +497,8 @@ function DailyTemperatureChart({
filter: (legendItem, chartData) => { filter: (legendItem, chartData) => {
const text = String(legendItem.text || ""); const text = String(legendItem.text || "");
if (!text) return false; if (!text) return false;
if (text === "TAF Timing" || text === "TAF 时段") return false; if (text === "TAF Timing" || text === "TAF 时段")
return false;
if (!text.includes("DEB")) return true; if (!text.includes("DEB")) return true;
const firstDebIndex = (chartData.datasets || []).findIndex( const firstDebIndex = (chartData.datasets || []).findIndex(
@@ -497,17 +511,27 @@ function DailyTemperatureChart({
}, },
tooltip: { tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.96)", backgroundColor: "rgba(15, 23, 42, 0.96)",
borderColor: "rgba(34, 211, 238, 0.2)", borderColor: "rgba(0, 224, 164, 0.2)",
borderWidth: 1, borderWidth: 1,
callbacks: { callbacks: {
title: (items) => { title: (items) => {
const rawX = items?.[0]?.parsed?.x; const rawX = items?.[0]?.parsed?.x;
return rawX != null ? formatMinuteAxisLabel(Number(rawX)) : ""; return rawX != null
? formatMinuteAxisLabel(Number(rawX))
: "";
}, },
label: (ctx) => { label: (ctx) => {
const label = String(ctx.dataset.label || ""); const label = String(ctx.dataset.label || "");
const raw = ctx.raw as const raw = ctx.raw as
| { marker?: { summary?: string; markerType?: string; displayType?: string; isCurrent?: boolean; isPeakWindow?: boolean } } | {
marker?: {
summary?: string;
markerType?: string;
displayType?: string;
isCurrent?: boolean;
isPeakWindow?: boolean;
};
}
| undefined; | undefined;
if ( if (
label === "TAF Timing" || label === "TAF Timing" ||
@@ -539,7 +563,9 @@ function DailyTemperatureChart({
: "峰值窗口 TAF" : "峰值窗口 TAF"
: label; : label;
return `${prefix}: ${ return `${prefix}: ${
markerType ? summary.replace(markerType, displayType) : summary markerType
? summary.replace(markerType, displayType)
: summary
}`; }`;
} }
const value = ctx.parsed.y; const value = ctx.parsed.y;
@@ -598,8 +624,8 @@ function DailyTemperatureChart({
data: { data: {
datasets: [ datasets: [
{ {
backgroundColor: "rgba(34, 211, 238, 0.08)", backgroundColor: "rgba(0, 224, 164, 0.08)",
borderColor: "#22d3ee", borderColor: "#00E0A4",
data: view.slice.map((point) => point.temp), data: view.slice.map((point) => point.temp),
fill: false, fill: false,
label: label:
@@ -632,7 +658,7 @@ function DailyTemperatureChart({
}, },
tooltip: { tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.96)", backgroundColor: "rgba(15, 23, 42, 0.96)",
borderColor: "rgba(34, 211, 238, 0.2)", borderColor: "rgba(0, 224, 164, 0.2)",
borderWidth: 1, borderWidth: 1,
callbacks: { callbacks: {
label: (ctx) => label: (ctx) =>
@@ -685,8 +711,11 @@ export function FutureForecastModal() {
const dateStr = store.futureModalDate; const dateStr = store.futureModalDate;
const isPro = store.proAccess.subscriptionActive; const isPro = store.proAccess.subscriptionActive;
const isProLoading = store.proAccess.loading; const isProLoading = store.proAccess.loading;
const [showDeferredTodaySections, setShowDeferredTodaySections] = useState(false); const [showDeferredTodaySections, setShowDeferredTodaySections] =
const [freshMarketScan, setFreshMarketScan] = useState<MarketScan | null>(null); useState(false);
const [freshMarketScan, setFreshMarketScan] = useState<MarketScan | null>(
null,
);
if (!detail || !dateStr) return null; if (!detail || !dateStr) return null;
@@ -728,7 +757,8 @@ export function FutureForecastModal() {
(store.forecastModalMode == null && dateStr === detail.local_date); (store.forecastModalMode == null && dateStr === detail.local_date);
const detailDepth = detail.detail_depth || "full"; const detailDepth = detail.detail_depth || "full";
const isFullDetailReady = detailDepth === "full"; const isFullDetailReady = detailDepth === "full";
const isStructureSyncing = store.loadingState.futureDeep || !isFullDetailReady; const isStructureSyncing =
store.loadingState.futureDeep || !isFullDetailReady;
const isAnyLayerSyncing = isStructureSyncing; const isAnyLayerSyncing = isStructureSyncing;
const isTodayBlockingRefresh = isToday && isStructureSyncing; const isTodayBlockingRefresh = isToday && isStructureSyncing;
const activeMarketScan = freshMarketScan || detail.market_scan || null; const activeMarketScan = freshMarketScan || detail.market_scan || null;
@@ -763,7 +793,10 @@ export function FutureForecastModal() {
refreshMarketScan(); refreshMarketScan();
intervalId = setInterval(() => { intervalId = setInterval(() => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") { if (
typeof document !== "undefined" &&
document.visibilityState === "hidden"
) {
return; return;
} }
refreshMarketScan(); refreshMarketScan();
@@ -804,7 +837,10 @@ export function FutureForecastModal() {
() => getProbabilityView(detail, dateStr), () => getProbabilityView(detail, dateStr),
[dateStr, detail], [dateStr, detail],
); );
const modelView = useMemo(() => getModelView(detail, dateStr), [dateStr, detail]); const modelView = useMemo(
() => getModelView(detail, dateStr),
[dateStr, detail],
);
const probabilityEngineKey = String(probabilityView?.engine || "") const probabilityEngineKey = String(probabilityView?.engine || "")
.trim() .trim()
.toLowerCase(); .toLowerCase();
@@ -816,12 +852,16 @@ export function FutureForecastModal() {
const hasLgbmProbability = useMemo( const hasLgbmProbability = useMemo(
() => () =>
Object.keys(modelView?.models || {}).some((name) => Object.keys(modelView?.models || {}).some((name) =>
String(name || "").toLowerCase().replace(/[\s_/-]/g, "").includes("lgbm"), String(name || "")
.toLowerCase()
.replace(/[\s_/-]/g, "")
.includes("lgbm"),
), ),
[modelView], [modelView],
); );
const hasEmosProbability = const hasEmosProbability =
probabilityEngineKey === "emos" || probabilityCalibrationMode.includes("emos"); probabilityEngineKey === "emos" ||
probabilityCalibrationMode.includes("emos");
const probabilityEngineLabel = hasLgbmProbability const probabilityEngineLabel = hasLgbmProbability
? locale === "en-US" ? locale === "en-US"
? "LGBM" ? "LGBM"
@@ -877,7 +917,9 @@ export function FutureForecastModal() {
if (projected == null || !Number.isFinite(projected)) return null; if (projected == null || !Number.isFinite(projected)) return null;
const distances = [bounds.lower, bounds.upper] const distances = [bounds.lower, bounds.upper]
.filter((value): value is number => value != null && Number.isFinite(value)) .filter(
(value): value is number => value != null && Number.isFinite(value),
)
.map((value) => ({ .map((value) => ({
boundary: value, boundary: value,
gap: Math.abs(projected - value), gap: Math.abs(projected - value),
@@ -911,7 +953,15 @@ export function FutureForecastModal() {
tone, tone,
value: `${nearest.gap.toFixed(1)}${detail.temp_symbol}`, value: `${nearest.gap.toFixed(1)}${detail.temp_symbol}`,
}; };
}, [detail.deb?.prediction, detail.temp_symbol, isToday, locale, paceView, showDeferredTodaySections, topProbabilityBucket]); }, [
detail.deb?.prediction,
detail.temp_symbol,
isToday,
locale,
paceView,
showDeferredTodaySections,
topProbabilityBucket,
]);
const peakWindowStateView = useMemo(() => { const peakWindowStateView = useMemo(() => {
if (!showDeferredTodaySections) return null; if (!showDeferredTodaySections) return null;
if (!isToday || !paceView) return null; if (!isToday || !paceView) return null;
@@ -948,7 +998,15 @@ export function FutureForecastModal() {
tone, tone,
value: paceView.peakWindowText, value: paceView.peakWindowText,
}; };
}, [detail.local_time, detail.peak?.first_h, detail.peak?.last_h, isToday, locale, paceView, showDeferredTodaySections]); }, [
detail.local_time,
detail.peak?.first_h,
detail.peak?.last_h,
isToday,
locale,
paceView,
showDeferredTodaySections,
]);
const networkLeadView = useMemo(() => { const networkLeadView = useMemo(() => {
if (!showDeferredTodaySections) return null; if (!showDeferredTodaySections) return null;
if (!isToday) return null; if (!isToday) return null;
@@ -958,8 +1016,12 @@ export function FutureForecastModal() {
const leaderLabel = const leaderLabel =
String(leadSignal?.leader_station_label || "").trim() || String(leadSignal?.leader_station_label || "").trim() ||
String(leadSignal?.leader_station_code || "").trim(); String(leadSignal?.leader_station_code || "").trim();
const leaderSyncStatus = String(leadSignal?.leader_sync_status || "").trim().toLowerCase(); const leaderSyncStatus = String(leadSignal?.leader_sync_status || "")
const leaderSyncDelta = Number(leadSignal?.leader_time_delta_vs_anchor_minutes); .trim()
.toLowerCase();
const leaderSyncDelta = Number(
leadSignal?.leader_time_delta_vs_anchor_minutes,
);
const syncNote = const syncNote =
leaderSyncStatus === "near_realtime" || leaderSyncStatus === "lagged" leaderSyncStatus === "near_realtime" || leaderSyncStatus === "lagged"
? Number.isFinite(leaderSyncDelta) ? Number.isFinite(leaderSyncDelta)
@@ -987,8 +1049,7 @@ export function FutureForecastModal() {
: locale === "en-US" : locale === "en-US"
? "Tracking network" ? "Tracking network"
: "与站网齐平"; : "与站网齐平";
const tone = const tone = delta <= -0.4 ? "amber" : delta >= 0.4 ? "cyan" : "blue";
delta <= -0.4 ? "amber" : delta >= 0.4 ? "cyan" : "blue";
const note = const note =
delta <= -0.4 delta <= -0.4
? locale === "en-US" ? locale === "en-US"
@@ -1008,7 +1069,14 @@ export function FutureForecastModal() {
tone, tone,
value: `${delta > 0 ? "+" : ""}${delta.toFixed(1)}${detail.temp_symbol}`, value: `${delta > 0 ? "+" : ""}${delta.toFixed(1)}${detail.temp_symbol}`,
}; };
}, [detail.airport_vs_network_delta, detail.network_lead_signal, detail.temp_symbol, isToday, locale, showDeferredTodaySections]); }, [
detail.airport_vs_network_delta,
detail.network_lead_signal,
detail.temp_symbol,
isToday,
locale,
showDeferredTodaySections,
]);
const isNoaaSettlement = const isNoaaSettlement =
detail.current?.settlement_source === "noaa" || detail.current?.settlement_source === "noaa" ||
detail.current?.settlement_source_label === "NOAA"; detail.current?.settlement_source_label === "NOAA";
@@ -1056,7 +1124,8 @@ export function FutureForecastModal() {
: "当前拿不到可用的模型分歧。"; : "当前拿不到可用的模型分歧。";
} }
const modelEntries = Object.entries(modelView?.models || {}).filter( const modelEntries = Object.entries(modelView?.models || {}).filter(
([, value]) => value !== null && value !== undefined && Number.isFinite(Number(value)), ([, value]) =>
value !== null && value !== undefined && Number.isFinite(Number(value)),
); );
if (modelEntries.length === 1) { if (modelEntries.length === 1) {
const [singleModelName, singleModelValue] = modelEntries[0]; const [singleModelName, singleModelValue] = modelEntries[0];
@@ -1072,9 +1141,12 @@ export function FutureForecastModal() {
const tafSignal = detail.taf?.signal || {}; const tafSignal = detail.taf?.signal || {};
const upperAirCue = useMemo(() => { const upperAirCue = useMemo(() => {
if (!showDeferredTodaySections) return null; if (!showDeferredTodaySections) return null;
if (!isToday || (!upperAirSignal.source && !tafSignal.available)) return null; if (!isToday || (!upperAirSignal.source && !tafSignal.available))
return null;
const setup = String(upperAirSignal.heating_setup || "neutral").toLowerCase(); const setup = String(
upperAirSignal.heating_setup || "neutral",
).toLowerCase();
const tafSuppression = String( const tafSuppression = String(
tafSignal.suppression_level || "low", tafSignal.suppression_level || "low",
).toLowerCase(); ).toLowerCase();
@@ -1228,7 +1300,9 @@ export function FutureForecastModal() {
if (!showDeferredTodaySections) return [] as string[]; if (!showDeferredTodaySections) return [] as string[];
const commentary = detail.dynamic_commentary || {}; const commentary = detail.dynamic_commentary || {};
const headline = String( const headline = String(
locale === "en-US" ? commentary.headline_en || "" : commentary.headline_zh || "", locale === "en-US"
? commentary.headline_en || ""
: commentary.headline_zh || "",
).trim(); ).trim();
const bullets = ( const bullets = (
locale === "en-US" ? commentary.bullets_en : commentary.bullets_zh locale === "en-US" ? commentary.bullets_en : commentary.bullets_zh
@@ -1275,9 +1349,19 @@ export function FutureForecastModal() {
); );
} }
return lines.slice(0, 3); return lines.slice(0, 3);
}, [boundaryRiskView, isToday, locale, localizedAiCommentaryLines, networkLeadView, paceView, showDeferredTodaySections]); }, [
boundaryRiskView,
isToday,
locale,
localizedAiCommentaryLines,
networkLeadView,
paceView,
showDeferredTodaySections,
]);
const intradayMeteorology = detail.intraday_meteorology || {}; const intradayMeteorology = detail.intraday_meteorology || {};
const meteorologySignals = Array.isArray(intradayMeteorology.signal_contributions) const meteorologySignals = Array.isArray(
intradayMeteorology.signal_contributions,
)
? intradayMeteorology.signal_contributions ? intradayMeteorology.signal_contributions
: []; : [];
const invalidationRules = localizedList( const invalidationRules = localizedList(
@@ -1382,40 +1466,38 @@ export function FutureForecastModal() {
{ {
key: "base", key: "base",
state: isAnyLayerSyncing ? "syncing" : "ready", state: isAnyLayerSyncing ? "syncing" : "ready",
label: label: isAnyLayerSyncing
isAnyLayerSyncing ? locale === "en-US"
? locale === "en-US" ? "Refreshing base analysis"
? "Refreshing base analysis" : "正在刷新基础分析"
: "正在刷新基础分析" : locale === "en-US"
: locale === "en-US" ? "Base analysis ready" : "基础分析已加载", ? "Base analysis ready"
note: : "基础分析已加载",
isAnyLayerSyncing note: isAnyLayerSyncing
? locale === "en-US" ? locale === "en-US"
? "Latest anchor readings and forecast curve are being rebuilt." ? "Latest anchor readings and forecast curve are being rebuilt."
: "正在重建最新锚点读数和预测曲线。" : "正在重建最新锚点读数和预测曲线。"
: locale === "en-US" : locale === "en-US"
? "Forecast curve, anchor state, and the core intraday view are available." ? "Forecast curve, anchor state, and the core intraday view are available."
: "预测曲线、锚点状态和核心日内视图已经可用。", : "预测曲线、锚点状态和核心日内视图已经可用。",
}, },
{ {
key: "market", key: "market",
state: isAnyLayerSyncing ? "syncing" : "ready", state: isAnyLayerSyncing ? "syncing" : "ready",
label: label: isAnyLayerSyncing
isAnyLayerSyncing ? locale === "en-US"
? locale === "en-US" ? "Refreshing probability layer"
? "Refreshing probability layer" : "正在刷新概率层"
: "正在刷新概率层" : locale === "en-US"
: locale === "en-US" ? "Probability layer ready"
? "Probability layer ready" : "概率层已加载",
: "概率层已加载", note: isAnyLayerSyncing
note: ? locale === "en-US"
isAnyLayerSyncing ? "Model spread and calibrated buckets are updating."
? locale === "en-US" : "模型分歧和校准概率桶正在更新。"
? "Model spread and calibrated buckets are updating." : locale === "en-US"
: "模型分歧和校准概率桶正在更新。" ? `Probability buckets are derived from the ${probabilityEngineLabel} layer.`
: locale === "en-US" : `概率桶当前由 ${probabilityEngineLabel} 层推导。`,
? `Probability buckets are derived from the ${probabilityEngineLabel} layer.`
: `概率桶当前由 ${probabilityEngineLabel} 层推导。`,
}, },
] as const; ] as const;
@@ -1450,7 +1532,9 @@ export function FutureForecastModal() {
<div className="modal-header"> <div className="modal-header">
<div className="modal-title-stack"> <div className="modal-title-stack">
<div className="modal-overline"> <div className="modal-overline">
<span>{locale === "en-US" ? "Analysis workspace" : "分析工作台"}</span> <span>
{locale === "en-US" ? "Analysis workspace" : "分析工作台"}
</span>
<span className="modal-overline-sep"></span> <span className="modal-overline-sep"></span>
<span>{detail.display_name.toUpperCase()}</span> <span>{detail.display_name.toUpperCase()}</span>
</div> </div>
@@ -1534,8 +1618,15 @@ export function FutureForecastModal() {
)} )}
> >
{isTodayBlockingRefresh && ( {isTodayBlockingRefresh && (
<div className="future-v2-refresh-lock" role="status" aria-live="assertive"> <div
<span className="future-v2-refresh-spinner" aria-hidden="true" /> className="future-v2-refresh-lock"
role="status"
aria-live="assertive"
>
<span
className="future-v2-refresh-spinner"
aria-hidden="true"
/>
<div> <div>
<strong> <strong>
{locale === "en-US" {locale === "en-US"
@@ -1555,19 +1646,27 @@ export function FutureForecastModal() {
<div className="future-v2-meteorology-copy"> <div className="future-v2-meteorology-copy">
<div className="future-v2-anchor-row"> <div className="future-v2-anchor-row">
<div className="modal-section-kicker"> <div className="modal-section-kicker">
{locale === "en-US" ? "Professional meteorology read" : "专业气象判断"} {locale === "en-US"
? "Professional meteorology read"
: "专业气象判断"}
</div> </div>
<span className="future-v2-anchor-source">{anchorSourceLabel}</span> <span className="future-v2-anchor-source">
{anchorSourceLabel}
</span>
</div> </div>
<h3>{meteorologyHeadline}</h3> <h3>{meteorologyHeadline}</h3>
<p className="future-v2-anchor-rule">{anchorRuleText}</p> <p className="future-v2-anchor-rule">{anchorRuleText}</p>
<div className="future-v2-meteorology-meta"> <div className="future-v2-meteorology-meta">
<span> <span>
{locale === "en-US" ? "Confidence" : "置信度"} ·{" "} {locale === "en-US" ? "Confidence" : "置信度"} ·{" "}
{formatConfidenceLabel(intradayMeteorology.confidence, locale)} {formatConfidenceLabel(
intradayMeteorology.confidence,
locale,
)}
</span> </span>
<span> <span>
{locale === "en-US" ? "Path state" : "路径状态"} · {pathStatus} {locale === "en-US" ? "Path state" : "路径状态"} ·{" "}
{pathStatus}
</span> </span>
<span> <span>
{nextObservationLabel} · {nextObservationTime} {nextObservationLabel} · {nextObservationTime}
@@ -1587,14 +1686,20 @@ export function FutureForecastModal() {
</div> </div>
<div> <div>
<span>{locale === "en-US" ? "Upside" : "上修"}</span> <span>{locale === "en-US" ? "Upside" : "上修"}</span>
<strong>{intradayMeteorology.upside_bucket || "--"}</strong> <strong>
{intradayMeteorology.upside_bucket || "--"}
</strong>
</div> </div>
<div> <div>
<span>{locale === "en-US" ? "Downside" : "下修"}</span> <span>{locale === "en-US" ? "Downside" : "下修"}</span>
<strong>{intradayMeteorology.downside_bucket || "--"}</strong> <strong>
{intradayMeteorology.downside_bucket || "--"}
</strong>
</div> </div>
<div> <div>
<span>{locale === "en-US" ? "Gap to base" : "距基准还差"}</span> <span>
{locale === "en-US" ? "Gap to base" : "距基准还差"}
</span>
<strong>{gapToBaseText}</strong> <strong>{gapToBaseText}</strong>
</div> </div>
</div> </div>
@@ -1637,9 +1742,7 @@ export function FutureForecastModal() {
<section className="future-v2-card future-v2-hero-card"> <section className="future-v2-card future-v2-hero-card">
<div className="future-v2-card-head"> <div className="future-v2-card-head">
<h3 className="future-v2-hero-title"> <h3 className="future-v2-hero-title">
{locale === "en-US" {locale === "en-US" ? "Anchor Status" : "锚点状态"}
? "Anchor Status"
: "锚点状态"}
</h3> </h3>
<div className="future-v2-card-kicker"> <div className="future-v2-card-kicker">
{locale === "en-US" {locale === "en-US"
@@ -1669,9 +1772,7 @@ export function FutureForecastModal() {
<div className="future-v2-daylight"> <div className="future-v2-daylight">
<div className="future-v2-daylight-head"> <div className="future-v2-daylight-head">
<span> <span>
{locale === "en-US" {locale === "en-US" ? "Solar Window" : "昼夜进度"}
? "Solar Window"
: "昼夜进度"}
</span> </span>
<strong> <strong>
{locale === "en-US" {locale === "en-US"
@@ -1684,7 +1785,9 @@ export function FutureForecastModal() {
style={ style={
{ {
"--daylight-progress": `${daylightProgress.percent}%`, "--daylight-progress": `${daylightProgress.percent}%`,
} as CSSProperties & { "--daylight-progress": string } } as CSSProperties & {
"--daylight-progress": string;
}
} }
/> />
<div className="future-v2-daylight-times"> <div className="future-v2-daylight-times">
@@ -1735,9 +1838,7 @@ export function FutureForecastModal() {
<span> <span>
{locale === "en-US" ? "Sunset" : "日落时间"} {locale === "en-US" ? "Sunset" : "日落时间"}
</span> </span>
<strong> <strong>{detail.forecast?.sunset || "--"}</strong>
{detail.forecast?.sunset || "--"}
</strong>
</div> </div>
</div> </div>
</section> </section>
@@ -1835,10 +1936,17 @@ export function FutureForecastModal() {
</div> </div>
</div> </div>
<div className="future-v2-pace-signal-grid"> <div className="future-v2-pace-signal-grid">
{[boundaryRiskView, peakWindowStateView, networkLeadView] {[
boundaryRiskView,
peakWindowStateView,
networkLeadView,
]
.filter((item) => item != null) .filter((item) => item != null)
.map((item) => ( .map((item) => (
<div key={item.label} className="future-v2-pace-signal-card"> <div
key={item.label}
className="future-v2-pace-signal-card"
>
<div className="future-v2-signal-head"> <div className="future-v2-signal-head">
<span>{item.label}</span> <span>{item.label}</span>
<em <em
@@ -1879,7 +1987,6 @@ export function FutureForecastModal() {
</div> </div>
</section> </section>
) : null} ) : null}
</aside> </aside>
<main className="future-v2-right"> <main className="future-v2-right">
@@ -1894,11 +2001,23 @@ export function FutureForecastModal() {
: "今日气温路径(锚点观测 + 模型)"} : "今日气温路径(锚点观测 + 模型)"}
</h3> </h3>
</div> </div>
<DailyTemperatureChart dateStr={dateStr} forceToday={isToday} /> <DailyTemperatureChart
dateStr={dateStr}
forceToday={isToday}
/>
<div className="future-v2-chart-thresholds"> <div className="future-v2-chart-thresholds">
<span>{locale === "en-US" ? "Base" : "基准"} · {baseCaseBucket || "--"}</span> <span>
<span>{locale === "en-US" ? "Upside" : "上修"} · {intradayMeteorology.upside_bucket || "--"}</span> {locale === "en-US" ? "Base" : "基准"} ·{" "}
<span>{locale === "en-US" ? "Invalidates at" : "失效观察"} · {nextObservationTime}</span> {baseCaseBucket || "--"}
</span>
<span>
{locale === "en-US" ? "Upside" : "上修"} ·{" "}
{intradayMeteorology.upside_bucket || "--"}
</span>
<span>
{locale === "en-US" ? "Invalidates at" : "失效观察"} ·{" "}
{nextObservationTime}
</span>
</div> </div>
</section> </section>
@@ -1908,7 +2027,11 @@ export function FutureForecastModal() {
<div className="modal-section-kicker"> <div className="modal-section-kicker">
{locale === "en-US" ? "Evidence chain" : "气象证据链"} {locale === "en-US" ? "Evidence chain" : "气象证据链"}
</div> </div>
<h3>{locale === "en-US" ? "Signal Contributions" : "信号贡献"}</h3> <h3>
{locale === "en-US"
? "Signal Contributions"
: "信号贡献"}
</h3>
</div> </div>
<div className="future-v2-evidence-list"> <div className="future-v2-evidence-list">
{meteorologySignals.length > 0 ? ( {meteorologySignals.length > 0 ? (
@@ -1922,15 +2045,30 @@ export function FutureForecastModal() {
> >
<div className="future-v2-evidence-head"> <div className="future-v2-evidence-head">
<strong> <strong>
{localizedText(locale, signal.label, signal.label_en) || "--"} {localizedText(
locale,
signal.label,
signal.label_en,
) || "--"}
</strong> </strong>
<span> <span>
{formatSignalDirection(signal.direction, locale)} ·{" "} {formatSignalDirection(
{formatSignalStrength(signal.strength, locale)} signal.direction,
locale,
)}{" "}
·{" "}
{formatSignalStrength(
signal.strength,
locale,
)}
</span> </span>
</div> </div>
<p> <p>
{localizedText(locale, signal.summary, signal.summary_en) || "--"} {localizedText(
locale,
signal.summary,
signal.summary_en,
) || "--"}
</p> </p>
</div> </div>
)) ))
@@ -1949,7 +2087,11 @@ export function FutureForecastModal() {
<div className="modal-section-kicker"> <div className="modal-section-kicker">
{locale === "en-US" ? "Failure modes" : "失效条件"} {locale === "en-US" ? "Failure modes" : "失效条件"}
</div> </div>
<h3>{locale === "en-US" ? "What Downgrades the Read" : "什么会让判断降级"}</h3> <h3>
{locale === "en-US"
? "What Downgrades the Read"
: "什么会让判断降级"}
</h3>
</div> </div>
<ul className="future-v2-rule-list"> <ul className="future-v2-rule-list">
{(invalidationRules.length > 0 {(invalidationRules.length > 0
@@ -1970,7 +2112,11 @@ export function FutureForecastModal() {
<div className="modal-section-kicker"> <div className="modal-section-kicker">
{locale === "en-US" ? "Confirmation" : "确认条件"} {locale === "en-US" ? "Confirmation" : "确认条件"}
</div> </div>
<h3>{locale === "en-US" ? "What Confirms the Path" : "什么会确认主路径"}</h3> <h3>
{locale === "en-US"
? "What Confirms the Path"
: "什么会确认主路径"}
</h3>
</div> </div>
<ul className="future-v2-rule-list"> <ul className="future-v2-rule-list">
{(confirmationRules.length > 0 {(confirmationRules.length > 0
@@ -1998,11 +2144,12 @@ export function FutureForecastModal() {
<div className="modal-section-kicker"> <div className="modal-section-kicker">
{locale === "en-US" ? "Probability read" : "概率判断"} {locale === "en-US" ? "Probability read" : "概率判断"}
</div> </div>
<h3> <h3>{probabilityTitle}</h3>
{probabilityTitle}
</h3>
</div> </div>
<div className="future-text-block" style={{ marginBottom: "12px" }}> <div
className="future-text-block"
style={{ marginBottom: "12px" }}
>
{probabilitySummary} {probabilitySummary}
</div> </div>
<div style={{ position: "relative", minHeight: "120px" }}> <div style={{ position: "relative", minHeight: "120px" }}>
@@ -2020,10 +2167,15 @@ export function FutureForecastModal() {
{locale === "en-US" ? "Model layer" : "模型层"} {locale === "en-US" ? "Model layer" : "模型层"}
</div> </div>
<h3> <h3>
{locale === "en-US" ? "Model Range & Spread" : "模型区间与分歧"} {locale === "en-US"
? "Model Range & Spread"
: "模型区间与分歧"}
</h3> </h3>
</div> </div>
<div className="future-text-block" style={{ marginBottom: "12px" }}> <div
className="future-text-block"
style={{ marginBottom: "12px" }}
>
{modelSummary} {modelSummary}
</div> </div>
<ModelForecast <ModelForecast
@@ -2033,7 +2185,6 @@ export function FutureForecastModal() {
/> />
</section> </section>
</div> </div>
</main> </main>
</div> </div>
) : ( ) : (
@@ -2072,7 +2223,10 @@ export function FutureForecastModal() {
<section className="future-modal-section"> <section className="future-modal-section">
<h3>{t("future.targetTempTrend")}</h3> <h3>{t("future.targetTempTrend")}</h3>
<DailyTemperatureChart dateStr={dateStr} forceToday={isToday} /> <DailyTemperatureChart
dateStr={dateStr}
forceToday={isToday}
/>
</section> </section>
<div className="future-modal-grid"> <div className="future-modal-grid">
@@ -2101,4 +2255,3 @@ export function FutureForecastModal() {
</div> </div>
); );
} }
@@ -18,7 +18,7 @@ function clamp(value: number, min: number, max: number) {
} }
function getToneColor(tone: string) { function getToneColor(tone: string) {
if (tone === "cyan") return "#22d3ee"; if (tone === "cyan") return "#00E0A4";
if (tone === "blue") return "#60a5fa"; if (tone === "blue") return "#60a5fa";
if (tone === "amber") return "#f59e0b"; if (tone === "amber") return "#f59e0b";
return "#94a3b8"; return "#94a3b8";
@@ -77,7 +77,7 @@ export function IntradaySignalScene({
const ring = new THREE.Mesh( const ring = new THREE.Mesh(
new THREE.TorusGeometry(2.8, 0.03, 18, 100), new THREE.TorusGeometry(2.8, 0.03, 18, 100),
new THREE.MeshBasicMaterial({ new THREE.MeshBasicMaterial({
color: new THREE.Color(score >= 0 ? "#22d3ee" : "#f59e0b"), color: new THREE.Color(score >= 0 ? "#00E0A4" : "#FFB020"),
transparent: true, transparent: true,
opacity: 0.5, opacity: 0.5,
}), }),
@@ -134,7 +134,13 @@ export function IntradaySignalScene({
glow.position.set(mesh.position.x, 0.06, 0); glow.position.set(mesh.position.x, 0.06, 0);
stage.add(glow); stage.add(glow);
bars.push({ mesh, cap, glow, baseY: cap.position.y, targetHeight: height }); bars.push({
mesh,
cap,
glow,
baseY: cap.position.y,
targetHeight: height,
});
}); });
const resize = () => { const resize = () => {
@@ -195,7 +201,11 @@ export function IntradaySignalScene({
return ( return (
<div className="intraday-scene-shell"> <div className="intraday-scene-shell">
<div ref={containerRef} className="intraday-scene-frame" aria-hidden="true" /> <div
ref={containerRef}
className="intraday-scene-frame"
aria-hidden="true"
/>
<div className="intraday-scene-legend"> <div className="intraday-scene-legend">
{metrics.slice(0, 4).map((metric) => ( {metrics.slice(0, 4).map((metric) => (
<div key={metric.key} className="intraday-scene-chip"> <div key={metric.key} className="intraday-scene-chip">
+229 -185
View File
@@ -80,7 +80,9 @@ function getMarketYesPrice(scan?: MarketScan | null) {
} }
function isFahrenheitSymbol(symbol?: string | null) { function isFahrenheitSymbol(symbol?: string | null) {
return String(symbol || "").toUpperCase().includes("F"); return String(symbol || "")
.toUpperCase()
.includes("F");
} }
function displayTempToMarketCelsius( function displayTempToMarketCelsius(
@@ -120,17 +122,25 @@ function getMarketBucketUnit(bucket?: MarketTopBucket | null) {
} }
function isMarketBucketAbove(bucket?: MarketTopBucket | null) { function isMarketBucketAbove(bucket?: MarketTopBucket | null) {
const text = `${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}` const text =
.toLowerCase() `${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
.replace(/\s+/g, ""); .toLowerCase()
return text.includes("+") || text.includes("orhigher") || text.includes("or-higher"); .replace(/\s+/g, "");
return (
text.includes("+") ||
text.includes("orhigher") ||
text.includes("or-higher")
);
} }
function isMarketBucketBelow(bucket?: MarketTopBucket | null) { function isMarketBucketBelow(bucket?: MarketTopBucket | null) {
const text = `${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}` const text =
.toLowerCase() `${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
.replace(/\s+/g, ""); .toLowerCase()
return text.includes("<=") || text.includes("orlower") || text.includes("or-lower"); .replace(/\s+/g, "");
return (
text.includes("<=") || text.includes("orlower") || text.includes("or-lower")
);
} }
function findMarketBucketForDisplayTemp( function findMarketBucketForDisplayTemp(
@@ -182,11 +192,14 @@ function marketBucketContainsDisplayTemp(
displayTemp: number | null, displayTemp: number | null,
detail: Pick<CityDetail, "temp_symbol">, detail: Pick<CityDetail, "temp_symbol">,
) { ) {
if (!bucket || displayTemp == null || !Number.isFinite(displayTemp)) return false; if (!bucket || displayTemp == null || !Number.isFinite(displayTemp))
return false;
const bucketUnit = getMarketBucketUnit(bucket); const bucketUnit = getMarketBucketUnit(bucket);
const compareTemp = const compareTemp =
bucketUnit === "F" ? displayTemp : displayTempToMarketCelsius(displayTemp, detail); bucketUnit === "F"
? displayTemp
: displayTempToMarketCelsius(displayTemp, detail);
if (compareTemp == null) return false; if (compareTemp == null) return false;
const lower = bucket.lower != null ? Number(bucket.lower) : null; const lower = bucket.lower != null ? Number(bucket.lower) : null;
@@ -265,18 +278,26 @@ function formatMarketBucketDisplayLabel(
} }
const unit = const unit =
getMarketBucketUnit(bucket) === "F" || isFahrenheitSymbol(detail.temp_symbol) getMarketBucketUnit(bucket) === "F" ||
isFahrenheitSymbol(detail.temp_symbol)
? "°F" ? "°F"
: "°C"; : "°C";
const lower = bucket.lower != null ? Number(bucket.lower) : null; const lower = bucket.lower != null ? Number(bucket.lower) : null;
const upper = bucket.upper != null ? Number(bucket.upper) : null; const upper = bucket.upper != null ? Number(bucket.upper) : null;
if (lower != null && upper != null && Number.isFinite(lower) && Number.isFinite(upper)) { if (
lower != null &&
upper != null &&
Number.isFinite(lower) &&
Number.isFinite(upper)
) {
return `${lower}-${upper}${unit}`; return `${lower}-${upper}${unit}`;
} }
const value = bucket.value ?? bucket.temp ?? lower; const value = bucket.value ?? bucket.temp ?? lower;
const numeric = value != null ? Number(value) : null; const numeric = value != null ? Number(value) : null;
if (numeric != null && Number.isFinite(numeric)) { if (numeric != null && Number.isFinite(numeric)) {
return isMarketBucketAbove(bucket) ? `${numeric}${unit}+` : `${numeric}${unit}`; return isMarketBucketAbove(bucket)
? `${numeric}${unit}+`
: `${numeric}${unit}`;
} }
return "--"; return "--";
} }
@@ -321,7 +342,8 @@ function getModelGroupMeta(
) { ) {
return { return {
key: "north-america", key: "north-america",
label: locale === "en-US" ? "North America high-resolution" : "北美高分辨率", label:
locale === "en-US" ? "North America high-resolution" : "北美高分辨率",
order: 3, order: 3,
tone: "amber", tone: "amber",
}; };
@@ -477,7 +499,7 @@ function getMarketTopBuckets(scan?: MarketScan | null) {
.filter( .filter(
(item): item is MarketTopBucket & { probability: number } => (item): item is MarketTopBucket & { probability: number } =>
item.probability != null, item.probability != null,
); );
} }
function getMarketAllBuckets(scan?: MarketScan | null) { function getMarketAllBuckets(scan?: MarketScan | null) {
@@ -530,11 +552,11 @@ function formatProbabilityEngineLabel(
) { ) {
const view = getProbabilityView(detail, targetDate); const view = getProbabilityView(detail, targetDate);
if (hasLgbmModel(detail, targetDate)) { if (hasLgbmModel(detail, targetDate)) {
return locale === "en-US" return locale === "en-US" ? "LGBM-calibrated probability" : "LGBM 校准概率";
? "LGBM-calibrated probability"
: "LGBM 校准概率";
} }
const engine = String(view.engine || "").trim().toLowerCase(); const engine = String(view.engine || "")
.trim()
.toLowerCase();
const calibrationMode = String(view.calibrationMode || "") const calibrationMode = String(view.calibrationMode || "")
.trim() .trim()
.toLowerCase(); .toLowerCase();
@@ -555,9 +577,7 @@ export function HeroSummary() {
const settlementSourceCode = normalizeObservationSourceCode( const settlementSourceCode = normalizeObservationSourceCode(
current.settlement_source || "metar", current.settlement_source || "metar",
); );
const settlementIcao = String( const settlementIcao = String(current.station_code || data.risk?.icao || "")
current.station_code || data.risk?.icao || "",
)
.trim() .trim()
.toUpperCase(); .toUpperCase();
const settlementSource = const settlementSource =
@@ -605,9 +625,7 @@ export function HeroSummary() {
<span className="label"> <span className="label">
{locale === "en-US" ? "Current Obs" : "当前实测"} {locale === "en-US" ? "Current Obs" : "当前实测"}
</span> </span>
<span className="value"> <span className="value">{currentObsText}</span>
{currentObsText}
</span>
</div> </div>
<div className="hero-item"> <div className="hero-item">
<span className="label"> <span className="label">
@@ -699,8 +717,8 @@ export function TemperatureChart() {
} }
datasets.push({ datasets.push({
backgroundColor: "#22d3ee", backgroundColor: "#00E0A4",
borderColor: "#22d3ee", borderColor: "#00E0A4",
borderWidth: 0, borderWidth: 0,
data: chartData.datasets.metarPoints, data: chartData.datasets.metarPoints,
fill: false, fill: false,
@@ -732,7 +750,7 @@ export function TemperatureChart() {
Math.abs(chartData.datasets.offset) > 0.3 Math.abs(chartData.datasets.offset) > 0.3
) { ) {
datasets.push({ datasets.push({
borderColor: "rgba(99, 102, 241, 0.2)", borderColor: "rgba(123, 97, 255, 0.2)",
borderDash: [2, 4], borderDash: [2, 4],
borderWidth: 1, borderWidth: 1,
data: chartData.datasets.temps, data: chartData.datasets.temps,
@@ -860,7 +878,9 @@ export function ProbabilityDistribution({
const topProbabilityLabel = topProbability const topProbabilityLabel = topProbability
? formatBucketDisplayLabel(topProbability, detail) ? formatBucketDisplayLabel(topProbability, detail)
: null; : null;
const topProbabilityTemp = topProbability ? getBucketTemp(topProbability) : null; const topProbabilityTemp = topProbability
? getBucketTemp(topProbability)
: null;
const probabilitiesForMarketContracts = const probabilitiesForMarketContracts =
view.probabilitiesAll?.length > 0 view.probabilitiesAll?.length > 0
? view.probabilitiesAll ? view.probabilitiesAll
@@ -955,18 +975,23 @@ export function ProbabilityDistribution({
const linkedNoAsk = linkedMarketBucket?.no_buy ?? noPriceView?.ask ?? null; const linkedNoAsk = linkedMarketBucket?.no_buy ?? noPriceView?.ask ?? null;
const linkedContractLabel = const linkedContractLabel =
topContractRow?.label || topContractRow?.label ||
(linkedMarketBucket ? formatMarketBucketDisplayLabel(linkedMarketBucket, detail) : null) || (linkedMarketBucket
? formatMarketBucketDisplayLabel(linkedMarketBucket, detail)
: null) ||
topProbabilityLabel || topProbabilityLabel ||
null; null;
const aggregatedMarketProbability = getAggregatedModelProbabilityForMarketBucket( const aggregatedMarketProbability =
probabilitiesForMarketContracts, getAggregatedModelProbabilityForMarketBucket(
linkedMarketBucket, probabilitiesForMarketContracts,
detail, linkedMarketBucket,
); detail,
);
const linkedMarketProbability = const linkedMarketProbability =
topContractRow?.probability ?? topContractRow?.probability ??
aggregatedMarketProbability ?? aggregatedMarketProbability ??
(topProbability?.probability != null ? Number(topProbability.probability) : null); (topProbability?.probability != null
? Number(topProbability.probability)
: null);
const linkedMarketProbabilityText = toPercent(linkedMarketProbability); const linkedMarketProbabilityText = toPercent(linkedMarketProbability);
const linkedMarketEdge = const linkedMarketEdge =
linkedMarketProbability != null && linkedMarketAsk != null linkedMarketProbability != null && linkedMarketAsk != null
@@ -985,28 +1010,28 @@ export function ProbabilityDistribution({
const linkedBestAsk = linkedBestSide === "no" ? linkedNoAsk : linkedMarketAsk; const linkedBestAsk = linkedBestSide === "no" ? linkedNoAsk : linkedMarketAsk;
const linkedBestEdge = const linkedBestEdge =
linkedBestSide === "no" ? linkedNoEdge : linkedMarketEdge; linkedBestSide === "no" ? linkedNoEdge : linkedMarketEdge;
const preferredPriceView = const preferredPriceView = linkedMarketBucket
linkedMarketBucket ? {
? { ask: linkedBestAsk,
ask: linkedBestAsk, edge: linkedBestEdge,
edge: linkedBestEdge, }
} : priceAnalysis?.best_side === "no"
: priceAnalysis?.best_side === "no" ? noPriceView
? noPriceView : yesPriceView;
: yesPriceView; const preferredSideLabel = linkedMarketBucket
const preferredSideLabel = ? linkedBestSide === "no"
linkedMarketBucket ? "NO"
? linkedBestSide === "no" : "YES"
? "NO" : priceAnalysis?.best_side === "no"
: "YES"
: priceAnalysis?.best_side === "no"
? locale === "en-US" ? locale === "en-US"
? "NO" ? "NO"
: "NO" : "NO"
: locale === "en-US" : locale === "en-US"
? "YES" ? "YES"
: "YES"; : "YES";
const yesDisplayPrice = linkedMarketBucket ? linkedMarketAsk : yesPriceView?.ask; const yesDisplayPrice = linkedMarketBucket
? linkedMarketAsk
: yesPriceView?.ask;
const noDisplayPrice = linkedMarketBucket ? linkedNoAsk : noPriceView?.ask; const noDisplayPrice = linkedMarketBucket ? linkedNoAsk : noPriceView?.ask;
const yesDisplayEdge = linkedMarketBucket const yesDisplayEdge = linkedMarketBucket
? linkedMarketEdge ? linkedMarketEdge
@@ -1018,7 +1043,9 @@ export function ProbabilityDistribution({
Boolean(marketScan) || Boolean(marketScan) ||
Boolean(topProbability)); Boolean(topProbability));
const lockEdge = normalizeSignedProbability(priceAnalysis?.lock?.edge); const lockEdge = normalizeSignedProbability(priceAnalysis?.lock?.edge);
const lockAvailable = Boolean(priceAnalysis?.lock?.available && lockEdge != null); const lockAvailable = Boolean(
priceAnalysis?.lock?.available && lockEdge != null,
);
const quoteSource = const quoteSource =
linkedMarketBucket?.quote_source || linkedMarketBucket?.quote_source ||
marketScan?.yes_token?.quote_source || marketScan?.yes_token?.quote_source ||
@@ -1047,53 +1074,54 @@ export function ProbabilityDistribution({
linkedNoEdge != null && linkedNoEdge != null &&
linkedNoEdge > 0; linkedNoEdge > 0;
const linkedContractOverpay = const linkedContractOverpay =
linkedContractOverpriced && linkedMarketProbability != null && linkedMarketAsk != null linkedContractOverpriced &&
linkedMarketProbability != null &&
linkedMarketAsk != null
? Number(linkedMarketAsk) - linkedMarketProbability ? Number(linkedMarketAsk) - linkedMarketProbability
: null; : null;
const actionText = const actionText = !marketScan
!marketScan ? locale === "en-US"
? "Waiting"
: "等待"
: !marketScan.available
? locale === "en-US" ? locale === "en-US"
? "Waiting" ? "No market"
: "等待" : "无盘口"
: !marketScan.available : actionableEdge == null
? locale === "en-US" ? locale === "en-US"
? "No market" ? "No quote"
: "无盘口" : "无报价"
: actionableEdge == null : actionableEdge >= 0.02
? locale === "en-US" ? linkedContractOverpriced
? "No quote" ? locale === "en-US"
: "无报价" ? "Overpriced"
: actionableEdge >= 0.02 : "市场偏贵"
: locale === "en-US"
? `Watch ${preferredSideLabel}`
: `可关注 ${preferredSideLabel}`
: actionableEdge > 0
? linkedContractOverpriced ? linkedContractOverpriced
? locale === "en-US" ? locale === "en-US"
? "Overpriced" ? "Slightly overpriced"
: "市场偏贵" : "偏贵"
: locale === "en-US" : locale === "en-US"
? `Watch ${preferredSideLabel}` ? `Small ${preferredSideLabel}`
: `可关注 ${preferredSideLabel}` : `${preferredSideLabel} 优势较小`
: actionableEdge > 0 : locale === "en-US"
? linkedContractOverpriced ? "No clear edge"
? locale === "en-US" : "暂无优势";
? "Slightly overpriced"
: "略偏贵"
: locale === "en-US"
? `Small ${preferredSideLabel}`
: `${preferredSideLabel} 优势较小`
: locale === "en-US"
? "No clear edge"
: "暂无优势";
const actionNote = const actionNote =
linkedContractOverpriced && linkedContractOverpay != null linkedContractOverpriced && linkedContractOverpay != null
? locale === "en-US" ? locale === "en-US"
? `YES above model by ${formatSignedPercent(linkedContractOverpay)}` ? `YES above model by ${formatSignedPercent(linkedContractOverpay)}`
: `YES 高于模型 ${formatSignedPercent(linkedContractOverpay)}` : `YES 高于模型 ${formatSignedPercent(linkedContractOverpay)}`
: actionableEdge != null && actionableEdge >= 0.02 : actionableEdge != null && actionableEdge >= 0.02
? locale === "en-US" ? locale === "en-US"
? `${formatSignedPercent(actionableEdge)} vs ask` ? `${formatSignedPercent(actionableEdge)} vs ask`
: `相对买价 ${formatSignedPercent(actionableEdge)}` : `相对买价 ${formatSignedPercent(actionableEdge)}`
: locale === "en-US" : locale === "en-US"
? `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}` ? `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`
: `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`; : `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`;
return ( return (
<section className="prob-section"> <section className="prob-section">
@@ -1140,7 +1168,7 @@ export function ProbabilityDistribution({
: `市场仅作参考:最高交易温度桶 ${topMarketBucketText}` : `市场仅作参考:最高交易温度桶 ${topMarketBucketText}`
: locale === "en-US" : locale === "en-US"
? `Market reference only: this bucket ${marketYesText}` ? `Market reference only: this bucket ${marketYesText}`
: `市场仅作参考:该温度桶 ${marketYesText}`} : `市场仅作参考:该温度桶 ${marketYesText}`}
</div> </div>
)} )}
<div className="prob-distribution-panel"> <div className="prob-distribution-panel">
@@ -1164,10 +1192,14 @@ export function ProbabilityDistribution({
<EmptyState text={t("section.noProb")} /> <EmptyState text={t("section.noProb")} />
) : ( ) : (
probabilityRows.map((row, index) => { probabilityRows.map((row, index) => {
const probability = Math.round(Number(row.probability || 0) * 100); const probability = Math.round(
Number(row.probability || 0) * 100,
);
const rowMarketBucket = row.marketBucket; const rowMarketBucket = row.marketBucket;
const rowMarketPrice = const rowMarketPrice =
rowMarketBucket?.yes_buy ?? rowMarketBucket?.market_price ?? null; rowMarketBucket?.yes_buy ??
rowMarketBucket?.market_price ??
null;
const yesPriceText = toPriceCents(rowMarketPrice); const yesPriceText = toPriceCents(rowMarketPrice);
const marketTagFinal = rowMarketBucket const marketTagFinal = rowMarketBucket
? locale === "en-US" ? locale === "en-US"
@@ -1176,10 +1208,7 @@ export function ProbabilityDistribution({
: null; : null;
return ( return (
<div <div key={`${row.key || index}`} className="prob-row">
key={`${row.key || index}`}
className="prob-row"
>
<div className="prob-label">{row.label}</div> <div className="prob-label">{row.label}</div>
<div className="prob-bar-track"> <div className="prob-bar-track">
<div <div
@@ -1224,19 +1253,25 @@ export function ProbabilityDistribution({
? `${actionText}: ${linkedContractLabel || "contract bucket"}` ? `${actionText}: ${linkedContractLabel || "contract bucket"}`
: `${actionText}${linkedContractLabel || "合约桶"}` : `${actionText}${linkedContractLabel || "合约桶"}`
: preferredPriceView?.edge != null : preferredPriceView?.edge != null
? locale === "en-US" ? locale === "en-US"
? `${actionText} · edge ${formatSignedPercent(preferredPriceView.edge)}` ? `${actionText} · edge ${formatSignedPercent(preferredPriceView.edge)}`
: `${actionText} · 优势 ${formatSignedPercent(preferredPriceView.edge)}` : `${actionText} · 优势 ${formatSignedPercent(preferredPriceView.edge)}`
: locale === "en-US" : locale === "en-US"
? "Waiting for executable quote" ? "Waiting for executable quote"
: "等待可执行报价"} : "等待可执行报价"}
</strong> </strong>
</div> </div>
<div className="prob-price-grid"> <div className="prob-price-grid">
<div> <div>
<span>{locale === "en-US" ? "Contract bucket" : "合约桶口径"}</span> <span>
<strong>{linkedContractLabel || topProbabilityLabel || "--"}</strong> {locale === "en-US" ? "Contract bucket" : "合约桶口径"}
<em>{linkedMarketProbabilityText || topProbabilityText || "--"}</em> </span>
<strong>
{linkedContractLabel || topProbabilityLabel || "--"}
</strong>
<em>
{linkedMarketProbabilityText || topProbabilityText || "--"}
</em>
</div> </div>
<div> <div>
<span>{locale === "en-US" ? "Candidate" : "可关注"}</span> <span>{locale === "en-US" ? "Candidate" : "可关注"}</span>
@@ -1333,30 +1368,32 @@ export function ModelForecast({
const sortedEntries = modelEntries.sort( const sortedEntries = modelEntries.sort(
(a, b) => Number(b[1] || 0) - Number(a[1] || 0), (a, b) => Number(b[1] || 0) - Number(a[1] || 0),
); );
const groupedEntries = sortedEntries.reduce( const groupedEntries = sortedEntries
(acc, [name, value]) => { .reduce(
const group = getModelGroupMeta(name, modelMetadata, locale); (acc, [name, value]) => {
const existing = acc.find((item) => item.key === group.key); const group = getModelGroupMeta(name, modelMetadata, locale);
const entry = { const existing = acc.find((item) => item.key === group.key);
metaLine: formatModelMetaLine(name, modelMetadata, locale), const entry = {
name, metaLine: formatModelMetaLine(name, modelMetadata, locale),
value: Number(value), name,
}; value: Number(value),
if (existing) { };
existing.entries.push(entry); if (existing) {
} else { existing.entries.push(entry);
acc.push({ ...group, entries: [entry] }); } else {
} acc.push({ ...group, entries: [entry] });
return acc; }
}, return acc;
[] as Array<{ },
entries: Array<{ metaLine: string; name: string; value: number }>; [] as Array<{
key: string; entries: Array<{ metaLine: string; name: string; value: number }>;
label: string; key: string;
order: number; label: string;
tone: string; order: number;
}>, tone: string;
).sort((a, b) => a.order - b.order); }>,
)
.sort((a, b) => a.order - b.order);
const spread = const spread =
numericValues.length >= 2 numericValues.length >= 2
? Math.max(...numericValues) - Math.min(...numericValues) ? Math.max(...numericValues) - Math.min(...numericValues)
@@ -1491,7 +1528,10 @@ export function ForecastTable() {
const isForecastCompleting = const isForecastCompleting =
store.loadingState.cityDetail && store.loadingState.cityDetail &&
(data.detail_depth !== "full" || isSparseDaily); (data.detail_depth !== "full" || isSparseDaily);
const resolveForecastTemp = (date: string, fallback: number | null | undefined) => { const resolveForecastTemp = (
date: string,
fallback: number | null | undefined,
) => {
const debPrediction = data.multi_model_daily?.[date]?.deb?.prediction; const debPrediction = data.multi_model_daily?.[date]?.deb?.prediction;
return debPrediction ?? fallback ?? null; return debPrediction ?? fallback ?? null;
}; };
@@ -1513,62 +1553,66 @@ export function ForecastTable() {
{daily.length === 0 ? ( {daily.length === 0 ? (
<EmptyState text={t("forecast.empty")} /> <EmptyState text={t("forecast.empty")} />
) : ( ) : (
daily.map((day, index) => { daily
const isToday = day.date === data.local_date || index === 0; .map((day, index) => {
const isSelected = const isToday = day.date === data.local_date || index === 0;
(isToday && const isSelected =
store.forecastModalMode === "today" && (isToday &&
Boolean(store.futureModalDate)) || store.forecastModalMode === "today" &&
(store.forecastModalMode !== "today" && Boolean(store.futureModalDate)) ||
store.futureModalDate === day.date) || (store.forecastModalMode !== "today" &&
store.selectedForecastDate === day.date; store.futureModalDate === day.date) ||
return ( store.selectedForecastDate === day.date;
<button return (
key={day.date} <button
type="button" key={day.date}
className={clsx( type="button"
"forecast-day", className={clsx(
isToday && "today", "forecast-day",
isSelected && "selected", isToday && "today",
)} isSelected && "selected",
onClick={() => { )}
startTransition(() => { onClick={() => {
if (isToday) { startTransition(() => {
store.openTodayModal(); if (isToday) {
return; store.openTodayModal();
} return;
store.openFutureModal(day.date); }
}); store.openFutureModal(day.date);
}} });
> }}
<div className="f-date"> >
{isToday <div className="f-date">
? t("forecast.today") {isToday
: day.date.substring(5).replace("-", "/")} ? t("forecast.today")
</div> : day.date.substring(5).replace("-", "/")}
<div className="f-temp"> </div>
{resolveForecastTemp(day.date, day.max_temp)} <div className="f-temp">
{data.temp_symbol} {resolveForecastTemp(day.date, day.max_temp)}
</div> {data.temp_symbol}
</button> </div>
); </button>
}).concat( );
isForecastCompleting })
? Array.from({ length: Math.max(0, 5 - daily.length) }).map((_, index) => ( .concat(
<button isForecastCompleting
key={`forecast-sync-${index}`} ? Array.from({ length: Math.max(0, 5 - daily.length) }).map(
type="button" (_, index) => (
className="forecast-day forecast-day-sync" <button
disabled key={`forecast-sync-${index}`}
> type="button"
<div className="f-date"> className="forecast-day forecast-day-sync"
{locale === "en-US" ? "Syncing" : "同步中"} disabled
</div> >
<div className="f-temp">--</div> <div className="f-date">
</button> {locale === "en-US" ? "Syncing" : "同步中"}
)) </div>
: [], <div className="f-temp">--</div>
) </button>
),
)
: [],
)
)} )}
</div> </div>
</section> </section>
@@ -142,15 +142,11 @@ function buildSnapshot(
detail?.risk?.level; detail?.risk?.level;
const hitRate = Number(city.deb_recent_hit_rate ?? 0); const hitRate = Number(city.deb_recent_hit_rate ?? 0);
const sampleCount = Number(city.deb_recent_sample_count ?? 0); const sampleCount = Number(city.deb_recent_sample_count ?? 0);
const edgePercent = Number(getMarketEdgeValue(detail)); const normalizedEdge = normalizeEdgePercent(getMarketEdgeValue(detail));
const normalizedEdge =
Number.isFinite(edgePercent) && Math.abs(edgePercent) <= 1
? edgePercent * 100
: edgePercent;
const tradableOpportunity = isTradableMarketOpportunity(detail); const tradableOpportunity = isTradableMarketOpportunity(detail);
const score = const score =
(detail?.market_scan && !tradableOpportunity ? -10_000 : 0) + (detail?.market_scan && !tradableOpportunity ? -10_000 : 0) +
(tradableOpportunity && Number.isFinite(normalizedEdge) (tradableOpportunity && normalizedEdge != null
? 1000 + normalizedEdge * 10 ? 1000 + normalizedEdge * 10
: 0) + : 0) +
(RISK_SCORE[String(tier || "")] || 0) * 100 + (RISK_SCORE[String(tier || "")] || 0) * 100 +
@@ -305,6 +301,75 @@ function getBestSide(detail?: CityDetail | null) {
return null; return null;
} }
function getSideEdgePercent(detail: CityDetail | null | undefined, side: "yes" | "no") {
const sideAnalysis = detail?.market_scan?.price_analysis?.[side];
return normalizeEdgePercent(sideAnalysis?.edge_percent ?? sideAnalysis?.edge);
}
function getExecutableEdgePercent(detail?: CityDetail | null) {
const bestSide = getBestSide(detail);
if (bestSide) {
const bestEdge = getSideEdgePercent(detail, bestSide);
if (bestEdge != null) return bestEdge;
}
const sideEdges = [
getSideEdgePercent(detail, "yes"),
getSideEdgePercent(detail, "no"),
].filter((value): value is number => value != null && value > 0);
if (sideEdges.length) return Math.max(...sideEdges);
return normalizeEdgePercent(detail?.market_scan?.edge_percent);
}
function getBestSideModelProbability(detail?: CityDetail | null) {
const marketScan = detail?.market_scan;
const pYes = normalizeProbabilityValue(
marketScan?.price_analysis?.model_probability ??
marketScan?.model_probability ??
marketScan?.temperature_bucket?.probability,
);
if (pYes == null) return null;
return getBestSide(detail) === "no" ? 1 - pYes : pYes;
}
function getBestSideAsk(detail?: CityDetail | null) {
const marketScan = detail?.market_scan;
const side = getBestSide(detail);
if (side === "yes") {
return (
marketScan?.yes_buy ??
marketScan?.yes_token?.buy_price ??
marketScan?.price_analysis?.yes?.ask
);
}
if (side === "no") {
return (
marketScan?.no_buy ??
marketScan?.no_token?.buy_price ??
marketScan?.price_analysis?.no?.ask
);
}
return marketScan?.market_price ?? marketScan?.midpoint ?? null;
}
function getTradeSideLabel(side: "yes" | "no" | null, locale: string) {
if (side === "yes") return locale === "en-US" ? "BUY YES" : "买 YES";
if (side === "no") return locale === "en-US" ? "BUY NO" : "买 NO";
return locale === "en-US" ? "WATCH" : "观察";
}
function getTradeFormulaLabel(side: "yes" | "no" | null, locale: string) {
if (side === "no") {
return locale === "en-US"
? "Edge = EMOS P(NO) - NO ask"
: "Edge = EMOS P(NO) - NO 买价";
}
return locale === "en-US"
? "Edge = EMOS P(YES) - YES ask"
: "Edge = EMOS P(YES) - YES 买价";
}
function isLiveMarketScan(detail?: CityDetail | null) { function isLiveMarketScan(detail?: CityDetail | null) {
const marketScan = detail?.market_scan; const marketScan = detail?.market_scan;
if (!marketScan) return false; if (!marketScan) return false;
@@ -640,13 +705,6 @@ type HomeTrendChart = {
label: string; label: string;
temperatureText: string; temperatureText: string;
}>; }>;
hoverPoints: Array<{
cx: number;
cy: number;
key: string;
label: string;
temperatureText: string;
}>;
yAxisLabels: Array<{ key: string; label: string; y: number }>; yAxisLabels: Array<{ key: string; label: string; y: number }>;
xAxisLabels: Array<{ key: string; label: string; x: number }>; xAxisLabels: Array<{ key: string; label: string; x: number }>;
}; };
@@ -904,23 +962,6 @@ function buildHomeTrendChart(
}); });
const hoverSource = const hoverSource =
observationSeries.length > 0 ? observationSeries : forecastSeries; observationSeries.length > 0 ? observationSeries : forecastSeries;
const hoverPoints = hoverSource.map((point, index) => {
const projected = projectHomeTrendPoint(
point.x,
point.y,
chartData.xMin,
chartData.xMax,
chartData.min,
chartData.max,
);
return {
cx: projected.cx,
cy: projected.cy,
key: `hover-${point.labelTime}-${index}`,
label: point.labelTime,
temperatureText: formatTemperature(point.y, detail.temp_symbol || "°C"),
};
});
const hourlyReports = hoverSource const hourlyReports = hoverSource
.filter((point) => { .filter((point) => {
const label = String(point.labelTime || ""); const label = String(point.labelTime || "");
@@ -985,7 +1026,6 @@ function buildHomeTrendChart(
legendText: chartData.legendText, legendText: chartData.legendText,
observationDots, observationDots,
hourlyReports, hourlyReports,
hoverPoints,
yAxisLabels, yAxisLabels,
xAxisLabels, xAxisLabels,
}; };
@@ -1061,14 +1101,7 @@ function isBoundaryMarketPrice(value: number | null | undefined) {
} }
function getMarketEdgeValue(detail?: CityDetail | null) { function getMarketEdgeValue(detail?: CityDetail | null) {
const marketScan = detail?.market_scan; return getExecutableEdgePercent(detail);
return (
marketScan?.edge_percent ??
marketScan?.price_analysis?.yes?.edge_percent ??
marketScan?.price_analysis?.yes?.edge ??
marketScan?.price_analysis?.no?.edge_percent ??
marketScan?.price_analysis?.no?.edge
);
} }
function isTradableMarketOpportunity(detail?: CityDetail | null) { function isTradableMarketOpportunity(detail?: CityDetail | null) {
@@ -1110,19 +1143,13 @@ function isTradableMarketOpportunity(detail?: CityDetail | null) {
return false; return false;
} }
if (isBoundaryMarketPrice(marketPrice)) return false; if (isBoundaryMarketPrice(marketPrice)) return false;
return Number.isFinite(Number(getMarketEdgeValue(detail))); const executableEdge = getExecutableEdgePercent(detail);
return executableEdge != null && executableEdge > 0;
} }
function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) { function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
const store = useDashboardStore(); const store = useDashboardStore();
const { locale } = useI18n(); const { locale } = useI18n();
const [hoveredTrendPoint, setHoveredTrendPoint] = useState<{
cx: number;
cy: number;
key: string;
label: string;
temperatureText: string;
} | null>(null);
const spotlight = useMemo( const spotlight = useMemo(
() => () =>
store.selectedCity store.selectedCity
@@ -1449,39 +1476,6 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
</span> </span>
))} ))}
</div> </div>
{trendChart.hoverPoints.map((point) => (
<button
key={point.key}
type="button"
className="home-intraday-hotspot"
style={{ left: `${point.cx}px`, top: `${point.cy}px` }}
aria-label={`${point.label} ${point.temperatureText}`}
onMouseEnter={() => setHoveredTrendPoint(point)}
onMouseLeave={() =>
setHoveredTrendPoint((current) =>
current?.key === point.key ? null : current,
)
}
onFocus={() => setHoveredTrendPoint(point)}
onBlur={() =>
setHoveredTrendPoint((current) =>
current?.key === point.key ? null : current,
)
}
/>
))}
{hoveredTrendPoint ? (
<div
className="home-intraday-tooltip"
style={{
left: `${Math.min(248, Math.max(48, hoveredTrendPoint.cx))}px`,
top: `${Math.max(8, hoveredTrendPoint.cy - 34)}px`,
}}
>
<strong>{hoveredTrendPoint.temperatureText}</strong>
<span>{hoveredTrendPoint.label}</span>
</div>
) : null}
</div> </div>
{trendChart.hourlyReports.length ? ( {trendChart.hourlyReports.length ? (
<div className="home-intraday-reports"> <div className="home-intraday-reports">
@@ -1677,16 +1671,15 @@ function OpportunityStrip({
bestTradableSnapshot.detail, bestTradableSnapshot.detail,
) )
: "--"; : "--";
const highCount = snapshots.filter( const quoteReadyCount = liveSnapshots.filter((snapshot) => {
(snapshot) => snapshot.city.deb_recent_tier === "high", const marketScan = snapshot.detail?.market_scan;
).length; return Boolean(
const mediumCount = snapshots.filter( marketScan?.price_analysis?.available ||
(snapshot) => snapshot.city.deb_recent_tier === "medium", marketScan?.yes_buy != null ||
).length; marketScan?.no_buy != null,
const lowCount = snapshots.filter( );
(snapshot) => snapshot.city.deb_recent_tier === "low", }).length;
).length; const noEdgeCount = Math.max(quoteReadyCount - tradableSnapshots.length, 0);
const noEdgeCount = Math.max(completedCount - tradableSnapshots.length, 0);
return { return {
items: tradableSnapshots.slice(0, 5), items: tradableSnapshots.slice(0, 5),
@@ -1705,29 +1698,49 @@ function OpportunityStrip({
bestTradableCityName, bestTradableCityName,
summaryCards: [ summaryCards: [
{ {
key: "scan-progress", key: "data-chain",
title: locale === "en-US" ? "Scan Progress" : "扫描进度", title: locale === "en-US" ? "Data Chain" : "数据链路",
items: [ items: [
{ {
label: locale === "en-US" ? "Completed" : "已完成", label: locale === "en-US" ? "Markets" : "市场列表",
value: `${completedCount}/${totalCount}`, value: "Gamma",
accent: "cyan" as const, accent: "cyan" as const,
}, },
{ {
label: locale === "en-US" ? "Scanning" : "扫描中", label: locale === "en-US" ? "Quotes" : "盘口价格",
value: String(pendingCount), value: "CLOB",
accent: "amber" as const, accent: "green" as const,
}, },
{ {
label: locale === "en-US" ? "Errors" : "异常", label: locale === "en-US" ? "Model" : "概率模型",
value: String(errorCount), value: "EMOS",
accent: errorCount > 0 ? ("red" as const) : ("green" as const), accent: "amber" as const,
}, },
], ],
}, },
{ {
key: "market-live", key: "refresh-cadence",
title: locale === "en-US" ? "Market Status" : "盘口状态", title: locale === "en-US" ? "Refresh Cadence" : "刷新节奏",
items: [
{
label: locale === "en-US" ? "Market list" : "市场列表",
value: "60s",
accent: "green" as const,
},
{
label: locale === "en-US" ? "YES/NO ask" : "YES/NO 买价",
value: "30s",
accent: "cyan" as const,
},
{
label: locale === "en-US" ? "Mode" : "模式",
value: "REST",
},
],
},
{
key: "quote-filter",
title: locale === "en-US" ? "Quote Filter" : "报价筛选",
items: [ items: [
{ {
label: locale === "en-US" ? "Live" : "在线", label: locale === "en-US" ? "Live" : "在线",
@@ -1735,117 +1748,95 @@ function OpportunityStrip({
accent: "green" as const, accent: "green" as const,
}, },
{ {
label: locale === "en-US" ? "Tradable" : "可交易", label: locale === "en-US" ? "Quoted" : "已报价",
value: String(tradableSnapshots.length), value: String(quoteReadyCount),
accent: "cyan" as const, accent: "cyan" as const,
}, },
{ {
label: locale === "en-US" ? "No edge" : "无机会", label: locale === "en-US" ? "No edge" : "无正 edge",
value: String(noEdgeCount), value: String(noEdgeCount),
accent: "amber" as const,
}, },
], ],
}, },
{ {
key: "market-quality", key: "edge-decision",
title: locale === "en-US" ? "Opportunity Quality" : "机会质量", title: locale === "en-US" ? "Decision" : "交易决策",
items: [ items: [
{ {
label: locale === "en-US" ? "Avg. edge" : "平均优势", label: locale === "en-US" ? "Actionable" : "可买",
value: formatEdge(avgTradableEdge), value: String(tradableSnapshots.length),
accent: "green" as const, accent: "green" as const,
}, },
{ {
label: locale === "en-US" ? "Best edge" : "最高优势", label: locale === "en-US" ? "Avg edge" : "平均 edge",
value: bestTradableSnapshot value: formatEdge(avgTradableEdge),
? formatEdge(getMarketEdgeValue(bestTradableSnapshot.detail)) accent: avgTradableEdge != null ? ("cyan" as const) : undefined,
: "--",
accent: "cyan" as const,
}, },
{ {
label: locale === "en-US" ? "Focus" : "最佳城市", label: locale === "en-US" ? "Focus" : "当前标的",
value: bestTradableCityName, value: bestTradableCityName,
accent: "amber" as const, accent: "amber" as const,
}, },
], ],
}, },
{
key: "risk-summary",
title: locale === "en-US" ? "Risk Layer" : "风险层",
items: [
{
label: locale === "en-US" ? "High" : "高",
value: String(highCount),
accent: "red" as const,
},
{
label: locale === "en-US" ? "Medium" : "中",
value: String(mediumCount),
accent: "amber" as const,
},
{
label: locale === "en-US" ? "Low" : "低",
value: String(lowCount),
accent: "green" as const,
},
],
},
], ],
overviewKicker: overviewKicker:
locale === "en-US" ? "EMOS Market Scanner" : "EMOS 交易扫描台", locale === "en-US" ? "EMOS / CLOB Decision Layer" : "EMOS / CLOB 交易决策层",
headingTitle: headingTitle:
tradableSnapshots.length > 0 tradableSnapshots.length > 0
? locale === "en-US" ? locale === "en-US"
? `${tradableSnapshots.length} tradable markets · focus ${bestTradableCityName}` ? `${getTradeSideLabel(getBestSide(bestTradableSnapshot?.detail), locale)} · ${bestTradableCityName} · ${formatEdge(bestTradableEdge)}`
: `发现 ${tradableSnapshots.length} 个可交易市场 · 当前最佳 ${bestTradableCityName}` : `${getTradeSideLabel(getBestSide(bestTradableSnapshot?.detail), locale)} · ${bestTradableCityName} · ${formatEdge(bestTradableEdge)}`
: pendingCount > 0 : pendingCount > 0
? locale === "en-US" ? locale === "en-US"
? `Scanning ${completedCount}/${totalCount} cities` ? "Waiting for executable CLOB quotes"
: `正在扫描 ${completedCount}/${totalCount} 个城市市场层` : "等待可执行 CLOB 报价"
: locale === "en-US" : locale === "en-US"
? `Completed ${completedCount} scans · no tradable market` ? "No positive EMOS edge right now"
: `已完成 ${completedCount} 个城市扫描 · 当前无可交易市场`, : "当前没有正 EMOS edge",
overviewBody: overviewBody:
tradableSnapshots.length > 0 tradableSnapshots.length > 0
? locale === "en-US" ? locale === "en-US"
? "Use EMOS distribution against the live CLOB quote and surface the highest executable edge first." ? "Decision rule: compare the EMOS probability for the exact market bucket with the executable YES/NO ask from CLOB REST."
: "用 EMOS 概率分布对齐实时 CLOB 盘口,优先展示当前可执行 edge 最高的市场。" : "决策规则:把具体温度桶的 EMOS 概率和 CLOB REST 可买 YES/NO 价格相减,只展示正 edge 的买入方向。"
: pendingCount > 0 : pendingCount > 0
? locale === "en-US" ? locale === "en-US"
? "The opportunity layer is filling active markets and will promote the first executable setup when quotes arrive." ? "Gamma active markets are cached separately; the faster loop waits for CLOB YES/NO asks and recalculates edge locally."
: "机会层正在补齐活跃盘口,等最新报价返回后会直接抬出第一优先机会。" : "Gamma 活跃市场单独缓存;更快的价格层等待 CLOB YES/NO 买价后在本地重算 edge。"
: locale === "en-US" : locale === "en-US"
? "Scanner is live, but the current market prices do not beat the EMOS distribution yet." ? "Markets can still be live, but the current asks are not cheaper than the EMOS distribution."
: "扫描器在线,但当前盘口价格还没有跑赢 EMOS 概率分布。", : "盘口可以在线,但当前 ask 没有低于 EMOS 概率分布,先不提示买入。",
tapeStats: [ tapeStats: [
{ {
key: "coverage", key: "coverage",
label: locale === "en-US" ? "Coverage" : "覆盖城市", label: locale === "en-US" ? "Gamma" : "Gamma 市场",
value: `${completedCount}/${totalCount}`, value: "60s",
accent: "cyan" as const, accent: "cyan" as const,
}, },
{ {
key: "live", key: "live",
label: locale === "en-US" ? "Live" : "在线盘口", label: locale === "en-US" ? "CLOB asks" : "CLOB 报价",
value: String(liveSnapshots.length), value: "30s",
accent: "green" as const, accent: "green" as const,
}, },
{ {
key: "tradable", key: "tradable",
label: locale === "en-US" ? "Tradable" : "可交易", label: locale === "en-US" ? "Actionable" : "可买机会",
value: String(tradableSnapshots.length), value: String(tradableSnapshots.length),
accent: "cyan" as const, accent: "cyan" as const,
}, },
{ {
key: "avg-edge", key: "avg-edge",
label: locale === "en-US" ? "Avg edge" : "平均优势", label: locale === "en-US" ? "Best edge" : "最佳 edge",
value: formatEdge(avgTradableEdge), value: formatEdge(bestTradableEdge),
accent: avgTradableEdge != null ? ("green" as const) : ("slate" as const), accent: bestTradableEdge != null ? ("green" as const) : ("slate" as const),
}, },
], ],
yesCountLabel: yesCountLabel:
locale === "en-US" ? `YES bias ${yesCount}` : `YES 倾向 ${yesCount}`, locale === "en-US" ? `BUY YES ${yesCount}` : `YES ${yesCount}`,
noCountLabel: noCountLabel:
locale === "en-US" ? `NO bias ${noCount}` : `NO 倾向 ${noCount}`, locale === "en-US" ? `BUY NO ${noCount}` : `NO ${noCount}`,
}; };
}, [locale, marketScanStatusByCity, scanTargetNames, snapshots]); }, [locale, marketScanStatusByCity, scanTargetNames, snapshots]);
@@ -1853,24 +1844,15 @@ function OpportunityStrip({
const heroSnapshot = stripState.bestTradableSnapshot; const heroSnapshot = stripState.bestTradableSnapshot;
const heroSide = getBestSide(heroSnapshot?.detail); const heroSide = getBestSide(heroSnapshot?.detail);
const heroSignalLabel = const heroSignalLabel = getTradeSideLabel(heroSide, locale);
heroSnapshot?.detail?.market_scan?.signal_label ||
(heroSide === "yes"
? locale === "en-US"
? "BUY YES"
: "买入 YES"
: heroSide === "no"
? locale === "en-US"
? "BUY NO"
: "买入 NO"
: locale === "en-US"
? "MONITOR"
: "继续监控");
const heroSignalTone = heroSignalLabel.toLowerCase().includes("yes") const heroSignalTone = heroSignalLabel.toLowerCase().includes("yes")
? "yes" ? "yes"
: heroSignalLabel.toLowerCase().includes("no") : heroSignalLabel.toLowerCase().includes("no")
? "no" ? "no"
: "neutral"; : "neutral";
const heroBestAsk = getBestSideAsk(heroSnapshot?.detail);
const heroBestProbability = getBestSideModelProbability(heroSnapshot?.detail);
const heroBestEdge = getExecutableEdgePercent(heroSnapshot?.detail);
const heroSymbol = heroSnapshot const heroSymbol = heroSnapshot
? getTempSymbol(heroSnapshot.city, heroSnapshot.summary, heroSnapshot.detail) ? getTempSymbol(heroSnapshot.city, heroSnapshot.summary, heroSnapshot.detail)
: "°C"; : "°C";
@@ -1902,17 +1884,7 @@ function OpportunityStrip({
if (engine.includes("emos")) return "EMOS"; if (engine.includes("emos")) return "EMOS";
return engine.toUpperCase(); return engine.toUpperCase();
})(); })();
const heroModelProbability =
heroSnapshot?.detail?.market_scan?.model_probability ??
heroMarketBucket?.probability ??
null;
const heroMidpoint =
heroSnapshot?.detail?.market_scan?.midpoint ??
heroSnapshot?.detail?.market_scan?.market_price ??
null;
const heroSpread = heroSnapshot?.detail?.market_scan?.spread ?? null; const heroSpread = heroSnapshot?.detail?.market_scan?.spread ?? null;
const heroConfidence =
heroSnapshot?.detail?.market_scan?.confidence || null;
const heroQuoteSource = heroSnapshot?.detail?.market_scan?.quote_source const heroQuoteSource = heroSnapshot?.detail?.market_scan?.quote_source
? String(heroSnapshot.detail.market_scan.quote_source) ? String(heroSnapshot.detail.market_scan.quote_source)
.replaceAll("_", " ") .replaceAll("_", " ")
@@ -1992,7 +1964,7 @@ function OpportunityStrip({
<div className="opportunity-hero-header"> <div className="opportunity-hero-header">
<div className="opportunity-hero-copy"> <div className="opportunity-hero-copy">
<span className="opportunity-hero-kicker"> <span className="opportunity-hero-kicker">
{locale === "en-US" ? "Primary market" : "当前主机会"} {locale === "en-US" ? "Current action" : "当前交易建议"}
</span> </span>
<div className="opportunity-hero-title-row"> <div className="opportunity-hero-title-row">
<strong>{heroCityName}</strong> <strong>{heroCityName}</strong>
@@ -2025,47 +1997,47 @@ function OpportunityStrip({
<div className="opportunity-hero-body"> <div className="opportunity-hero-body">
<div className="opportunity-hero-edgeblock"> <div className="opportunity-hero-edgeblock">
<span> <span>
{locale === "en-US" ? "Executable edge" : "可执行优势"} {locale === "en-US" ? "Trade now" : "现在值得买什么"}
</span> </span>
<strong>{formatEdge(stripState.bestTradableEdge)}</strong> <strong>
{heroSide
? `${getTradeSideLabel(heroSide, locale)} ${formatCents(heroBestAsk)}`
: locale === "en-US"
? "WATCH"
: "观察"}
</strong>
<em> <em>
{heroSide === "yes" {getTradeFormulaLabel(heroSide, locale)} ·{" "}
? locale === "en-US" {locale === "en-US" ? "edge" : "优势"}{" "}
? "Prefer lifting the YES ask" {formatEdge(heroBestEdge)}
: "优先吃 YES 买价"
: heroSide === "no"
? locale === "en-US"
? "Prefer lifting the NO ask"
: "优先吃 NO 买价"
: locale === "en-US"
? "Keep monitoring executable quotes"
: "继续观察可执行报价"}
</em> </em>
</div> </div>
<div className="opportunity-hero-metrics"> <div className="opportunity-hero-metrics">
<div className="opportunity-hero-metric"> <div className="opportunity-hero-metric">
<span>{locale === "en-US" ? "EMOS prob." : "EMOS 概率"}</span> <span>
<strong>{formatProbability(heroModelProbability)}</strong> {heroSide === "no"
? locale === "en-US"
? "EMOS P(NO)"
: "EMOS P(NO)"
: locale === "en-US"
? "EMOS P(YES)"
: "EMOS P(YES)"}
</span>
<strong>{formatProbability(heroBestProbability)}</strong>
</div> </div>
<div className="opportunity-hero-metric"> <div className="opportunity-hero-metric">
<span>{locale === "en-US" ? "Midpoint" : "盘口中位"}</span> <span>{locale === "en-US" ? "CLOB ask" : "CLOB 买价"}</span>
<strong>{formatCents(heroMidpoint)}</strong> <strong>{formatCents(heroBestAsk)}</strong>
</div>
<div className="opportunity-hero-metric">
<span>{locale === "en-US" ? "Edge" : "Edge"}</span>
<strong>{formatEdge(heroBestEdge)}</strong>
</div> </div>
<div className="opportunity-hero-metric"> <div className="opportunity-hero-metric">
<span>{locale === "en-US" ? "Spread" : "盘口点差"}</span> <span>{locale === "en-US" ? "Spread" : "盘口点差"}</span>
<strong>{formatCents(heroSpread)}</strong> <strong>{formatCents(heroSpread)}</strong>
</div> </div>
<div className="opportunity-hero-metric">
<span>{locale === "en-US" ? "Confidence" : "信号强度"}</span>
<strong>
{heroConfidence
? String(heroConfidence).toUpperCase()
: locale === "en-US"
? "LIVE"
: "在线"}
</strong>
</div>
</div> </div>
<div className="opportunity-hero-book"> <div className="opportunity-hero-book">
+1 -1
View File
@@ -13,7 +13,7 @@ interface SparklineProps {
export function Sparkline({ export function Sparkline({
data, data,
color = "#22d3ee", color = "#00E0A4",
height = 40, height = 40,
width = 120, width = 120,
strokeWidth = 2, strokeWidth = 2,
+51 -38
View File
@@ -23,33 +23,32 @@ export type WeatherAuraEffect =
const RISK_AURA: Record< const RISK_AURA: Record<
string, string,
Omit<WeatherAuraProfile, "intensity" | "drift" | "effect" | "effectIntensity"> Omit<WeatherAuraProfile, "intensity" | "drift" | "effect" | "effectIntensity">
> = > = {
{ high: {
high: { primary: "#ff7c2a",
primary: "#ff7c2a", secondary: "#ffcf66",
secondary: "#ffcf66", tertiary: "#56c7ff",
tertiary: "#56c7ff", particleOpacity: 0.42,
particleOpacity: 0.42, },
}, medium: {
medium: { primary: "#f6c453",
primary: "#f6c453", secondary: "#5eead4",
secondary: "#5eead4", tertiary: "#7dd3fc",
tertiary: "#7dd3fc", particleOpacity: 0.34,
particleOpacity: 0.34, },
}, low: {
low: { primary: "#38bdf8",
primary: "#38bdf8", secondary: "#00E0A4",
secondary: "#22d3ee", tertiary: "#34d399",
tertiary: "#34d399", particleOpacity: 0.28,
particleOpacity: 0.28, },
}, default: {
default: { primary: "#6ee7ff",
primary: "#6ee7ff", secondary: "#7c8dff",
secondary: "#7c8dff", tertiary: "#60a5fa",
tertiary: "#60a5fa", particleOpacity: 0.3,
particleOpacity: 0.3, },
}, };
};
function clamp(value: number, min: number, max: number) { function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
@@ -61,7 +60,9 @@ export function getWeatherAuraProfile(
): WeatherAuraProfile { ): WeatherAuraProfile {
const dominantRisk = const dominantRisk =
String(detail?.risk?.level || "").toLowerCase() || String(detail?.risk?.level || "").toLowerCase() ||
String(cities.find((city) => city.risk_level)?.risk_level || "").toLowerCase() || String(
cities.find((city) => city.risk_level)?.risk_level || "",
).toLowerCase() ||
"default"; "default";
const base = RISK_AURA[dominantRisk] || RISK_AURA.default; const base = RISK_AURA[dominantRisk] || RISK_AURA.default;
@@ -69,7 +70,11 @@ export function getWeatherAuraProfile(
const validTemp = Number.isFinite(currentTemp) ? currentTemp : 18; const validTemp = Number.isFinite(currentTemp) ? currentTemp : 18;
const intensity = clamp(0.7 + validTemp / 40, 0.72, 1.45); const intensity = clamp(0.7 + validTemp / 40, 0.72, 1.45);
const drift = clamp(0.45 + validTemp / 30, 0.55, 1.35); const drift = clamp(0.45 + validTemp / 30, 0.55, 1.35);
const particleOpacity = clamp(base.particleOpacity + (intensity - 1) * 0.08, 0.22, 0.48); const particleOpacity = clamp(
base.particleOpacity + (intensity - 1) * 0.08,
0.22,
0.48,
);
const wxText = `${String(detail?.current?.wx_desc || "")} ${String( const wxText = `${String(detail?.current?.wx_desc || "")} ${String(
detail?.current?.cloud_desc || "", detail?.current?.cloud_desc || "",
)}`.toUpperCase(); )}`.toUpperCase();
@@ -77,9 +82,7 @@ export function getWeatherAuraProfile(
const humidity = Number(detail?.current?.humidity); const humidity = Number(detail?.current?.humidity);
let effect: WeatherAuraEffect = "clear"; let effect: WeatherAuraEffect = "clear";
if ( if (/(TS|VCTS|THUNDER|雷暴|LIGHTNING)/.test(wxText)) {
/(TS|VCTS|THUNDER|雷暴|LIGHTNING)/.test(wxText)
) {
effect = "storm"; effect = "storm";
} else if (/(SN|SG|GS|ICE|SLEET|雪|霰)/.test(wxText)) { } else if (/(SN|SG|GS|ICE|SLEET|雪|霰)/.test(wxText)) {
effect = "snow"; effect = "snow";
@@ -87,9 +90,7 @@ export function getWeatherAuraProfile(
effect = "rain"; effect = "rain";
} else if (/(FG|BR|HZ|FU|MIST|FOG|雾|霾)/.test(wxText)) { } else if (/(FG|BR|HZ|FU|MIST|FOG|雾|霾)/.test(wxText)) {
effect = "fog"; effect = "fog";
} else if ( } else if (/(BKN|OVC|SCT|FEW|CLOUD|云|阴)/.test(wxText)) {
/(BKN|OVC|SCT|FEW|CLOUD|云|阴)/.test(wxText)
) {
effect = "cloud"; effect = "cloud";
} else if (Number.isFinite(windSpeed) && windSpeed >= 18) { } else if (Number.isFinite(windSpeed) && windSpeed >= 18) {
effect = "wind"; effect = "wind";
@@ -113,11 +114,23 @@ export function getWeatherAuraProfile(
1.7, 1.7,
) )
: effect === "snow" : effect === "snow"
? clamp(0.8 + (Number.isFinite(windSpeed) ? windSpeed / 50 : 0), 0.78, 1.4) ? clamp(
0.8 + (Number.isFinite(windSpeed) ? windSpeed / 50 : 0),
0.78,
1.4,
)
: effect === "fog" : effect === "fog"
? clamp(0.78 + (Number.isFinite(humidity) ? humidity / 240 : 0), 0.75, 1.3) ? clamp(
0.78 + (Number.isFinite(humidity) ? humidity / 240 : 0),
0.75,
1.3,
)
: effect === "wind" : effect === "wind"
? clamp(0.72 + (Number.isFinite(windSpeed) ? windSpeed / 40 : 0), 0.72, 1.5) ? clamp(
0.72 + (Number.isFinite(windSpeed) ? windSpeed / 40 : 0),
0.72,
1.5,
)
: effect === "cloud" : effect === "cloud"
? 0.82 ? 0.82
: 0.72; : 0.72;
+1 -1
View File
@@ -70,7 +70,7 @@ const config: Config = {
"pw-xl": "var(--radius-xl)", "pw-xl": "var(--radius-xl)",
}, },
boxShadow: { boxShadow: {
glow: "0 0 0 1px rgba(0, 229, 255, 0.15), 0 8px 32px rgba(8, 47, 73, 0.5)", glow: "0 0 0 1px rgba(0, 224, 164, 0.15), 0 8px 32px rgba(8, 47, 73, 0.5)",
"glow-accent": "var(--shadow-glow-accent)", "glow-accent": "var(--shadow-glow-accent)",
"glow-secondary": "var(--shadow-glow-secondary)", "glow-secondary": "var(--shadow-glow-secondary)",
"elevation-1": "var(--shadow-elevation-1)", "elevation-1": "var(--shadow-elevation-1)",