diff --git a/.gitignore b/.gitignore
index 4d57e657..4207e34f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,6 +42,8 @@ frontend/.vercel/
frontend/*.tsbuildinfo
frontend/.codex-next-dev*.log
frontend/.codex-next-start*.log
+frontend/.next-dev.log
+frontend/.next-start.log
.codex-backend-*.log
.npm-cache/
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index de9ee406..b29fe2a6 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -9,28 +9,28 @@
@layer base {
:root {
/* ── Background Scale ── */
- --color-bg-base: #080c14;
- --color-bg-raised: #0d1321;
- --color-bg-overlay: #111827;
- --color-bg-card: rgba(15, 23, 42, 0.72);
- --color-bg-input: rgba(255, 255, 255, 0.04);
+ --color-bg-base: #0B1220;
+ --color-bg-raised: #111A2E;
+ --color-bg-overlay: #16213A;
+ --color-bg-card: rgba(17, 26, 46, 0.88);
+ --color-bg-input: rgba(22, 33, 58, 0.72);
/* ── Text Scale ── */
- --color-text-primary: #f1f5f9;
- --color-text-secondary: #94a3b8;
- --color-text-muted: #64748b;
- --color-text-disabled: #475569;
+ --color-text-primary: #E6EDF3;
+ --color-text-secondary: #9FB2C7;
+ --color-text-muted: #6B7A90;
+ --color-text-disabled: #6B7A90;
/* ── Accent Colors (Fintech 3-Color) ── */
- --color-accent-primary: #00e0a4;
- --color-accent-secondary: #7b61ff;
- --color-accent-tertiary: #a78bfa;
+ --color-accent-primary: #4DA3FF;
+ --color-accent-secondary: #4DA3FF;
+ --color-accent-tertiary: #93C5FD;
/* ── Signal / Semantic Colors ── */
- --color-signal-success: #00e0a4;
- --color-signal-warning: #ffb020;
- --color-signal-danger: #ff4d6a;
- --color-signal-info: #7b61ff;
+ --color-signal-success: #22C55E;
+ --color-signal-warning: #F59E0B;
+ --color-signal-danger: #EF4444;
+ --color-signal-info: #4DA3FF;
/* ── Risk Colors (aliased from signal) ── */
--color-risk-high: var(--color-signal-danger);
@@ -38,16 +38,16 @@
--color-risk-low: var(--color-signal-success);
/* ── Border ── */
- --color-border-default: rgba(123, 97, 255, 0.12);
- --color-border-hover: rgba(123, 97, 255, 0.28);
- --color-border-subtle: rgba(255, 255, 255, 0.06);
+ --color-border-default: rgba(159, 178, 199, 0.16);
+ --color-border-hover: rgba(77, 163, 255, 0.38);
+ --color-border-subtle: rgba(159, 178, 199, 0.08);
/* ── Shadow / Elevation ── */
--shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-elevation-2: 0 8px 24px rgba(0, 0, 0, 0.45);
--shadow-elevation-3: 0 20px 60px rgba(0, 0, 0, 0.6);
- --shadow-glow-accent: 0 0 20px rgba(0, 224, 164, 0.25);
- --shadow-glow-secondary: 0 0 20px rgba(123, 97, 255, 0.25);
+ --shadow-glow-accent: 0 0 20px rgba(77, 163, 255, 0.24);
+ --shadow-glow-secondary: 0 0 20px rgba(111, 183, 255, 0.22);
/* ── Typography ── */
--font-data:
@@ -182,7 +182,7 @@
}
.map-pill.low {
- background: linear-gradient(135deg, #10b981, #047857);
+ background: linear-gradient(135deg, #22C55E, #047857);
}
.map-pill.active {
@@ -329,12 +329,12 @@
}
.marker-bubble.risk-low {
- background: linear-gradient(135deg, #059669, #10b981);
+ background: linear-gradient(135deg, #059669, #22C55E);
border-color: rgba(16, 185, 129, 0.5);
}
.marker-bubble.risk-low::after {
- border-top-color: #10b981;
+ border-top-color: #22C55E;
}
.marker-name {
diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css
index bec68b28..21117160 100644
--- a/frontend/components/dashboard/Dashboard.module.css
+++ b/frontend/components/dashboard/Dashboard.module.css
@@ -4,12 +4,12 @@
.root {
/* ── Palette (bridged from globals.css unified tokens) ── */
- --bg-primary: #030711;
- --bg-secondary: #09111d;
- --bg-card: #0b1422;
- --bg-glass: rgba(8, 15, 27, 0.78);
- --border-glass: rgba(120, 146, 176, 0.18);
- --border-subtle: rgba(120, 146, 176, 0.1);
+ --bg-primary: #0B1220;
+ --bg-secondary: #16213A;
+ --bg-card: #111A2E;
+ --bg-glass: rgba(17, 26, 46, 0.82);
+ --border-glass: rgba(159, 178, 199, 0.16);
+ --border-subtle: rgba(159, 178, 199, 0.08);
/* Text */
--text-primary: var(--color-text-primary);
@@ -17,13 +17,13 @@
--text-muted: var(--color-text-muted);
/* Accents — Fintech 3-Color Model */
- --accent-cyan: #00e0a4;
- --accent-blue: #7b61ff;
- --accent-green: #00e0a4;
- --accent-orange: #ffb020;
- --accent-red: #ff4d6a;
- --accent-yellow: #ffb020;
- --accent-purple: #7b61ff;
+ --accent-cyan: #4DA3FF;
+ --accent-blue: #4DA3FF;
+ --accent-green: #22C55E;
+ --accent-orange: #F59E0B;
+ --accent-red: #EF4444;
+ --accent-yellow: #F59E0B;
+ --accent-purple: #4DA3FF;
/* Risk colors */
--risk-high: var(--color-risk-high);
@@ -66,41 +66,41 @@
background-image:
radial-gradient(
circle at 18% 18%,
- rgba(0, 224, 164, 0.08),
+ rgba(77, 163, 255, 0.09),
transparent 24%
),
radial-gradient(
circle at 82% 12%,
- rgba(123, 97, 255, 0.06),
+ rgba(111, 183, 255, 0.05),
transparent 20%
),
- linear-gradient(180deg, #040914 0%, #02050d 100%);
+ linear-gradient(180deg, #0B1220 0%, #07101D 100%);
}
:global(html.light) .root {
- --bg-primary: #edf5ff;
- --bg-secondary: #f7fbff;
+ --bg-primary: #F7F9FC;
+ --bg-secondary: #EEF2F7;
--bg-card: #ffffff;
- --bg-glass: rgba(248, 252, 255, 0.84);
- --border-glass: rgba(48, 77, 112, 0.18);
- --border-subtle: rgba(48, 77, 112, 0.1);
- --text-primary: #0b1726;
- --text-secondary: #42546c;
- --text-muted: #6b7b91;
- background: #edf5ff;
- color: #0b1726;
+ --bg-glass: rgba(255, 255, 255, 0.88);
+ --border-glass: #E2E8F0;
+ --border-subtle: rgba(226, 232, 240, 0.74);
+ --text-primary: #0F172A;
+ --text-secondary: #475569;
+ --text-muted: #94A3B8;
+ background: #F7F9FC;
+ color: #0F172A;
background-image:
radial-gradient(
circle at 18% 18%,
- rgba(0, 224, 164, 0.12),
+ rgba(59, 130, 246, 0.1),
transparent 25%
),
radial-gradient(
circle at 82% 12%,
- rgba(123, 97, 255, 0.1),
+ rgba(22, 163, 74, 0.07),
transparent 22%
),
- linear-gradient(180deg, #f8fbff 0%, #e9f3ff 100%);
+ linear-gradient(180deg, #F7F9FC 0%, #EEF2F7 100%);
}
:global(html.light) .root :global(.header),
@@ -774,7 +774,7 @@
}
.root :global(.city-item .city-deviation-cold) {
- color: #38bdf8;
+ color: #4DA3FF;
}
.root :global(.city-item .city-deviation-hot) {
@@ -1217,7 +1217,7 @@
border: 1px solid rgba(34, 211, 238, 0.18);
border-radius: 999px;
background: rgba(8, 145, 178, 0.1);
- color: #67e8f9;
+ color: #6FB7FF;
font-size: 10px;
font-weight: 900;
letter-spacing: 0.12em;
@@ -1310,7 +1310,7 @@
.root :global(.home-weather-label) {
margin-bottom: 8px;
- color: #67e8f9;
+ color: #6FB7FF;
font-size: 10px;
font-weight: 900;
letter-spacing: 0.14em;
@@ -1377,7 +1377,7 @@
.root :global(.home-weather-icon .cloud) {
position: absolute;
border-radius: 999px;
- background: linear-gradient(180deg, #b8c4d2, #64748b);
+ background: linear-gradient(180deg, #b8c4d2, #6B7A90);
filter: drop-shadow(0 10px 18px rgba(0, 0, 0, 0.28));
}
@@ -1401,7 +1401,7 @@
width: 4px;
height: 13px;
border-radius: 999px;
- background: #38bdf8;
+ background: #4DA3FF;
transform: rotate(18deg);
}
@@ -1594,7 +1594,7 @@
.root :global(.home-deb-card span),
.root :global(.home-card-section h3) {
- color: #67e8f9;
+ color: #6FB7FF;
font-size: 12px;
font-weight: 850;
}
@@ -1716,7 +1716,7 @@
}
.root :global(.home-intraday-chart circle) {
- fill: #38bdf8;
+ fill: #4DA3FF;
stroke: rgba(15, 23, 42, 0.92);
stroke-width: 1.6;
filter: drop-shadow(0 0 10px rgba(56, 189, 248, 0.28));
@@ -1943,7 +1943,7 @@
}
.root :global(.home-market-prices .yes) {
- color: #67e8f9;
+ color: #6FB7FF;
background: rgba(8, 145, 178, 0.2);
}
@@ -2126,7 +2126,7 @@
}
.root :global(.opportunity-tape-pill.accent-cyan strong) {
- color: #67e8f9;
+ color: #6FB7FF;
}
.root :global(.opportunity-tape-pill.accent-green strong) {
@@ -2217,7 +2217,7 @@
}
.root :global(.opportunity-hero-kicker) {
- color: #67e8f9;
+ color: #6FB7FF;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
@@ -2279,7 +2279,7 @@
.root :global(.opportunity-hero-tag.engine) {
border-color: rgba(34, 211, 238, 0.24);
- color: #67e8f9;
+ color: #6FB7FF;
}
.root :global(.opportunity-hero-tag.signal-yes) {
@@ -3111,7 +3111,7 @@
position: sticky;
top: 16px;
width: auto;
- min-width: 0;
+ min-width: 380px;
height: calc(100vh - 32px);
min-height: 0;
max-height: calc(100vh - 32px);
@@ -3512,7 +3512,7 @@
padding: 3px 8px;
border: 1px solid rgba(34, 211, 238, 0.28);
border-radius: 8px;
- color: #67e8f9;
+ color: #6FB7FF;
background: rgba(34, 211, 238, 0.08);
font-size: 11px;
font-weight: 900;
@@ -4216,7 +4216,7 @@
0 0 22px rgba(74, 222, 128, 0.14);
}
.root :global(.marker-bubble.risk-low::after) {
- border-top-color: #10b981;
+ border-top-color: #22C55E;
}
.root :global(.marker-name) {
@@ -4417,7 +4417,7 @@
width: 14px;
height: 14px;
border-radius: 50%;
- background: radial-gradient(circle, #67e8f9 0%, #0891b2 100%);
+ background: radial-gradient(circle, #6FB7FF 0%, #0891b2 100%);
transform: translate(-50%, -50%);
box-shadow:
0 0 12px rgba(34, 211, 238, 0.65),
@@ -5122,7 +5122,7 @@
.root :global(.scan-select) {
border: none;
border-radius: 8px;
- background: linear-gradient(135deg, #00e0a4, #00b383);
+ background: linear-gradient(135deg, #4DA3FF, #00b383);
color: #000;
font-weight: 700;
cursor: pointer;
@@ -5165,7 +5165,7 @@
}
.root :global(.modal-content) {
- background: #111827;
+ background: #16213A;
border: 1px solid var(--border-subtle);
border-radius: 16px;
width: 100%;
@@ -6053,7 +6053,7 @@
border: 1px solid rgba(34, 211, 238, 0.28);
border-radius: 8px;
background: rgba(8, 47, 73, 0.36);
- color: #67e8f9;
+ color: #6FB7FF;
font-size: 11px;
font-weight: 800;
line-height: 1.25;
@@ -6132,7 +6132,7 @@
.root :global(.future-v2-decision-anchor small) {
display: block;
margin-top: 6px;
- color: #67e8f9;
+ color: #6FB7FF;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
@@ -6156,7 +6156,7 @@
.root :global(.future-v2-decision-grid strong) {
display: block;
margin-top: 7px;
- color: #67e8f9;
+ color: #6FB7FF;
font-size: 18px;
font-weight: 800;
line-height: 1;
@@ -6187,7 +6187,7 @@
.root :global(.future-v2-meteorology-paths strong) {
display: block;
margin-top: 8px;
- color: #67e8f9;
+ color: #6FB7FF;
font-size: 22px;
font-weight: 800;
line-height: 1;
@@ -6213,7 +6213,7 @@
width: 9px;
height: 9px;
border-radius: 999px;
- background: #34d399;
+ background: #22C55E;
margin-top: 5px;
flex: 0 0 auto;
box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.12);
@@ -6508,7 +6508,7 @@
}
.root :global(.future-v2-signal-tag.cyan) {
- color: #67e8f9;
+ color: #6FB7FF;
border-color: rgba(34, 211, 238, 0.22);
background: rgba(34, 211, 238, 0.08);
}
@@ -6597,7 +6597,7 @@
}
.root :global(.future-v2-pace-delta.cold) {
- color: #67e8f9;
+ color: #6FB7FF;
}
.root :global(.future-v2-pace-delta.neutral) {
@@ -6672,11 +6672,11 @@
}
.root :global(.future-v2-pace-meter-fill.cold) {
- background: linear-gradient(90deg, #0f766e 0%, #67e8f9 100%);
+ background: linear-gradient(90deg, #0f766e 0%, #6FB7FF 100%);
}
.root :global(.future-v2-pace-meter-fill.neutral) {
- background: linear-gradient(90deg, #475569 0%, #cbd5e1 100%);
+ background: linear-gradient(90deg, #6B7A90 0%, #cbd5e1 100%);
}
.root :global(.intraday-scene-shell) {
@@ -7189,7 +7189,7 @@
left: 0;
bottom: 0;
border-radius: inherit;
- background: linear-gradient(90deg, #334155 0%, #94a3b8 100%);
+ background: linear-gradient(90deg, #334155 0%, #9FB2C7 100%);
}
.root :global(.future-trend-meter-fill.warm) {
@@ -7229,7 +7229,7 @@
}
.root :global(.future-trend-value.warm) {
- color: #34d399;
+ color: #22C55E;
}
.root :global(.future-trend-value.cold) {
@@ -7608,23 +7608,60 @@
.root :global(.detail-mini-chart-wrap) {
display: grid;
- gap: 8px;
+ gap: 10px;
}
.root :global(.detail-mini-chart) {
position: relative;
- height: 190px;
+ height: 210px;
border: 1px solid var(--border-subtle);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 10px;
}
+.root :global(.scan-city-detail-rail .detail-mini-chart) {
+ height: 230px;
+}
+
.root :global(.detail-mini-chart canvas) {
width: 100% !important;
height: 100% !important;
}
+.root :global(.detail-mini-chart-legend) {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 12px;
+ color: var(--text-muted);
+ font-size: 11px;
+ line-height: 1.4;
+}
+
+.root :global(.detail-mini-chart-legend span) {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+
+.root :global(.detail-mini-chart-legend i) {
+ width: 16px;
+ height: 3px;
+ border-radius: 999px;
+ flex: 0 0 auto;
+}
+
+.root :global(.detail-mini-chart-legend i.forecast) {
+ background: rgba(52, 211, 153, 0.86);
+}
+
+.root :global(.detail-mini-chart-legend i.observation) {
+ width: 7px;
+ height: 7px;
+ background: #4DA3FF;
+}
+
.root :global(.detail-mini-meta) {
color: var(--text-muted);
font-size: 11px;
@@ -7966,7 +8003,7 @@
.root :global(.home-ai-message small) {
display: inline-block;
margin-top: 6px;
- color: #67e8f9;
+ color: #6FB7FF;
font-size: 10px;
font-weight: 800;
}
@@ -8180,7 +8217,7 @@
.root :global(.scan-mode-tab.active) {
background: rgba(0, 224, 164, 0.08);
- color: #00e0a4;
+ color: #4DA3FF;
font-weight: 600;
}
@@ -8191,7 +8228,7 @@
top: 25%;
height: 50%;
width: 3px;
- background: #00e0a4;
+ background: #4DA3FF;
border-radius: 0 2px 2px 0;
}
@@ -8232,7 +8269,7 @@
appearance: none;
width: 14px;
height: 14px;
- background: #00e0a4;
+ background: #4DA3FF;
border-radius: 50%;
cursor: pointer;
border: 2px solid var(--bg-secondary);
@@ -8253,7 +8290,7 @@
.root :global(.scan-toggle.active) {
background: rgba(0, 224, 164, 0.3);
- border-color: #00e0a4;
+ border-color: #4DA3FF;
}
.root :global(.scan-toggle-knob) {
@@ -8269,7 +8306,7 @@
.root :global(.scan-toggle.active .scan-toggle-knob) {
left: 18px;
- background: #00e0a4;
+ background: #4DA3FF;
}
/* Select */
@@ -8286,7 +8323,7 @@
}
.root :global(.scan-select:focus) {
- border-color: #00e0a4;
+ border-color: #4DA3FF;
}
/* Scan CTA */
@@ -8299,7 +8336,7 @@
padding: 12px 16px;
border: none;
border-radius: 10px;
- background: linear-gradient(135deg, #00e0a4, #00c48f);
+ background: linear-gradient(135deg, #4DA3FF, #00c48f);
color: #030711;
font-size: 14px;
font-weight: 700;
@@ -8437,7 +8474,7 @@
}
.root :global(.scan-kpi-green .scan-kpi-value) {
- color: #00e0a4;
+ color: #4DA3FF;
}
.root :global(.scan-kpi-delta) {
@@ -8469,7 +8506,7 @@
}
.root :global(.scan-view-tab.active) {
- color: #00e0a4;
+ color: #4DA3FF;
font-weight: 600;
}
@@ -8480,7 +8517,7 @@
left: 0;
right: 0;
height: 2px;
- background: #00e0a4;
+ background: #4DA3FF;
border-radius: 4px;
opacity: 0.8;
transition: all 0.3s ease;
@@ -8557,17 +8594,17 @@
font-size: 12px;
font-weight: 700;
background: rgba(0, 224, 164, 0.12);
- color: #00e0a4;
+ color: #4DA3FF;
}
.root :global(.rank-amber .scan-rank-circle) {
background: rgba(255, 176, 32, 0.12);
- color: #ffb020;
+ color: #F59E0B;
}
.root :global(.rank-purple .scan-rank-circle) {
background: rgba(123, 97, 255, 0.12);
- color: #7b61ff;
+ color: #4DA3FF;
}
.root :global(.rank-neutral .scan-rank-circle) {
@@ -8654,17 +8691,17 @@
.root :global(.scan-status-badge.tone-green) {
background: rgba(0, 224, 164, 0.12);
- color: #00e0a4;
+ color: #4DA3FF;
}
.root :global(.scan-status-badge.tone-amber) {
background: rgba(255, 176, 32, 0.12);
- color: #ffb020;
+ color: #F59E0B;
}
.root :global(.scan-status-badge.tone-purple) {
background: rgba(123, 97, 255, 0.12);
- color: #7b61ff;
+ color: #4DA3FF;
}
.root :global(.scan-status-badge.tone-neutral) {
@@ -8719,7 +8756,7 @@
.root :global(.scan-action-text) {
font-size: 12px;
font-weight: 600;
- color: #00e0a4;
+ color: #4DA3FF;
white-space: nowrap;
}
@@ -8731,7 +8768,7 @@
}
.root :global(.scan-edge-value.positive) {
- color: #00e0a4;
+ color: #4DA3FF;
}
.root :global(.scan-edge-value.neutral) {
@@ -8762,7 +8799,7 @@
}
.root :global(.scan-fav-icon:hover) {
- color: #ffb020;
+ color: #F59E0B;
}
/* ── Right: Scan Detail Panel ── */
@@ -8869,11 +8906,11 @@
}
.root :global(.scan-condition-value.accent-green) {
- color: #00e0a4;
+ color: #4DA3FF;
}
.root :global(.scan-condition-value.accent-red) {
- color: #ff4d6a;
+ color: #EF4444;
}
/* Trade Cards */
@@ -8894,11 +8931,11 @@
}
.root :global(.scan-trade-card.yes) {
- border-top: 2px solid #00e0a4;
+ border-top: 2px solid #4DA3FF;
}
.root :global(.scan-trade-card.no) {
- border-top: 2px solid #ff4d6a;
+ border-top: 2px solid #EF4444;
}
.root :global(.scan-trade-card-title) {
@@ -8918,11 +8955,11 @@
}
.root :global(.scan-trade-card-edge.positive) {
- color: #00e0a4;
+ color: #4DA3FF;
}
.root :global(.scan-trade-card-edge.negative) {
- color: #ff4d6a;
+ color: #EF4444;
}
.root :global(.scan-trade-card-note) {
@@ -8971,15 +9008,15 @@
}
.root :global(.scan-confidence-dot.filled) {
- background: #00e0a4;
+ background: #4DA3FF;
}
.root :global(.scan-confidence-dot.filled.amber) {
- background: #ffb020;
+ background: #F59E0B;
}
.root :global(.scan-confidence-dot.filled.red) {
- background: #ff4d6a;
+ background: #EF4444;
}
/* Empty state */
@@ -9000,7 +9037,7 @@
.root :global(.scan-terminal) {
display: grid;
- grid-template-columns: minmax(0, 1fr) 324px;
+ grid-template-columns: minmax(0, 1fr) minmax(380px, 420px);
gap: 14px;
width: 100%;
min-height: 100vh;
@@ -9008,8 +9045,8 @@
margin-top: 0;
padding: 16px;
background:
- radial-gradient(circle at top, rgba(17, 44, 78, 0.46), transparent 36%),
- linear-gradient(180deg, #07111f 0%, #050c16 100%);
+ radial-gradient(circle at top, rgba(77, 163, 255, 0.12), transparent 36%),
+ linear-gradient(180deg, #0B1220 0%, #07101D 100%);
overflow: auto;
}
@@ -9017,9 +9054,9 @@
.root :global(.scan-data-grid),
.root :global(.scan-detail-panel) {
min-height: calc(100vh - 32px);
- border: 1px solid rgba(82, 114, 161, 0.18);
+ border: 1px solid rgba(159, 178, 199, 0.14);
border-radius: 22px;
- background: linear-gradient(180deg, rgba(15, 28, 47, 0.94), rgba(9, 18, 32, 0.94));
+ background: linear-gradient(180deg, rgba(17, 26, 46, 0.96), rgba(11, 18, 32, 0.96));
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(14px);
}
@@ -9027,7 +9064,7 @@
.root :global(.scan-filter-panel) {
width: auto;
min-width: 0;
- border-right: 1px solid rgba(82, 114, 161, 0.18);
+ border-right: 1px solid rgba(159, 178, 199, 0.14);
padding: 18px 14px;
gap: 18px;
overflow: hidden auto;
@@ -9374,14 +9411,14 @@
gap: 8px;
min-height: 42px;
padding: 11px 16px;
- border: 1px solid rgba(23, 217, 139, 0.38);
+ border: 1px solid rgba(77, 163, 255, 0.42);
border-radius: 12px;
- background: linear-gradient(180deg, #1ddb8d, #0eb96d);
- color: #031411;
+ background: linear-gradient(180deg, #4DA3FF, #3B82F6);
+ color: #FFFFFF;
font-size: 14px;
font-weight: 900;
text-decoration: none;
- box-shadow: 0 10px 24px rgba(14, 185, 109, 0.18);
+ box-shadow: 0 10px 24px rgba(59, 130, 246, 0.22);
cursor: pointer;
}
@@ -9400,18 +9437,18 @@
gap: 8px;
min-height: 42px;
padding: 11px 14px;
- border: 1px solid rgba(94, 234, 212, 0.3);
+ border: 1px solid rgba(77, 163, 255, 0.3);
border-radius: 12px;
- background: linear-gradient(180deg, rgba(20, 184, 166, 0.22), rgba(14, 116, 144, 0.18));
- color: #a7fff2;
+ background: linear-gradient(180deg, rgba(77, 163, 255, 0.18), rgba(59, 130, 246, 0.12));
+ color: #CFE6FF;
font-size: 13px;
font-weight: 900;
cursor: pointer;
}
.root :global(.scan-ai-button:hover:not(:disabled)) {
- border-color: rgba(94, 234, 212, 0.5);
- background: linear-gradient(180deg, rgba(20, 184, 166, 0.32), rgba(14, 116, 144, 0.24));
+ border-color: rgba(111, 183, 255, 0.5);
+ background: linear-gradient(180deg, rgba(77, 163, 255, 0.26), rgba(59, 130, 246, 0.18));
}
.root :global(.scan-ai-button:disabled) {
@@ -9420,7 +9457,7 @@
}
.root :global(.scan-cta-ghost) {
- color: #27ea98;
+ color: #6FB7FF;
border-color: rgba(23, 217, 139, 0.3);
background: rgba(23, 217, 139, 0.12);
}
@@ -9503,7 +9540,7 @@
}
.root :global(.scan-kpi-card.orange .scan-kpi-label) {
- color: #ffb020;
+ color: #F59E0B;
}
.root :global(.scan-kpi-label) {
@@ -9833,7 +9870,7 @@
}
.root :global(.scan-opportunity-item.ai-veto .scan-opportunity-action) {
- color: #94a3b8;
+ color: #9FB2C7;
}
.root :global(.scan-opportunity-branch) {
@@ -10088,7 +10125,7 @@
.root :global(.scan-v4-analysis strong) {
display: block;
- color: #72f3d1;
+ color: #6FB7FF;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.02em;
@@ -10362,7 +10399,7 @@
.root :global(.scan-v4-heading strong),
.root :global(.scan-v4-current b),
.root :global(.scan-v4-brief-grid strong) {
- color: #72f3d1;
+ color: #6FB7FF;
font-size: 12px;
font-weight: 950;
letter-spacing: 0.02em;
@@ -10461,7 +10498,7 @@
width: 5px;
height: 5px;
border-radius: 999px;
- background: #2dd4bf;
+ background: #4DA3FF;
box-shadow: 0 0 10px rgba(45, 212, 191, 0.45);
}
@@ -10509,6 +10546,774 @@
margin-top: 10px;
}
+.root :global(.scan-forecast-desk) {
+ gap: 16px;
+ padding: 16px 18px 20px;
+}
+
+.root :global(.scan-forecast-city-card) {
+ overflow: visible;
+ border-color: rgba(159, 178, 199, 0.14);
+ border-radius: 18px;
+ background:
+ linear-gradient(180deg, rgba(17, 26, 46, 0.96), rgba(11, 18, 32, 0.98)),
+ radial-gradient(circle at 0 0, rgba(77, 163, 255, 0.08), transparent 26%);
+}
+
+.root :global(.scan-forecast-city-card.selected),
+.root :global(.scan-forecast-city-card:has(.scan-forecast-row.selected)) {
+ border-color: rgba(77, 163, 255, 0.42);
+ box-shadow:
+ inset 0 0 0 1px rgba(77, 163, 255, 0.14),
+ 0 20px 54px rgba(0, 0, 0, 0.2);
+}
+
+.root :global(.scan-forecast-city-head) {
+ grid-template-columns: minmax(0, 1fr) minmax(250px, 340px);
+ padding: 18px 20px;
+ border-bottom-color: rgba(159, 178, 199, 0.1);
+ background: rgba(22, 33, 58, 0.56);
+}
+
+.root :global(.scan-forecast-city-title) {
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+}
+
+.root :global(.scan-forecast-kicker) {
+ color: #4DA3FF;
+ font-size: 11px;
+ font-weight: 950;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.root :global(.scan-forecast-city-title strong) {
+ color: #E6EDF3;
+ font-size: 24px;
+ font-weight: 950;
+ letter-spacing: 0;
+}
+
+.root :global(.scan-forecast-city-chips) {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.root :global(.scan-forecast-city-chips span) {
+ min-height: 28px;
+ padding: 6px 9px;
+ border: 1px solid rgba(159, 178, 199, 0.14);
+ border-radius: 9px;
+ background: rgba(22, 33, 58, 0.56);
+ color: #9FB2C7;
+ font-size: 12px;
+ font-weight: 850;
+ line-height: 1.2;
+}
+
+.root :global(.scan-forecast-city-read) {
+ display: grid;
+ justify-items: end;
+ gap: 5px;
+ min-width: 0;
+ color: #9FB2C7;
+ text-align: right;
+}
+
+.root :global(.scan-forecast-city-read small) {
+ color: #6B7A90;
+ font-size: 11px;
+ font-weight: 950;
+ text-transform: uppercase;
+}
+
+.root :global(.scan-forecast-city-read > b:not(.scan-phase-badge)) {
+ color: #E6EDF3;
+ font-size: 30px;
+ font-weight: 950;
+ line-height: 1;
+}
+
+.root :global(.scan-forecast-city-read span) {
+ color: #9FB2C7;
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.root :global(.scan-forecast-row) {
+ gap: 0;
+ overflow: hidden;
+ border-color: rgba(159, 178, 199, 0.12);
+ border-radius: 16px;
+ background: rgba(17, 26, 46, 0.76);
+ transition:
+ background 0.18s ease,
+ border-color 0.18s ease,
+ box-shadow 0.18s ease;
+}
+
+.root :global(.scan-forecast-row:hover) {
+ border-color: rgba(77, 163, 255, 0.28);
+ background: rgba(22, 33, 58, 0.88);
+}
+
+.root :global(.scan-forecast-row.selected),
+.root :global(.scan-forecast-row.expanded) {
+ border-color: rgba(77, 163, 255, 0.42);
+ background:
+ linear-gradient(180deg, rgba(13, 37, 66, 0.76), rgba(11, 18, 32, 0.9)),
+ radial-gradient(circle at 0 0, rgba(77, 163, 255, 0.12), transparent 32%);
+ box-shadow: inset 4px 0 0 rgba(77, 163, 255, 0.78);
+}
+
+.root :global(.scan-forecast-row-main) {
+ display: grid;
+ grid-template-columns: minmax(260px, 1.05fr) minmax(360px, 1fr) max-content max-content;
+ gap: 12px;
+ align-items: stretch;
+ padding: 14px 16px;
+}
+
+.root :global(.scan-forecast-bucket) {
+ display: grid;
+ align-content: center;
+ gap: 4px;
+ min-width: 0;
+}
+
+.root :global(.scan-forecast-bucket span),
+.root :global(.scan-forecast-bucket small),
+.root :global(.scan-forecast-signals small),
+.root :global(.scan-ai-temperature-line small) {
+ color: #6B7A90;
+ font-size: 11px;
+ font-weight: 900;
+ letter-spacing: 0.02em;
+}
+
+.root :global(.scan-forecast-bucket strong) {
+ color: #E6EDF3;
+ font-size: 18px;
+ font-weight: 950;
+ line-height: 1.22;
+}
+
+.root :global(.scan-forecast-bucket small) {
+ color: #9FB2C7;
+ line-height: 1.45;
+}
+
+.root :global(.scan-forecast-signals) {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 8px;
+ min-width: 0;
+}
+
+.root :global(.scan-forecast-signals span) {
+ display: grid;
+ align-content: center;
+ gap: 4px;
+ min-width: 0;
+ padding: 9px 11px;
+ border: 1px solid rgba(159, 178, 199, 0.12);
+ border-radius: 11px;
+ background: rgba(22, 33, 58, 0.68);
+}
+
+.root :global(.scan-forecast-signals b) {
+ display: -webkit-box;
+ overflow: hidden;
+ color: #E6EDF3;
+ font-size: 12px;
+ font-weight: 900;
+ line-height: 1.25;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ text-overflow: ellipsis;
+ white-space: normal;
+}
+
+.root :global(.scan-forecast-fit) {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 110px;
+ min-height: 40px;
+ padding: 0 13px;
+ border: 1px solid rgba(96, 165, 250, 0.28);
+ border-radius: 999px;
+ background: rgba(59, 130, 246, 0.1);
+ color: #6FB7FF;
+ font-size: 12px;
+ font-weight: 950;
+ white-space: nowrap;
+}
+
+.root :global(.scan-forecast-fit.approve),
+.root :global(.scan-ai-forecast-pill.approve) {
+ border-color: rgba(34, 197, 94, 0.34);
+ background: rgba(34, 197, 94, 0.12);
+ color: #86EFAC;
+}
+
+.root :global(.scan-forecast-fit.downgrade),
+.root :global(.scan-forecast-fit.watchlist),
+.root :global(.scan-ai-forecast-pill.downgrade),
+.root :global(.scan-ai-forecast-pill.watchlist) {
+ border-color: rgba(245, 158, 11, 0.34);
+ background: rgba(245, 158, 11, 0.12);
+ color: #F59E0B;
+}
+
+.root :global(.scan-forecast-fit.veto),
+.root :global(.scan-ai-forecast-pill.veto) {
+ border-color: rgba(248, 113, 113, 0.34);
+ background: rgba(248, 113, 113, 0.12);
+ color: #EF4444;
+}
+
+.root :global(.scan-forecast-ai-line) {
+ display: grid;
+ grid-template-columns: max-content minmax(0, 1fr);
+ gap: 10px;
+ align-items: baseline;
+ padding: 0 16px 14px;
+ color: #9FB2C7;
+}
+
+.root :global(.scan-forecast-ai-line b) {
+ color: #6FB7FF;
+ font-size: 12px;
+ font-weight: 950;
+}
+
+.root :global(.scan-forecast-ai-line small) {
+ min-width: 0;
+ color: #9FB2C7;
+ font-size: 13px;
+ font-weight: 750;
+ line-height: 1.5;
+}
+
+.root :global(.scan-ai-analysis) {
+ display: grid;
+ gap: 14px;
+ margin: 0 16px 16px;
+ padding: 16px;
+ border-top: 1px solid rgba(159, 178, 199, 0.12);
+ background: rgba(11, 18, 32, 0.38);
+}
+
+.root :global(.scan-ai-analysis-head) {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 18px;
+}
+
+.root :global(.scan-ai-analysis-head > div) {
+ display: grid;
+ gap: 7px;
+ min-width: 0;
+}
+
+.root :global(.scan-ai-analysis-head strong),
+.root :global(.scan-ai-brief-grid strong) {
+ color: #6FB7FF;
+ font-size: 12px;
+ font-weight: 950;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.root :global(.scan-ai-analysis-head p) {
+ margin: 0;
+ color: #E6EDF3;
+ font-size: 20px;
+ font-weight: 850;
+ line-height: 1.3;
+}
+
+.root :global(.scan-ai-analysis-head small) {
+ max-width: 880px;
+ color: #9FB2C7;
+ font-size: 14px;
+ font-weight: 760;
+ line-height: 1.55;
+}
+
+.root :global(.scan-ai-forecast-pill) {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ min-height: 32px;
+ padding: 0 13px;
+ border: 1px solid rgba(96, 165, 250, 0.28);
+ border-radius: 999px;
+ background: rgba(59, 130, 246, 0.1);
+ color: #6FB7FF;
+ font-size: 12px;
+ font-weight: 950;
+ white-space: nowrap;
+}
+
+.root :global(.scan-ai-temperature-line) {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px;
+ padding: 13px 0;
+ border-top: 1px solid rgba(159, 178, 199, 0.1);
+ border-bottom: 1px solid rgba(159, 178, 199, 0.1);
+}
+
+.root :global(.scan-ai-temperature-line span) {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+
+.root :global(.scan-ai-temperature-line b) {
+ overflow: hidden;
+ color: #E6EDF3;
+ font-size: 17px;
+ font-weight: 950;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.root :global(.scan-ai-evidence-line) {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.root :global(.scan-ai-evidence-line span) {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+ padding: 10px 12px;
+ border: 1px solid rgba(159, 178, 199, 0.1);
+ border-radius: 10px;
+ background: rgba(22, 33, 58, 0.54);
+}
+
+.root :global(.scan-ai-evidence-line small) {
+ color: #6B7A90;
+ font-size: 11px;
+ font-weight: 900;
+}
+
+.root :global(.scan-ai-evidence-line b) {
+ display: -webkit-box;
+ overflow: hidden;
+ color: #E6EDF3;
+ font-size: 13px;
+ font-weight: 900;
+ line-height: 1.3;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ text-overflow: ellipsis;
+ white-space: normal;
+}
+
+.root :global(.scan-ai-workspace) {
+ min-height: 720px;
+ border: 1px solid rgba(77, 163, 255, 0.16);
+ border-radius: 18px;
+ background: #0b1220;
+ padding: 18px;
+}
+
+.root :global(.scan-ai-workspace.empty) {
+ display: grid;
+ place-items: center;
+}
+
+.root :global(.scan-ai-workspace-head) {
+ display: flex;
+ justify-content: space-between;
+ gap: 18px;
+ align-items: flex-end;
+ margin-bottom: 18px;
+ padding: 0 2px;
+}
+
+.root :global(.scan-ai-workspace-head span) {
+ display: block;
+ color: #4da3ff;
+ font-size: 12px;
+ font-weight: 800;
+ letter-spacing: 0;
+}
+
+.root :global(.scan-ai-workspace-head strong) {
+ display: block;
+ color: #e6edf3;
+ font-size: 22px;
+ line-height: 1.2;
+ margin-top: 4px;
+}
+
+.root :global(.scan-ai-workspace-head p) {
+ max-width: 560px;
+ margin: 0;
+ color: #9fb2c7;
+ font-size: 13px;
+ line-height: 1.55;
+ text-align: right;
+}
+
+.root :global(.scan-ai-city-stack) {
+ display: grid;
+ gap: 18px;
+}
+
+.root :global(.scan-ai-city-card) {
+ overflow: hidden;
+ border: 1px solid rgba(77, 163, 255, 0.35);
+ border-radius: 18px;
+ background: #111a2e;
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.24);
+}
+
+.root :global(.scan-ai-city-hero) {
+ display: flex;
+ justify-content: space-between;
+ gap: 18px;
+ padding: 22px;
+ background:
+ linear-gradient(135deg, rgba(77, 163, 255, 0.11), rgba(34, 197, 94, 0.03)),
+ #111a2e;
+ border-bottom: 1px solid rgba(159, 178, 199, 0.12);
+}
+
+.root :global(.scan-ai-city-kicker) {
+ color: #4da3ff;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.root :global(.scan-ai-city-hero h3) {
+ margin: 8px 0 12px;
+ color: #e6edf3;
+ font-size: 28px;
+ line-height: 1.1;
+}
+
+.root :global(.scan-ai-city-pills) {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.root :global(.scan-ai-city-pills span) {
+ border: 1px solid rgba(159, 178, 199, 0.14);
+ border-radius: 10px;
+ background: rgba(11, 18, 32, 0.58);
+ color: #c9d7e8;
+ font-size: 12px;
+ font-weight: 800;
+ padding: 7px 10px;
+}
+
+.root :global(.scan-ai-city-hero-side) {
+ min-width: 180px;
+ display: grid;
+ justify-items: end;
+ align-content: start;
+ gap: 6px;
+ text-align: right;
+}
+
+.root :global(.scan-ai-city-hero-side > span) {
+ color: #9fb2c7;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.root :global(.scan-ai-city-hero-side > strong) {
+ color: #e6edf3;
+ font-size: 30px;
+ line-height: 1;
+}
+
+.root :global(.scan-ai-city-remove),
+.root :global(.scan-ai-city-price-button) {
+ min-height: 36px;
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ border: 1px solid rgba(77, 163, 255, 0.32);
+ border-radius: 10px;
+ background: rgba(77, 163, 255, 0.1);
+ color: #9ecbff;
+ font-size: 12px;
+ font-weight: 800;
+ cursor: pointer;
+ transition: background 0.18s ease, border-color 0.18s ease;
+}
+
+.root :global(.scan-ai-city-remove) {
+ padding: 8px 10px;
+}
+
+.root :global(.scan-ai-city-price-button) {
+ padding: 9px 12px;
+}
+
+.root :global(.scan-ai-city-remove:hover),
+.root :global(.scan-ai-city-price-button:hover) {
+ background: rgba(77, 163, 255, 0.18);
+ border-color: rgba(111, 183, 255, 0.58);
+}
+
+.root :global(.scan-ai-city-price-button:disabled) {
+ cursor: wait;
+ opacity: 0.62;
+}
+
+.root :global(.scan-ai-city-body) {
+ padding: 18px;
+}
+
+.root :global(.scan-ai-decision-band) {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 18px;
+ align-items: center;
+ border: 1px solid rgba(77, 163, 255, 0.18);
+ border-radius: 16px;
+ background: #16213a;
+ padding: 18px;
+}
+
+.root :global(.scan-ai-decision-band.warm) {
+ border-color: rgba(239, 68, 68, 0.34);
+}
+
+.root :global(.scan-ai-decision-band.cold) {
+ border-color: rgba(34, 197, 94, 0.34);
+}
+
+.root :global(.scan-ai-decision-band span) {
+ color: #4da3ff;
+ font-size: 12px;
+ font-weight: 900;
+}
+
+.root :global(.scan-ai-decision-band strong) {
+ display: block;
+ margin-top: 5px;
+ color: #e6edf3;
+ font-size: 24px;
+ line-height: 1.2;
+}
+
+.root :global(.scan-ai-decision-band p) {
+ margin: 8px 0 0;
+ color: #9fb2c7;
+ line-height: 1.55;
+}
+
+.root :global(.scan-ai-decision-metrics) {
+ min-width: 360px;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.root :global(.scan-ai-decision-metrics span) {
+ border: 1px solid rgba(159, 178, 199, 0.14);
+ border-radius: 12px;
+ background: rgba(11, 18, 32, 0.5);
+ color: #9fb2c7;
+ padding: 12px;
+}
+
+.root :global(.scan-ai-decision-metrics b) {
+ display: block;
+ margin-top: 4px;
+ color: #e6edf3;
+ font-size: 15px;
+}
+
+.root :global(.scan-ai-city-analysis-grid) {
+ display: grid;
+ grid-template-columns: minmax(420px, 1.4fr) minmax(280px, 0.8fr);
+ gap: 14px;
+ margin-top: 14px;
+}
+
+.root :global(.scan-ai-city-section) {
+ border: 1px solid rgba(159, 178, 199, 0.12);
+ border-radius: 16px;
+ background: rgba(11, 18, 32, 0.38);
+ padding: 16px;
+}
+
+.root :global(.scan-ai-city-section-title) {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: #4da3ff;
+ font-size: 13px;
+ font-weight: 900;
+ margin-bottom: 12px;
+}
+
+.root :global(.scan-ai-city-section p) {
+ margin: 0 0 10px;
+ color: #9fb2c7;
+ font-size: 13px;
+ line-height: 1.6;
+}
+
+.root :global(.scan-ai-city-chart) {
+ height: 260px;
+}
+
+.root :global(.scan-ai-city-chart-legend) {
+ display: flex;
+ gap: 14px;
+ margin-top: 10px;
+ color: #9fb2c7;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.root :global(.scan-ai-city-chart-legend span) {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.root :global(.scan-ai-city-chart-legend i) {
+ width: 18px;
+ height: 3px;
+ border-radius: 999px;
+ background: #4da3ff;
+}
+
+.root :global(.scan-ai-city-chart-legend i.observation) {
+ width: 8px;
+ height: 8px;
+ background: #22c55e;
+}
+
+.root :global(.scan-ai-city-section.models) {
+ margin-top: 14px;
+}
+
+.root :global(.scan-ai-city-section.models .models-section) {
+ padding: 0;
+ border: 0;
+ background: transparent;
+}
+
+.root :global(.scan-ai-city-section-head) {
+ display: flex;
+ justify-content: space-between;
+ gap: 14px;
+ align-items: flex-start;
+ margin-bottom: 12px;
+}
+
+.root :global(.scan-ai-city-section.market) {
+ margin-top: 14px;
+}
+
+.root :global(.scan-ai-market-buckets) {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(155px, 1fr));
+ gap: 10px;
+}
+
+.root :global(.scan-ai-market-bucket) {
+ display: grid;
+ gap: 5px;
+ border: 1px solid rgba(159, 178, 199, 0.12);
+ border-radius: 12px;
+ background: rgba(17, 26, 46, 0.9);
+ padding: 12px;
+}
+
+.root :global(.scan-ai-market-bucket strong) {
+ color: #e6edf3;
+ font-size: 15px;
+}
+
+.root :global(.scan-ai-market-bucket span),
+.root :global(.scan-ai-city-muted),
+.root :global(.scan-ai-city-loading) {
+ color: #9fb2c7;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.root :global(.scan-ai-city-loading) {
+ padding: 28px;
+}
+
+.root :global(.scan-ai-brief-grid) {
+ display: grid;
+ grid-template-columns: minmax(0, 1.25fr) minmax(260px, 0.75fr);
+ gap: 28px;
+}
+
+.root :global(.scan-ai-brief-grid ul) {
+ display: grid;
+ gap: 8px;
+ margin: 9px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.root :global(.scan-ai-brief-grid li) {
+ position: relative;
+ padding-left: 16px;
+ color: #9FB2C7;
+ font-size: 13px;
+ font-weight: 760;
+ line-height: 1.48;
+}
+
+.root :global(.scan-ai-brief-grid li::before) {
+ position: absolute;
+ top: 0.65em;
+ left: 0;
+ width: 5px;
+ height: 5px;
+ border-radius: 999px;
+ background: #4DA3FF;
+ box-shadow: 0 0 10px rgba(77, 163, 255, 0.34);
+ content: "";
+}
+
+.root :global(.scan-ai-brief-grid section:nth-child(2) li::before) {
+ background: #F59E0B;
+ box-shadow: 0 0 10px rgba(245, 158, 11, 0.35);
+}
+
+.root :global(.scan-ai-airport-read) {
+ display: grid;
+ gap: 6px;
+ padding-top: 12px;
+ border-top: 1px solid rgba(159, 178, 199, 0.1);
+}
+
+.root :global(.scan-ai-airport-read p) {
+ margin: 0;
+ color: #9FB2C7;
+ font-size: 12px;
+ font-weight: 800;
+ line-height: 1.5;
+}
+
.root :global(.scan-table-body::-webkit-scrollbar),
.root :global(.scan-calendar-view::-webkit-scrollbar),
.root :global(.scan-detail-panel::-webkit-scrollbar) {
@@ -11702,17 +12507,17 @@
.root :global(.scan-terminal.light) {
background:
- radial-gradient(circle at top, rgba(65, 120, 190, 0.14), transparent 34%),
- linear-gradient(180deg, #f4f8fd 0%, #e9f0f8 100%);
- color: #102033;
+ radial-gradient(circle at top, rgba(59, 130, 246, 0.1), transparent 34%),
+ linear-gradient(180deg, #F7F9FC 0%, #EEF2F7 100%);
+ color: #0F172A;
}
.root :global(.scan-terminal.light .scan-filter-panel),
.root :global(.scan-terminal.light .scan-data-grid),
.root :global(.scan-terminal.light .scan-detail-panel),
.root :global(.scan-terminal.light > .detail-panel.scan-city-detail-rail) {
- border-color: rgba(35, 72, 118, 0.14);
- background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(242, 247, 253, 0.94));
+ border-color: #E2E8F0;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.96));
box-shadow: 0 16px 34px rgba(40, 70, 110, 0.12);
}
@@ -11735,7 +12540,7 @@
.root :global(.scan-terminal.light .scan-distribution-card strong),
.root :global(.scan-terminal.light .scan-table-header),
.root :global(.scan-terminal.light .scan-list-tabs button.active) {
- color: #122033;
+ color: #0F172A;
}
.root :global(.scan-terminal.light .scan-filter-heading),
@@ -11758,7 +12563,7 @@
.root :global(.scan-terminal.light .scan-chart-label),
.root :global(.scan-terminal.light .scan-trade-sub),
.root :global(.scan-terminal.light .scan-trade-note) {
- color: #58708f;
+ color: #475569;
}
.root :global(.scan-terminal.light .scan-list-tabs button),
@@ -11770,7 +12575,7 @@
.root :global(.scan-terminal.light .scan-distribution-card),
.root :global(.scan-terminal.light .scan-chart-legend),
.root :global(.scan-terminal.light .scan-kpi-note) {
- color: #647a98;
+ color: #94A3B8;
}
.root :global(.scan-terminal.light .scan-distribution-line em) {
@@ -11795,23 +12600,23 @@
.root :global(.scan-terminal.light .detail-structured-section),
.root :global(.scan-terminal.light .detail-card),
.root :global(.scan-terminal.light .scan-trade-card) {
- border-color: rgba(35, 72, 118, 0.12);
- background: rgba(255, 255, 255, 0.72);
+ border-color: #E2E8F0;
+ background: #FFFFFF;
}
.root :global(.scan-terminal.light .scan-mode-tab.active) {
- color: #096846;
- background: linear-gradient(180deg, rgba(222, 250, 238, 0.94), rgba(237, 252, 246, 0.96));
- border-color: rgba(10, 160, 100, 0.34);
- box-shadow: inset 0 0 0 1px rgba(10, 160, 100, 0.18);
+ color: #1D4ED8;
+ background: #DBEAFE;
+ border-color: rgba(59, 130, 246, 0.34);
+ box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.16);
}
.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-tab-label) {
- color: #075f43;
+ color: #1D4ED8;
}
.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-tab-sub) {
- color: #426b5f;
+ color: #475569;
}
.root :global(.scan-terminal.light .scan-calendar-subtitle),
@@ -11938,15 +12743,15 @@
}
.root :global(.scan-terminal.light .scan-primary-button) {
- color: #032019;
- border-color: rgba(10, 160, 100, 0.34);
- background: linear-gradient(180deg, #25d990, #10b870);
+ color: #FFFFFF;
+ border-color: rgba(59, 130, 246, 0.34);
+ background: linear-gradient(180deg, #3B82F6, #2563EB);
}
.root :global(.scan-terminal.light .scan-ai-button) {
- color: #075f5a;
- border-color: rgba(10, 160, 150, 0.28);
- background: linear-gradient(180deg, rgba(214, 250, 246, 0.94), rgba(231, 247, 252, 0.96));
+ color: #1D4ED8;
+ border-color: rgba(59, 130, 246, 0.28);
+ background: #DBEAFE;
}
.root :global(.scan-terminal.light .scan-opportunity-ai) {
@@ -11984,17 +12789,119 @@
.root :global(.scan-terminal.light .scan-v4-analysis p),
.root :global(.scan-terminal.light .scan-v4-analysis ul),
.root :global(.scan-terminal.light .scan-v4-evidence > div > span) {
- color: #475569;
+ color: #6B7A90;
}
.root :global(.scan-terminal.light .scan-v4-model-sources em) {
- color: #64748b;
+ color: #6B7A90;
}
.root :global(.scan-terminal.light .scan-v4-model-sources b) {
color: #0f172a;
}
+.root :global(.scan-terminal.light .scan-forecast-city-card),
+.root :global(.scan-terminal.light .scan-forecast-row),
+.root :global(.scan-terminal.light .scan-ai-analysis) {
+ border-color: #E2E8F0;
+ background: #FFFFFF;
+}
+
+.root :global(.scan-terminal.light .scan-forecast-city-head) {
+ background: #EEF2F7;
+}
+
+.root :global(.scan-terminal.light .scan-forecast-city-card.selected),
+.root :global(.scan-terminal.light .scan-forecast-city-card:has(.scan-forecast-row.selected)) {
+ border-color: rgba(59, 130, 246, 0.38);
+ box-shadow:
+ inset 0 0 0 1px rgba(59, 130, 246, 0.12),
+ 0 16px 34px rgba(40, 70, 110, 0.12);
+}
+
+.root :global(.scan-terminal.light .scan-forecast-row.selected),
+.root :global(.scan-terminal.light .scan-forecast-row.expanded) {
+ border-color: rgba(59, 130, 246, 0.38);
+ background:
+ linear-gradient(180deg, rgba(219, 234, 254, 0.62), rgba(255, 255, 255, 0.98)),
+ #FFFFFF;
+ box-shadow: inset 4px 0 0 #3B82F6;
+}
+
+.root :global(.scan-terminal.light .scan-forecast-city-title strong),
+.root :global(.scan-terminal.light .scan-forecast-city-read > b:not(.scan-phase-badge)),
+.root :global(.scan-terminal.light .scan-forecast-bucket strong),
+.root :global(.scan-terminal.light .scan-forecast-signals b),
+.root :global(.scan-terminal.light .scan-ai-temperature-line b) {
+ color: #0F172A;
+}
+
+.root :global(.scan-terminal.light .scan-forecast-city-chips span),
+.root :global(.scan-terminal.light .scan-forecast-signals span),
+.root :global(.scan-terminal.light .scan-ai-evidence-line span) {
+ border-color: #E2E8F0;
+ background: #EEF2F7;
+}
+
+.root :global(.scan-terminal.light .scan-forecast-city-chips span),
+.root :global(.scan-terminal.light .scan-forecast-city-read span),
+.root :global(.scan-terminal.light .scan-forecast-bucket small),
+.root :global(.scan-terminal.light .scan-forecast-ai-line small),
+.root :global(.scan-terminal.light .scan-ai-brief-grid li),
+.root :global(.scan-terminal.light .scan-ai-airport-read p) {
+ color: #475569;
+}
+
+.root :global(.scan-terminal.light .scan-ai-analysis-head p) {
+ color: #0F172A;
+}
+
+.root :global(.scan-terminal.light .scan-ai-analysis-head small),
+.root :global(.scan-terminal.light .scan-ai-evidence-line b) {
+ color: #475569;
+}
+
+.root :global(.scan-terminal.light .scan-ai-workspace) {
+ background: #f7f9fc;
+ border-color: #e2e8f0;
+}
+
+.root :global(.scan-terminal.light .scan-ai-city-card),
+.root :global(.scan-terminal.light .scan-ai-city-hero) {
+ background: #ffffff;
+ border-color: #e2e8f0;
+}
+
+.root :global(.scan-terminal.light .scan-ai-city-section),
+.root :global(.scan-terminal.light .scan-ai-decision-band),
+.root :global(.scan-terminal.light .scan-ai-decision-metrics span),
+.root :global(.scan-terminal.light .scan-ai-market-bucket),
+.root :global(.scan-terminal.light .scan-ai-city-pills span) {
+ background: #eef2f7;
+ border-color: #e2e8f0;
+}
+
+.root :global(.scan-terminal.light .scan-ai-workspace-head strong),
+.root :global(.scan-terminal.light .scan-ai-city-hero h3),
+.root :global(.scan-terminal.light .scan-ai-city-hero-side > strong),
+.root :global(.scan-terminal.light .scan-ai-decision-band strong),
+.root :global(.scan-terminal.light .scan-ai-decision-metrics b),
+.root :global(.scan-terminal.light .scan-ai-market-bucket strong) {
+ color: #0f172a;
+}
+
+.root :global(.scan-terminal.light .scan-ai-workspace-head p),
+.root :global(.scan-terminal.light .scan-ai-city-section p),
+.root :global(.scan-terminal.light .scan-ai-decision-band p),
+.root :global(.scan-terminal.light .scan-ai-city-pills span),
+.root :global(.scan-terminal.light .scan-ai-decision-metrics span),
+.root :global(.scan-terminal.light .scan-ai-market-bucket span),
+.root :global(.scan-terminal.light .scan-ai-city-muted),
+.root :global(.scan-terminal.light .scan-ai-city-loading),
+.root :global(.scan-terminal.light .scan-ai-city-chart-legend) {
+ color: #475569;
+}
+
.root :global(.scan-terminal.light .scan-ai-log-panel) {
border-color: rgba(35, 72, 118, 0.12);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(246, 250, 255, 0.9));
@@ -12064,7 +12971,7 @@
.root :global(.scan-terminal.light .scan-ai-city-head p),
.root :global(.scan-terminal.light .scan-ai-contract p),
.root :global(.scan-terminal.light .scan-ai-contract small) {
- color: #475569;
+ color: #6B7A90;
}
.root :global(.scan-terminal.light .scan-ai-city-head) {
@@ -12165,6 +13072,43 @@
grid-template-columns: 1fr;
}
+ .root :global(.scan-forecast-city-head),
+ .root :global(.scan-forecast-row-main),
+ .root :global(.scan-ai-brief-grid),
+ .root :global(.scan-ai-city-analysis-grid),
+ .root :global(.scan-ai-decision-band),
+ .root :global(.scan-ai-evidence-line) {
+ grid-template-columns: 1fr;
+ }
+
+ .root :global(.scan-ai-workspace-head),
+ .root :global(.scan-ai-city-hero),
+ .root :global(.scan-ai-city-section-head) {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .root :global(.scan-ai-workspace-head p),
+ .root :global(.scan-ai-city-hero-side) {
+ text-align: left;
+ justify-items: start;
+ }
+
+ .root :global(.scan-ai-decision-metrics) {
+ min-width: 0;
+ grid-template-columns: 1fr;
+ }
+
+ .root :global(.scan-forecast-city-read) {
+ justify-items: start;
+ text-align: left;
+ }
+
+ .root :global(.scan-forecast-signals),
+ .root :global(.scan-ai-temperature-line) {
+ grid-template-columns: 1fr;
+ }
+
.root :global(.scan-opportunity-phase) {
flex-wrap: wrap;
}
@@ -12218,4 +13162,18 @@
.root :global(.scan-opportunity-models span) {
white-space: normal;
}
+
+ .root :global(.scan-forecast-city-title strong) {
+ font-size: 21px;
+ }
+
+ .root :global(.scan-forecast-row-main),
+ .root :global(.scan-ai-analysis) {
+ padding: 13px;
+ }
+
+ .root :global(.scan-forecast-ai-line) {
+ grid-template-columns: 1fr;
+ padding: 0 13px 13px;
+ }
}
diff --git a/frontend/components/dashboard/DetailPanel.tsx b/frontend/components/dashboard/DetailPanel.tsx
index 007d77d4..0304c1b3 100644
--- a/frontend/components/dashboard/DetailPanel.tsx
+++ b/frontend/components/dashboard/DetailPanel.tsx
@@ -25,6 +25,16 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
() => getTemperatureChartData(detail, locale),
[detail, locale],
);
+ const forecastLabel = chartData?.datasets.hasMgmHourly
+ ? locale === "en-US"
+ ? "MGM Forecast"
+ : "MGM 预测"
+ : locale === "en-US"
+ ? "DEB Forecast"
+ : "DEB 预测";
+ const observationLabel =
+ chartData?.observationLabel ||
+ (locale === "en-US" ? "METAR Observation" : "METAR 实况");
const canvasRef = useChart(() => {
if (!chartData) {
@@ -50,28 +60,20 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
borderWidth: 1.8,
data: forecastPoints,
fill: false,
- label: chartData.datasets.hasMgmHourly
- ? locale === "en-US"
- ? "MGM Forecast"
- : "MGM 预测"
- : locale === "en-US"
- ? "DEB Forecast"
- : "DEB 预测",
+ label: forecastLabel,
pointRadius: 0,
spanGaps: true,
tension: 0.28,
},
{
- backgroundColor: "#00E0A4",
- borderColor: "#00E0A4",
+ backgroundColor: "#4DA3FF",
+ borderColor: "#4DA3FF",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
- label:
- chartData.observationLabel ||
- (locale === "en-US" ? "METAR Observation" : "METAR 实况"),
- pointHoverRadius: 6,
- pointRadius: 3.8,
+ label: observationLabel,
+ pointHoverRadius: 5,
+ pointRadius: 3.2,
showLine: false,
},
],
@@ -79,12 +81,13 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
},
options: {
interaction: { intersect: false, mode: "index" },
+ layout: { padding: { bottom: 0, left: 0, right: 6, top: 4 } },
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.95)",
- borderColor: "rgba(0, 224, 164, 0.25)",
+ borderColor: "rgba(77, 163, 255, 0.28)",
borderWidth: 1,
},
},
@@ -97,8 +100,9 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
typeof index === "number" && index % 4 === 0
? chartData.times[index]
: "",
- color: "#64748b",
+ color: "#6B7A90",
font: { size: 10 },
+ maxTicksLimit: 6,
maxRotation: 0,
},
},
@@ -107,22 +111,37 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
max: chartData.max,
min: chartData.min,
ticks: {
- callback: (value) => `${value}${detail.temp_symbol || "°C"}`,
- color: "#64748b",
+ callback: (value) =>
+ `${Number(value).toFixed(chartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
+ color: "#6B7A90",
font: { size: 10 },
+ maxTicksLimit: 5,
+ stepSize: chartData.yTickStep,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
- }, [chartData, detail.temp_symbol, locale]);
+ }, [chartData, detail.temp_symbol, forecastLabel, observationLabel]);
return (
+ {chartData ? (
+
+
+
+ {forecastLabel}
+
+
+
+ {observationLabel}
+
+
+ ) : null}
);
}
diff --git a/frontend/components/dashboard/DetailPanelChrome.module.css b/frontend/components/dashboard/DetailPanelChrome.module.css
index 1ab13757..7e3fefd9 100644
--- a/frontend/components/dashboard/DetailPanelChrome.module.css
+++ b/frontend/components/dashboard/DetailPanelChrome.module.css
@@ -90,9 +90,9 @@
}
.root :global(.panel-action-button-primary) {
- color: #05111f;
- background: linear-gradient(135deg, #67e8f9, #38bdf8);
- border-color: rgba(103, 232, 249, 0.3);
+ color: #FFFFFF;
+ background: linear-gradient(135deg, #4DA3FF, #3B82F6);
+ border-color: rgba(77, 163, 255, 0.34);
}
.root :global(.panel-action-button-secondary) {
diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx
index 13cd7431..bdcdff9f 100644
--- a/frontend/components/dashboard/FutureForecastModal.tsx
+++ b/frontend/components/dashboard/FutureForecastModal.tsx
@@ -62,8 +62,8 @@ function formatMinuteAxisLabel(value: number) {
function WeatherIcon({ emoji, size = 32 }: { emoji: string; size?: number }) {
if (emoji === "☀️") return ;
if (emoji === "⛅" || emoji === "🌤️")
- return ;
- if (emoji === "☁️") return ;
+ return ;
+ if (emoji === "☁️") return ;
if (emoji === "🌧️" || emoji === "🌦️")
return ;
if (emoji === "⛈️") return ;
@@ -71,7 +71,7 @@ function WeatherIcon({ emoji, size = 32 }: { emoji: string; size?: number }) {
return ;
if (emoji === "🌫️") return ;
if (emoji === "💨") return ;
- return ;
+ return ;
}
function formatMarketPercent(value?: number | null) {
@@ -354,8 +354,8 @@ function DailyTemperatureChart({
});
} else {
datasets.push({
- backgroundColor: "rgba(52, 211, 153, 0.05)",
- borderColor: "rgba(52, 211, 153, 0.6)",
+ backgroundColor: "rgba(77, 163, 255, 0.06)",
+ borderColor: "rgba(77, 163, 255, 0.66)",
borderWidth: 1.5,
data: todayChartData.datasets.debPastSeries,
fill: true,
@@ -366,7 +366,7 @@ function DailyTemperatureChart({
tension: 0.3,
});
datasets.push({
- borderColor: "rgba(52, 211, 153, 0.35)",
+ borderColor: "rgba(77, 163, 255, 0.36)",
borderDash: [5, 3],
borderWidth: 1.5,
data: todayChartData.datasets.debFutureSeries,
@@ -379,8 +379,8 @@ function DailyTemperatureChart({
}
datasets.push({
- backgroundColor: "#00E0A4",
- borderColor: "#00E0A4",
+ backgroundColor: "#4DA3FF",
+ borderColor: "#4DA3FF",
borderWidth: 0,
data: todayChartData.datasets.metarSeries,
fill: false,
@@ -431,7 +431,7 @@ function DailyTemperatureChart({
Math.abs(todayChartData.datasets.offset) > 0.3
) {
datasets.push({
- borderColor: "rgba(123, 97, 255, 0.2)",
+ borderColor: "rgba(77, 163, 255, 0.22)",
borderDash: [2, 4],
borderWidth: 1,
data: todayChartData.datasets.tempsSeries,
@@ -495,7 +495,7 @@ function DailyTemperatureChart({
plugins: {
legend: {
labels: {
- color: "#94a3b8",
+ color: "#9FB2C7",
filter: (legendItem, chartData) => {
const text = String(legendItem.text || "");
if (!text) return false;
@@ -513,7 +513,7 @@ function DailyTemperatureChart({
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.96)",
- borderColor: "rgba(0, 224, 164, 0.2)",
+ borderColor: "rgba(77, 163, 255, 0.24)",
borderWidth: 1,
callbacks: {
title: (items) => {
@@ -598,7 +598,7 @@ function DailyTemperatureChart({
}
return formatMinuteAxisLabel(minutes);
},
- color: "#64748b",
+ color: "#6B7A90",
font: { family: "Inter", size: 10 },
maxRotation: 0,
},
@@ -608,9 +608,11 @@ function DailyTemperatureChart({
max: todayChartData.max,
min: todayChartData.min,
ticks: {
- callback: (value) => `${value}${detail.temp_symbol || "°C"}`,
- color: "#64748b",
+ callback: (value) =>
+ `${Number(value).toFixed(todayChartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
+ color: "#6B7A90",
font: { family: "Inter", size: 10 },
+ stepSize: todayChartData.yTickStep,
},
},
},
@@ -626,8 +628,8 @@ function DailyTemperatureChart({
data: {
datasets: [
{
- backgroundColor: "rgba(0, 224, 164, 0.08)",
- borderColor: "#00E0A4",
+ backgroundColor: "rgba(77, 163, 255, 0.08)",
+ borderColor: "#4DA3FF",
data: view.slice.map((point) => point.temp),
fill: false,
label:
@@ -637,7 +639,7 @@ function DailyTemperatureChart({
},
{
backgroundColor: "transparent",
- borderColor: "#a78bfa",
+ borderColor: "#93C5FD",
borderDash: [5, 4],
data: view.slice.map((point) => point.dewPoint),
fill: false,
@@ -654,13 +656,13 @@ function DailyTemperatureChart({
plugins: {
legend: {
labels: {
- color: "#94a3b8",
+ color: "#9FB2C7",
font: { family: "Inter", size: 11 },
},
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.96)",
- borderColor: "rgba(0, 224, 164, 0.2)",
+ borderColor: "rgba(77, 163, 255, 0.24)",
borderWidth: 1,
callbacks: {
label: (ctx) =>
@@ -673,7 +675,7 @@ function DailyTemperatureChart({
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
- color: "#64748b",
+ color: "#6B7A90",
font: { family: "Inter", size: 10 },
maxRotation: 0,
},
@@ -682,7 +684,7 @@ function DailyTemperatureChart({
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
callback: (value) => `${value}${unit}`,
- color: "#64748b",
+ color: "#6B7A90",
font: { family: "Inter", size: 10 },
},
},
diff --git a/frontend/components/dashboard/IntradaySignalScene.tsx b/frontend/components/dashboard/IntradaySignalScene.tsx
index 82154646..a9af6080 100644
--- a/frontend/components/dashboard/IntradaySignalScene.tsx
+++ b/frontend/components/dashboard/IntradaySignalScene.tsx
@@ -18,10 +18,10 @@ function clamp(value: number, min: number, max: number) {
}
function getToneColor(tone: string) {
- if (tone === "cyan") return "#00E0A4";
+ if (tone === "cyan") return "#4DA3FF";
if (tone === "blue") return "#60a5fa";
if (tone === "amber") return "#f59e0b";
- return "#94a3b8";
+ return "#9FB2C7";
}
export function IntradaySignalScene({
@@ -77,7 +77,7 @@ export function IntradaySignalScene({
const ring = new THREE.Mesh(
new THREE.TorusGeometry(2.8, 0.03, 18, 100),
new THREE.MeshBasicMaterial({
- color: new THREE.Color(score >= 0 ? "#00E0A4" : "#FFB020"),
+ color: new THREE.Color(score >= 0 ? "#4DA3FF" : "#F59E0B"),
transparent: true,
opacity: 0.5,
}),
diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx
index e7ab1102..fa59b497 100644
--- a/frontend/components/dashboard/OpportunityTable.tsx
+++ b/frontend/components/dashboard/OpportunityTable.tsx
@@ -12,6 +12,7 @@ import {
formatTemperatureValue,
getModelView,
getProbabilityView,
+ getTodayPaceView,
normalizeTemperatureLabel,
normalizeTemperatureSymbol,
} from "@/lib/dashboard-utils";
@@ -595,6 +596,11 @@ type V4CityForecast = {
confidence?: string | null;
peakWindow?: string | null;
airportRead?: string | null;
+ weatherRead?: string | null;
+ paceRead?: string | null;
+ paceTone?: "warm" | "cold" | "neutral" | string | null;
+ paceDelta?: number | null;
+ paceAdjustedHigh?: number | null;
reason?: string | null;
modelNote?: string | null;
source: "ai" | "fallback";
@@ -736,6 +742,171 @@ function decodeRawMetarVisibility(rawMetar?: string | null) {
return "";
}
+function decodeMetarWeatherToken(token?: string | null, locale = "zh-CN") {
+ const raw = String(token || "").trim().toUpperCase();
+ if (!raw) return "";
+ const isEn = locale === "en-US";
+ const intensity = raw.startsWith("-")
+ ? isEn
+ ? "light "
+ : "轻"
+ : raw.startsWith("+")
+ ? isEn
+ ? "heavy "
+ : "强"
+ : "";
+ const cleaned = raw.replace(/^[+-]/, "");
+ const descriptors: Record = {
+ VC: { zh: "附近", en: "nearby " },
+ SH: { zh: "阵性", en: "showery " },
+ TS: { zh: "雷暴性", en: "thunderstorm " },
+ FZ: { zh: "冻", en: "freezing " },
+ BL: { zh: "吹扬", en: "blowing " },
+ DR: { zh: "低吹", en: "drifting " },
+ MI: { zh: "浅层", en: "shallow " },
+ BC: { zh: "碎片状", en: "patches of " },
+ PR: { zh: "部分", en: "partial " },
+ };
+ const phenomena: Record = {
+ DZ: { zh: "毛毛雨", en: "drizzle" },
+ RA: { zh: "雨", en: "rain" },
+ SN: { zh: "雪", en: "snow" },
+ SG: { zh: "米雪", en: "snow grains" },
+ IC: { zh: "冰晶", en: "ice crystals" },
+ PL: { zh: "冰粒", en: "ice pellets" },
+ GR: { zh: "冰雹", en: "hail" },
+ GS: { zh: "小冰雹", en: "small hail" },
+ UP: { zh: "未知降水", en: "unknown precipitation" },
+ BR: { zh: "薄雾", en: "mist" },
+ FG: { zh: "雾", en: "fog" },
+ FU: { zh: "烟", en: "smoke" },
+ VA: { zh: "火山灰", en: "volcanic ash" },
+ DU: { zh: "浮尘", en: "dust" },
+ SA: { zh: "沙", en: "sand" },
+ HZ: { zh: "霾", en: "haze" },
+ PY: { zh: "喷雾", en: "spray" },
+ PO: { zh: "尘卷风", en: "dust whirls" },
+ SQ: { zh: "飑", en: "squall" },
+ FC: { zh: "漏斗云", en: "funnel cloud" },
+ SS: { zh: "沙暴", en: "sandstorm" },
+ DS: { zh: "尘暴", en: "duststorm" },
+ };
+ const descriptorText = Object.entries(descriptors)
+ .filter(([code]) => cleaned.includes(code))
+ .map(([, text]) => (isEn ? text.en : text.zh))
+ .join("");
+ const phenomenonText = Object.entries(phenomena)
+ .filter(([code]) => cleaned.includes(code))
+ .map(([, text]) => (isEn ? text.en : text.zh))
+ .join(isEn ? " / " : "、");
+ if (!phenomenonText) return "";
+ return `${intensity}${descriptorText}${phenomenonText}`;
+}
+
+function decodeRawMetarWeather(rawMetar?: string | null, locale = "zh-CN") {
+ const raw = String(rawMetar || "").toUpperCase();
+ const matches = Array.from(
+ raw.matchAll(/\b([+-]?(?:VC)?(?:MI|PR|BC|DR|BL|SH|TS|FZ)?(?:DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS))\b/g),
+ );
+ return Array.from(
+ new Set(
+ matches
+ .map((match) => decodeMetarWeatherToken(match[1], locale))
+ .filter(Boolean),
+ ),
+ ).join(locale === "en-US" ? ", " : "、");
+}
+
+function getAirportWeatherInputs(row: ScanOpportunityRow, detail: CityDetail | null) {
+ const context = row.metar_context || {};
+ const airport: Partial> =
+ detail?.airport_current || {};
+ const rawMetar = String(context.airport_raw_metar || airport.raw_metar || "").trim();
+ return {
+ cloud: String(context.airport_cloud_desc || airport.cloud_desc || "").trim(),
+ rawMetar,
+ visibility:
+ context.airport_visibility_mi != null && Number.isFinite(Number(context.airport_visibility_mi))
+ ? Number(context.airport_visibility_mi)
+ : airport.visibility_mi != null && Number.isFinite(Number(airport.visibility_mi))
+ ? Number(airport.visibility_mi)
+ : null,
+ weather: String(context.airport_wx_desc || airport.wx_desc || "").trim(),
+ windSpeed:
+ context.airport_wind_speed_kt != null && Number.isFinite(Number(context.airport_wind_speed_kt))
+ ? Number(context.airport_wind_speed_kt)
+ : airport.wind_speed_kt != null && Number.isFinite(Number(airport.wind_speed_kt))
+ ? Number(airport.wind_speed_kt)
+ : null,
+ };
+}
+
+function formatAirportWeatherRead(
+ row: ScanOpportunityRow,
+ detail: CityDetail | null,
+ locale: string,
+) {
+ const isEn = locale === "en-US";
+ const inputs = getAirportWeatherInputs(row, detail);
+ const decodedCloud = inputs.cloud || decodeRawMetarCloud(inputs.rawMetar, locale);
+ const decodedWeather =
+ decodeMetarWeatherToken(inputs.weather, locale) ||
+ inputs.weather ||
+ decodeRawMetarWeather(inputs.rawMetar, locale);
+ const visibilityText =
+ inputs.visibility != null ? `${inputs.visibility.toFixed(1)}mi` : decodeRawMetarVisibility(inputs.rawMetar);
+ const cloudRaw = `${inputs.cloud} ${inputs.rawMetar}`.toUpperCase();
+ const weatherRaw = `${inputs.weather} ${inputs.rawMetar}`.toUpperCase();
+ const suppressors: string[] = [];
+ const supporters: string[] = [];
+
+ if (/(RA|DZ|SN|TS|SH|FG|BR|HZ|OVC|BKN)/.test(weatherRaw) || /(OVC|BKN)/.test(cloudRaw)) {
+ suppressors.push(
+ isEn
+ ? "cloud, precipitation or restricted visibility can suppress solar heating"
+ : "云雨、薄雾或低能见度会压制太阳辐射升温",
+ );
+ }
+ if (inputs.visibility != null && inputs.visibility < 6) {
+ suppressors.push(
+ isEn
+ ? `visibility is only ${visibilityText}, so the airport path may warm more slowly`
+ : `能见度仅 ${visibilityText},机场路径可能升温偏慢`,
+ );
+ }
+ if (/(FEW|SCT)/.test(cloudRaw) && !/(RA|DZ|SN|TS|FG|BR|HZ|OVC|BKN)/.test(weatherRaw)) {
+ supporters.push(
+ isEn
+ ? "few or scattered clouds do not block the heating path materially"
+ : "少云或散云对日间升温压制不明显",
+ );
+ }
+ if (inputs.windSpeed != null && inputs.windSpeed >= 15) {
+ suppressors.push(
+ isEn
+ ? "stronger wind mixing can change the airport temperature path"
+ : "风速偏大,边界层混合可能改写机场温度路径",
+ );
+ } else if (inputs.windSpeed != null && inputs.windSpeed <= 5 && !suppressors.length) {
+ supporters.push(
+ isEn
+ ? "light wind leaves the temperature path mainly driven by local sunshine"
+ : "风速较弱,温度路径更取决于本地日照",
+ );
+ }
+
+ const descriptors = [
+ decodedWeather ? (isEn ? `weather ${decodedWeather}` : `天气 ${decodedWeather}`) : null,
+ decodedCloud ? (isEn ? `cloud ${decodedCloud}` : `云况 ${decodedCloud}`) : null,
+ visibilityText ? (isEn ? `visibility ${visibilityText}` : `能见度 ${visibilityText}`) : null,
+ ].filter(Boolean);
+ const read = suppressors[0] || supporters[0];
+ if (!descriptors.length && !read) return null;
+ const prefix = isEn ? "Airport weather read" : "机场气象解读";
+ const evidence = descriptors.length ? `${descriptors.join(isEn ? ", " : ",")};` : "";
+ return `${prefix}:${evidence}${read || (isEn ? "no clear weather suppression signal yet" : "暂未看到明确天气压温信号")}。`;
+}
+
function formatAirportReportRead(
row: ScanOpportunityRow,
detail: CityDetail | null,
@@ -779,6 +950,10 @@ function formatAirportReportRead(
const weather = String(context.airport_wx_desc || airport.wx_desc || "").trim();
const rawMetar = String(context.airport_raw_metar || airport.raw_metar || "").trim();
const decodedCloud = cloud || decodeRawMetarCloud(rawMetar, locale);
+ const decodedWeather =
+ decodeMetarWeatherToken(weather, locale) ||
+ weather ||
+ decodeRawMetarWeather(rawMetar, locale);
const visibility =
context.airport_visibility_mi != null && Number.isFinite(Number(context.airport_visibility_mi))
? Number(context.airport_visibility_mi)
@@ -801,7 +976,7 @@ function formatAirportReportRead(
);
}
if (decodedCloud) parts.push(isEn ? `cloud ${decodedCloud}` : `云况 ${decodedCloud}`);
- if (weather) parts.push(isEn ? `weather ${weather}` : `天气 ${weather}`);
+ if (decodedWeather) parts.push(isEn ? `weather ${decodedWeather}` : `天气 ${decodedWeather}`);
if (decodedVisibility) parts.push(isEn ? `visibility ${decodedVisibility}` : `能见度 ${decodedVisibility}`);
if (!parts.length) return null;
const prefix = isEn ? "Latest airport METAR read" : "最新机场报文解读";
@@ -809,6 +984,74 @@ function formatAirportReportRead(
return `${prefix}${head ? ` ${head}` : ""}:${parts.join(",")}。`;
}
+function getPaceDeviationRead(
+ detail: CityDetail | null,
+ locale: string,
+ tempSymbol?: string | null,
+) {
+ if (!detail) return null;
+ const paceView = getTodayPaceView(detail, locale === "en-US" ? "en-US" : "zh-CN");
+ if (!paceView) return null;
+ const unit = normalizeTemperatureSymbol(tempSymbol || detail.temp_symbol);
+ const observed = formatTemperatureValue(paceView.observedNow, unit, { digits: 1 });
+ const expected = formatTemperatureValue(paceView.expectedNow, unit, { digits: 1 });
+ const delta = `${paceView.delta > 0 ? "+" : ""}${paceView.delta.toFixed(1)}${unit}`;
+ const isEn = locale === "en-US";
+ const toneText =
+ paceView.biasTone === "warm"
+ ? isEn
+ ? "running hotter"
+ : "偏热"
+ : paceView.biasTone === "cold"
+ ? isEn
+ ? "running cooler"
+ : "偏冷"
+ : isEn
+ ? "tracking"
+ : "基本跟踪";
+ return {
+ adjustedHigh: paceView.paceAdjustedHigh,
+ delta: paceView.delta,
+ label: paceView.badge,
+ read: isEn
+ ? `Observed path vs DEB curve: ${observed} now vs ${expected} expected, ${delta} (${toneText}).`
+ : `实测路径对比 DEB 曲线:当前 ${observed},同刻预期 ${expected},偏差 ${delta}(${toneText})。`,
+ tone: paceView.biasTone,
+ };
+}
+
+function getPaceSignalLabel(forecast: V4CityForecast, locale: string, tempSymbol?: string | null) {
+ const isEn = locale === "en-US";
+ if (forecast.paceDelta == null || !Number.isFinite(Number(forecast.paceDelta))) {
+ return isEn ? "Path pending" : "路径待确认";
+ }
+ const unit = normalizeTemperatureSymbol(tempSymbol);
+ const delta = `${forecast.paceDelta > 0 ? "+" : ""}${Number(forecast.paceDelta).toFixed(1)}${unit}`;
+ if (forecast.paceTone === "warm") return isEn ? `Hot path ${delta}` : `实测偏热 ${delta}`;
+ if (forecast.paceTone === "cold") return isEn ? `Cool path ${delta}` : `实测偏冷 ${delta}`;
+ return isEn ? `On path ${delta}` : `路径跟踪 ${delta}`;
+}
+
+function getPaceDecisionTail(forecast: V4CityForecast, locale: string, tempSymbol?: string | null) {
+ if (forecast.paceDelta == null || !Number.isFinite(Number(forecast.paceDelta))) return "";
+ const isEn = locale === "en-US";
+ const unit = normalizeTemperatureSymbol(tempSymbol);
+ const delta = `${forecast.paceDelta > 0 ? "+" : ""}${Number(forecast.paceDelta).toFixed(1)}${unit}`;
+ if (forecast.paceTone === "warm") {
+ return isEn
+ ? ` Observations are running ${delta} above the DEB path, so upside boundaries need extra caution.`
+ : ` 实测比 DEB 路径偏高 ${delta},上方阈值要额外谨慎。`;
+ }
+ if (forecast.paceTone === "cold") {
+ return isEn
+ ? ` Observations are running ${delta} below the DEB path, which weakens upside breakout odds.`
+ : ` 实测比 DEB 路径偏低 ${delta},上破概率需要下修。`;
+ }
+ return isEn
+ ? " Observations are still tracking the DEB path."
+ : " 实测仍基本跟踪 DEB 路径。";
+}
+
function median(values: number[]) {
if (!values.length) return null;
const sorted = [...values].sort((a, b) => a - b);
@@ -856,6 +1099,8 @@ function getV4CityForecast(
row.ai_airport_metar_read_zh,
row.ai_airport_metar_read_en,
) || formatAirportReportRead(row, detail, locale, tempSymbol);
+ const weatherRead = formatAirportWeatherRead(row, detail, locale);
+ const paceRead = getPaceDeviationRead(detail, locale, tempSymbol);
const modelNote =
row.ai_city_model_cluster_note ||
row.ai_model_cluster_note ||
@@ -865,8 +1110,8 @@ function getV4CityForecast(
getLocalizedRowText(row, locale, row.ai_city_thesis_zh, row.ai_city_thesis_en) ||
(fallbackPredicted != null
? isEn
- ? `${group.cityName} final high is centered near ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })}; use the contract rows only as bucket mapping.`
- : `${group.cityName} 最终最高温先以 ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })} 附近为中枢,下面合约只作为温度桶映射。`
+ ? `${group.cityName} final high is centered near ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })}; market temperature buckets are only mapped against that forecast range.`
+ : `${group.cityName} 最终最高温先以 ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })} 附近为中枢,市场温度桶只用于对照 AI 预测区间。`
: null);
return {
predicted: fallbackPredicted,
@@ -875,6 +1120,11 @@ function getV4CityForecast(
confidence: row.ai_forecast_confidence || row.ai_city_confidence,
peakWindow,
airportRead,
+ weatherRead,
+ paceRead: paceRead?.read || null,
+ paceTone: paceRead?.tone || null,
+ paceDelta: paceRead?.delta ?? null,
+ paceAdjustedHigh: paceRead?.adjustedHigh ?? null,
reason,
modelNote,
source: aiPredicted != null ? "ai" : "fallback",
@@ -1101,8 +1351,10 @@ function getMetarGate(
const unit = normalizeTemperatureSymbol(tempSymbol);
const peakTiming = formatPeakWindowTiming(row, locale);
const airportReport = formatAirportReportRead(row, detail, locale, unit);
+ const airportWeatherRead = formatAirportWeatherRead(row, detail, locale);
if (peakTiming) evidence.push(peakTiming);
if (airportReport) evidence.push(airportReport);
+ if (airportWeatherRead) evidence.push(airportWeatherRead);
if (obs.lastTemp != null) {
evidence.push(
`${isEn ? "METAR latest" : "METAR 最新"} ${formatTemperatureValue(obs.lastTemp, unit, { digits: 1 })}${obs.lastTime ? ` @ ${obs.lastTime}` : ""}`,
@@ -1487,6 +1739,312 @@ function buildOpportunityGroups(
}));
}
+function getBucketDisplayLabel(
+ row: ScanOpportunityRow,
+ locale: string,
+ tempSymbol?: string | null,
+) {
+ const isEn = locale === "en-US";
+ const { lower, upper } = getTargetRange(row);
+ if (lower != null && upper == null) {
+ const value = formatTemperatureValue(lower, tempSymbol);
+ return isEn ? `${value} or higher` : `${value} 以上`;
+ }
+ if (upper != null && lower == null) {
+ const value = formatTemperatureValue(upper, tempSymbol);
+ return isEn ? `${value} or lower` : `${value} 以下`;
+ }
+ if (lower != null && upper != null && Math.abs(lower - upper) > 0.01) {
+ return `${formatTemperatureValue(lower, tempSymbol)} ~ ${formatTemperatureValue(upper, tempSymbol)}`;
+ }
+ return formatThreshold(row, tempSymbol);
+}
+
+function getForecastFitMeta(
+ fit: ReturnType,
+ locale: string,
+) {
+ const isEn = locale === "en-US";
+ const tone = String(fit.tone || "watchlist");
+ if (tone === "approve" || tone === "core") {
+ return {
+ label: isEn ? "Clear signal" : "方向明确",
+ tone: "approve",
+ };
+ }
+ if (tone === "veto" || tone === "outside") {
+ return {
+ label: isEn ? "Outside AI range" : "偏离 AI 区间",
+ tone: "veto",
+ };
+ }
+ if (tone === "downgrade") {
+ return {
+ label: isEn ? "Downgraded" : "降级观察",
+ tone: "downgrade",
+ };
+ }
+ return {
+ label: isEn ? "Need peak confirmation" : "等待峰值确认",
+ tone: "watchlist",
+ };
+}
+
+function getThresholdDecision(
+ row: ScanOpportunityRow,
+ forecast: V4CityForecast,
+ locale: string,
+ tempSymbol?: string | null,
+) {
+ const isEn = locale === "en-US";
+ const unit = normalizeTemperatureSymbol(tempSymbol);
+ const { lower, upper } = getTargetRange(row);
+ const predicted = forecast.predicted;
+ const low = forecast.low;
+ const high = forecast.high;
+ const tolerance = unit === "°F" ? 1 : 0.5;
+ const cautionBand = unit === "°F" ? 2 : 1;
+ const format = (value: number) => formatTemperatureValue(value, unit, { digits: 0 });
+ const paceTolerance = unit === "°F" ? 1 : 0.6;
+ const paceTail = getPaceDecisionTail(forecast, locale, unit);
+ const paceAdjustedHigh =
+ forecast.paceAdjustedHigh != null && Number.isFinite(Number(forecast.paceAdjustedHigh))
+ ? Number(forecast.paceAdjustedHigh)
+ : null;
+ const runningHot =
+ forecast.paceDelta != null && Number(forecast.paceDelta) >= paceTolerance;
+ const runningCold =
+ forecast.paceDelta != null && Number(forecast.paceDelta) <= -paceTolerance;
+ const confidence = (() => {
+ const values = Object.values(row.model_cluster_sources || {})
+ .map((value) => Number(value))
+ .filter((value) => Number.isFinite(value));
+ if (!values.length || predicted == null) return isEn ? "Medium" : "中";
+ const near = values.filter((value) => Math.abs(value - predicted) <= cautionBand).length;
+ const ratio = near / values.length;
+ if (ratio >= 0.75) return isEn ? "High" : "高";
+ if (ratio >= 0.45) return isEn ? "Medium" : "中";
+ return isEn ? "Low" : "低";
+ })();
+
+ if (predicted == null || (lower == null && upper == null)) {
+ return {
+ confidence,
+ headline: isEn ? "Conclusion pending" : "结论待确认",
+ relation: isEn ? "Await stable forecast" : "等待稳定预测",
+ summary: isEn
+ ? "AI does not have a stable high-temperature center yet."
+ : "AI 还没有稳定的最高温中枢,先不输出边界结论。",
+ tone: "watchlist" as const,
+ };
+ }
+
+ if (lower != null && upper == null) {
+ const threshold = format(lower);
+ if (
+ predicted < lower - cautionBand &&
+ (high == null || high < lower + tolerance) &&
+ (paceAdjustedHigh == null || paceAdjustedHigh < lower - tolerance)
+ ) {
+ return {
+ confidence,
+ headline: isEn ? `Unlikely to reach ${threshold}` : `不太可能达到 ${threshold}`,
+ relation: isEn ? "Clearly below threshold" : "明显低于阈值",
+ summary: isEn
+ ? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}, below the ${threshold} boundary with no clear breakout signal.${paceTail}`
+ : `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},低于 ${threshold} 阈值,暂时缺乏明显突破信号。${paceTail}`,
+ tone: "veto" as const,
+ };
+ }
+ if (
+ predicted >= lower + tolerance &&
+ (low == null || low >= lower - tolerance) &&
+ !runningCold &&
+ (paceAdjustedHigh == null || paceAdjustedHigh >= lower - tolerance)
+ ) {
+ return {
+ confidence,
+ headline: isEn ? `Likely to reach ${threshold}` : `大概率达到 ${threshold}`,
+ relation: isEn ? "Above threshold" : "高于阈值",
+ summary: isEn
+ ? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}, already above the ${threshold} boundary.${paceTail}`
+ : `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},已经高于 ${threshold} 阈值。${paceTail}`,
+ tone: "approve" as const,
+ };
+ }
+ return {
+ confidence,
+ headline: isEn ? `${threshold} boundary is risky` : `${threshold} 边界偏危险`,
+ relation: isEn ? "Near threshold" : "接近阈值(存在突破风险)",
+ summary: isEn
+ ? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}; the ${threshold} boundary still needs peak-window confirmation.${paceTail}`
+ : `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},接近 ${threshold} 阈值,仍要等峰值窗口确认。${paceTail}`,
+ tone: "watchlist" as const,
+ };
+ }
+
+ if (upper != null && lower == null) {
+ const threshold = format(upper);
+ if (
+ predicted <= upper - tolerance &&
+ (high == null || high <= upper + tolerance) &&
+ !runningHot &&
+ (paceAdjustedHigh == null || paceAdjustedHigh <= upper + tolerance)
+ ) {
+ return {
+ confidence,
+ headline: isEn ? `Likely to stay below ${threshold}` : `大概率不超过 ${threshold}`,
+ relation: isEn ? "Clearly below threshold" : "明显低于阈值",
+ summary: isEn
+ ? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}, below the ${threshold} boundary.${paceTail}`
+ : `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},低于 ${threshold} 阈值。${paceTail}`,
+ tone: "approve" as const,
+ };
+ }
+ if (
+ predicted > upper + cautionBand &&
+ (low == null || low > upper - tolerance) &&
+ (paceAdjustedHigh == null || paceAdjustedHigh > upper + tolerance)
+ ) {
+ return {
+ confidence,
+ headline: isEn ? `Likely above ${threshold}` : `大概率超过 ${threshold}`,
+ relation: isEn ? "Above threshold" : "高于阈值",
+ summary: isEn
+ ? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}, above the ${threshold} boundary.${paceTail}`
+ : `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},高于 ${threshold} 阈值。${paceTail}`,
+ tone: "veto" as const,
+ };
+ }
+ return {
+ confidence,
+ headline: isEn ? `${threshold} boundary is risky` : `${threshold} 边界偏危险`,
+ relation: isEn ? "Near threshold" : "接近阈值(存在突破风险)",
+ summary: isEn
+ ? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}; the boundary still needs peak-window confirmation.${paceTail}`
+ : `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},仍需等待峰值窗口确认边界。${paceTail}`,
+ tone: "watchlist" as const,
+ };
+ }
+
+ const bucket = getBucketDisplayLabel(row, locale, unit);
+ const bucketReference = paceAdjustedHigh ?? predicted;
+ const inside =
+ (lower == null || bucketReference >= lower - tolerance) &&
+ (upper == null || bucketReference <= upper + tolerance);
+ return {
+ confidence,
+ headline: inside
+ ? isEn
+ ? `Likely inside ${bucket}`
+ : `大概率落在 ${bucket}`
+ : isEn
+ ? `Unlikely inside ${bucket}`
+ : `不太可能落在 ${bucket}`,
+ relation: inside ? (isEn ? "Inside target bucket" : "处于目标桶") : (isEn ? "Outside target bucket" : "偏离目标桶"),
+ summary: isEn
+ ? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}; use this bucket only as the market mapping of the city high.${paceTail}`
+ : `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},该温度桶只用于映射城市最高温判断。${paceTail}`,
+ tone: inside ? ("approve" as const) : ("veto" as const),
+ };
+}
+
+function getForecastRiskItems(
+ row: ScanOpportunityRow,
+ detail: CityDetail | null,
+ forecast: V4CityForecast,
+ locale: string,
+ tempSymbol?: string | null,
+) {
+ const isEn = locale === "en-US";
+ const obs = getMetarObservationContext(row, detail);
+ const risks: string[] = [];
+ const phase = String(row.window_phase || "").toLowerCase();
+ if (
+ phase === "early_today" ||
+ phase === "setup_today" ||
+ (row.minutes_until_peak_start != null && Number(row.minutes_until_peak_start) > 0)
+ ) {
+ risks.push(
+ isEn
+ ? "Peak window has not arrived; current METAR is only path evidence."
+ : "峰值窗口尚未到达,当前 METAR 只能作为路径证据。",
+ );
+ }
+ if (row.trend_alignment === false) {
+ risks.push(
+ isEn
+ ? "Intraday observation path does not fully support the forecast center."
+ : "日内实况路径还没有完全支持预测中枢。",
+ );
+ }
+ if (forecast.paceRead && forecast.paceTone !== "neutral") {
+ risks.push(
+ isEn
+ ? `Observed pace is deviating from the DEB curve: ${forecast.paceRead}`
+ : `实测节奏正在偏离 DEB 曲线:${forecast.paceRead}`,
+ );
+ }
+ if (obs.stale || obs.lastTemp == null) {
+ risks.push(
+ isEn
+ ? "Same-day METAR confirmation is still weak."
+ : "同日 METAR 确认仍然偏弱。",
+ );
+ }
+ if (forecast.low != null && forecast.high != null) {
+ const spread = Math.abs(forecast.high - forecast.low);
+ const wide = String(normalizeTemperatureSymbol(tempSymbol)).toUpperCase().includes("F")
+ ? spread > 2
+ : spread > 1;
+ if (wide) {
+ risks.push(
+ isEn
+ ? "Model range is wide; treat boundary buckets conservatively."
+ : "模型区间偏宽,边界温度桶需要保守处理。",
+ );
+ }
+ }
+ if (!risks.length) {
+ risks.push(
+ isEn
+ ? "Residual risk is late METAR revision or a shifted afternoon peak."
+ : "残余风险主要是后续 METAR 修订或峰值窗口漂移。",
+ );
+ }
+ return Array.from(new Set(risks)).slice(0, 3);
+}
+
+function getDecisionReasonItems(
+ row: ScanOpportunityRow,
+ forecast: V4CityForecast,
+ modelSupportText: string,
+ locale: string,
+ tempSymbol?: string | null,
+) {
+ const isEn = locale === "en-US";
+ const modelRange = formatModelClusterRange(row.model_cluster_sources, tempSymbol);
+ const reasons: string[] = [];
+ if (modelRange !== "--") {
+ reasons.push(
+ isEn
+ ? `Model cluster sits around ${modelRange}; ${modelSupportText}.`
+ : `模型区间集中在 ${modelRange},${modelSupportText}。`,
+ );
+ }
+ if (forecast.predicted != null) {
+ reasons.push(
+ isEn
+ ? `AI high-temperature center is ${formatTemperatureValue(forecast.predicted, tempSymbol, { digits: 1 })}.`
+ : `AI 最高温中枢约 ${formatTemperatureValue(forecast.predicted, tempSymbol, { digits: 1 })}。`,
+ );
+ }
+ if (forecast.paceRead) reasons.push(forecast.paceRead);
+ if (forecast.peakWindow) reasons.push(forecast.peakWindow);
+ if (forecast.weatherRead) reasons.push(forecast.weatherRead);
+ return Array.from(new Set(reasons)).slice(0, 3);
+}
+
export const OpportunityTable = React.memo(function OpportunityTable({
rows,
status,
@@ -1597,44 +2155,55 @@ export const OpportunityTable = React.memo(function OpportunityTable({
{staleReason || (isEn ? "Latest refresh failed, fallback to the last successful scan." : "最新刷新失败,已回退到上次成功扫描结果。")}
) : null}
-
+
{groups.map((group) => {
const groupSelected = group.rows.some((row) => row.id === selectedRowId);
+ const firstRow = group.rows[0];
+ const firstTempSymbol = normalizeTemperatureSymbol(
+ firstRow?.target_unit || firstRow?.temp_symbol || group.tempSymbol,
+ );
+ const firstDetail = firstRow
+ ? getDetailForRow(firstRow, cityDetailsByName)
+ : null;
+ const groupForecast = firstRow
+ ? getV4CityForecast(firstRow, group, firstDetail, locale, firstTempSymbol)
+ : null;
+ const groupForecastLabel =
+ groupForecast?.predicted != null
+ ? formatTemperatureValue(groupForecast.predicted, firstTempSymbol, { digits: 1 })
+ : "--";
+ const groupRangeLabel = groupForecast
+ ? getForecastRangeLabel(groupForecast, firstTempSymbol)
+ : group.peakLabel;
return (