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