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