From 3237f95e8c4e17debbb92f56f6dd71d166b49b73 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 26 Apr 2026 00:24:11 +0800 Subject: [PATCH] feat: implement scan terminal dashboard with real-time opportunity tracking and visualization components --- .gitignore | 2 + frontend/app/globals.css | 48 +- .../components/dashboard/Dashboard.module.css | 1226 +++++++++++++++-- frontend/components/dashboard/DetailPanel.tsx | 57 +- .../dashboard/DetailPanelChrome.module.css | 6 +- .../dashboard/FutureForecastModal.tsx | 44 +- .../dashboard/IntradaySignalScene.tsx | 6 +- .../components/dashboard/OpportunityTable.tsx | 847 ++++++++++-- .../components/dashboard/PanelSections.tsx | 22 +- frontend/components/dashboard/ScanKPIBar.tsx | 2 +- .../dashboard/ScanTerminalDashboard.tsx | 1211 ++++++++-------- .../components/dashboard/WeatherAuraLayer.tsx | 2 +- frontend/hooks/useDashboardStore.tsx | 40 +- frontend/lib/dashboard-utils.ts | 43 +- frontend/package.json | 4 +- frontend/scripts/sync-next-server-chunks.mjs | 29 + 16 files changed, 2538 insertions(+), 1051 deletions(-) create mode 100644 frontend/scripts/sync-next-server-chunks.mjs 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 (
-
- {isEn ? "AI forecast mapping" : "AI 预测映射"} +
+ {isEn ? "Current read" : "当前判断"} {shortConclusion}
{expanded ? ( -
-
+
+
- {isEn ? "AI max-temperature forecast" : "AI 最高温预测"} -

{cityAnalysis}

+ {isEn ? "Conclusion" : "结论"} +

{thresholdDecision.headline}

+ {thresholdDecision.summary}
- - {strength.label} + + {thresholdDecision.relation}
-
- {isEn ? "Forecast" : "预测"} +
- {isEn ? "Expected high" : "预计最高温"}{" "} - + {isEn ? "Bucket" : "判断对象"} + {bucketLabel} + + + {isEn ? "AI forecast" : "AI 预测"} + {cityForecast.predicted != null ? formatTemperatureValue(cityForecast.predicted, tempSymbol, { digits: 1 }) : "--"} - - {" · "} - {isEn ? "interval" : "区间"} {getForecastRangeLabel(cityForecast, tempSymbol)} - {cityForecast.confidence ? ` · ${isEn ? "confidence" : "信心"} ${cityForecast.confidence}` : ""} + + + + {isEn ? "Forecast range" : "预测区间"} + {forecastRangeLabel} + + + {isEn ? "Confidence" : "信心"} + {cityForecast.confidence || thresholdDecision.confidence} - {cityForecast.reason ? ( - {cityForecast.reason} - ) : null} - {cityForecast.airportRead ? ( - {cityForecast.airportRead} - ) : null} - {cityForecast.peakWindow ? ( - {cityForecast.peakWindow} - ) : null}
-
+
+ + DEB + {group.debLabel} + + + {modelSupportLabel} + {modelSupportText} + + + METAR + {metarConflictText} + + + {paceLabel} + {paceSignalText} + +
+
- {isEn ? "Key basis" : "关键依据"} + {isEn ? "Why" : "为什么"}
    {keyReasons.map((reason) => (
  • {reason}
  • @@ -1847,7 +2374,7 @@ export const OpportunityTable = React.memo(function OpportunityTable({
- {isEn ? "Risk" : "风险"} + {isEn ? "What can change it" : "可能改变判断的因素"}
    {riskItems.map((reason) => (
  • {reason}
  • @@ -1855,6 +2382,20 @@ export const OpportunityTable = React.memo(function OpportunityTable({
+
+ {cityForecast.airportRead ? ( +

{cityForecast.airportRead}

+ ) : null} + {cityForecast.weatherRead ? ( +

{cityForecast.weatherRead}

+ ) : null} + {cityForecast.paceRead ? ( +

{cityForecast.paceRead}

+ ) : null} + {cityForecast.peakWindow ? ( +

{cityForecast.peakWindow}

+ ) : null} +
) : null}
diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index 4b358840..76119dc8 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -694,8 +694,8 @@ export function TemperatureChart() { }); } 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: chartData.datasets.debPast, fill: true, @@ -705,7 +705,7 @@ export function TemperatureChart() { 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: chartData.datasets.debFuture, @@ -717,8 +717,8 @@ export function TemperatureChart() { } datasets.push({ - backgroundColor: "#00E0A4", - borderColor: "#00E0A4", + backgroundColor: "#4DA3FF", + borderColor: "#4DA3FF", borderWidth: 0, data: chartData.datasets.metarPoints, fill: false, @@ -750,7 +750,7 @@ export function TemperatureChart() { Math.abs(chartData.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: chartData.datasets.temps, @@ -773,7 +773,7 @@ export function TemperatureChart() { legend: { display: false }, tooltip: { backgroundColor: "rgba(15, 23, 42, 0.9)", - borderColor: "rgba(52, 211, 153, 0.3)", + borderColor: "rgba(77, 163, 255, 0.28)", borderWidth: 1, }, }, @@ -786,7 +786,7 @@ export function TemperatureChart() { typeof index === "number" && index % 3 === 0 ? chartData.times[index] : "", - color: "#64748b", + color: "#6B7A90", maxRotation: 0, }, }, @@ -795,8 +795,10 @@ export function TemperatureChart() { max: chartData.max, min: chartData.min, ticks: { - callback: (value) => `${value}${data.temp_symbol || "°C"}`, - color: "#64748b", + callback: (value) => + `${Number(value).toFixed(chartData.yTickStep < 1 ? 1 : 0)}${data.temp_symbol || "°C"}`, + color: "#6B7A90", + stepSize: chartData.yTickStep, }, }, }, diff --git a/frontend/components/dashboard/ScanKPIBar.tsx b/frontend/components/dashboard/ScanKPIBar.tsx index 1d61de42..61b95c0f 100644 --- a/frontend/components/dashboard/ScanKPIBar.tsx +++ b/frontend/components/dashboard/ScanKPIBar.tsx @@ -81,7 +81,7 @@ export function ScanKPIBar({ tone: "blue", }, { - label: isEn ? "Win-rate Basis" : "胜率依据", + label: isEn ? "Forecast Center" : "预测中枢", value: bestRow ? formatTemperature(bestRow.deb_prediction, bestRow.temp_symbol || bestRow.target_unit) : "--", note: bestRow ? `${isEn ? "Focus" : "焦点"} ${bestRow.city_display_name || bestRow.display_name || bestRow.city} · DEB / ${isEn ? "models" : "模型"} / METAR` diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index a373f637..40762926 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -1,18 +1,18 @@ "use client"; +import type { ChartConfiguration } from "chart.js"; import clsx from "clsx"; import Link from "next/link"; import { + BarChart3, LogIn, - RefreshCw, + X, Moon, Sun, UserRound, } from "lucide-react"; import { - startTransition, useCallback, - useDeferredValue, useEffect, useMemo, useRef, @@ -24,24 +24,26 @@ import modalChromeStyles from "./ModalChrome.module.css"; import { DetailPanel as CityDetailPanel } from "@/components/dashboard/DetailPanel"; import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal"; import { MapCanvas } from "@/components/dashboard/MapCanvas"; +import { ModelForecast } from "@/components/dashboard/PanelSections"; import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable"; -import { ScanKPIBar } from "@/components/dashboard/ScanKPIBar"; -import { OpportunityTable } from "@/components/dashboard/OpportunityTable"; import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall"; import { DashboardStoreProvider, useDashboardStore, } from "@/hooks/useDashboardStore"; +import { useChart } from "@/hooks/useChart"; import { I18nProvider, useI18n } from "@/hooks/useI18n"; -import { dashboardClient } from "@/lib/dashboard-client"; import type { + CityDetail, ScanOpportunityRow, - ScanTerminalFilters, ScanTerminalResponse, } from "@/lib/dashboard-types"; import { getLocalizedCityName } from "@/lib/dashboard-home-copy"; import { formatTemperatureValue, + getModelView, + getTemperatureChartData, + getTodayPaceView, normalizeTemperatureLabel, } from "@/lib/dashboard-utils"; import { @@ -50,47 +52,14 @@ import { getRowPeakSortValue, } from "@/lib/scan-market-focus"; -const DEFAULT_FILTERS: FilterState = { - scan_mode: "tradable", - min_price: 0.05, - max_price: 0.95, - min_edge_pct: 2, - min_liquidity: 1000, - high_liquidity_only: false, - market_type: "maxtemp", - time_range: "today", - limit: 28, -}; - -const SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000; - -interface FilterState extends ScanTerminalFilters {} - type ContentView = "list" | "map" | "calendar"; type ThemeMode = "dark" | "light"; -type ScanAiLogTone = "info" | "success" | "warning" | "error"; - -type ScanAiLogEntry = { - id: string; - time: string; - tone: ScanAiLogTone; - title: string; - detail?: string | null; +type AiPinnedCity = { + cityName: string; + displayName?: string | null; + addedAt: number; }; -function formatLogTime() { - const now = new Date(); - return `${String(now.getHours()).padStart(2, "0")}:${String( - now.getMinutes(), - ).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`; -} - -function formatDuration(ms?: number | null) { - if (ms == null || !Number.isFinite(Number(ms))) return "--"; - if (Number(ms) < 1000) return `${Math.round(Number(ms))}ms`; - return `${(Number(ms) / 1000).toFixed(1)}s`; -} - function formatShortDate(value?: string | null, locale = "zh-CN") { const text = String(value || "").trim(); if (!text) return "--"; @@ -274,31 +243,6 @@ function sortRowsByUserTime(rows: ScanOpportunityRow[]) { }); } -function mergeRefreshingScanSnapshot( - previous: ScanTerminalResponse | null, - next: ScanTerminalResponse, - locale = "zh-CN", -): ScanTerminalResponse { - if (next.rows.length || !previous?.rows.length) return next; - const nextStatus = String(next.status || "").toLowerCase(); - if (!["partial", "stale", "scanning", "loading", "failed"].includes(nextStatus)) { - return next; - } - return { - ...previous, - generated_at: previous.generated_at || next.generated_at, - status: nextStatus === "failed" ? "stale" : next.status || "stale", - stale: true, - stale_reason: - next.stale_reason || - (locale === "en-US" - ? "Refreshing a new scan; showing the previous snapshot until it is ready." - : "新扫描仍在刷新中,当前继续展示上一轮快照。"), - last_failed_at: next.last_failed_at || previous.last_failed_at, - filters: next.filters || previous.filters, - }; -} - function normalizeCityKey(value?: string | null) { return String(value || "") .trim() @@ -306,6 +250,13 @@ function normalizeCityKey(value?: string | null) { .replace(/[\s_-]+/g, ""); } +function prettifyCityName(value?: string | null) { + return String(value || "") + .trim() + .replace(/[-_]+/g, " ") + .replace(/\b\w/g, (match) => match.toUpperCase()); +} + function rowMatchesCity(row: ScanOpportunityRow, cityName: string) { const cityKey = normalizeCityKey(cityName); if (!cityKey) return false; @@ -320,256 +271,6 @@ function findRowForCity(rows: ScanOpportunityRow[], cityName?: string | null) { return rows.find((row) => rowMatchesCity(row, cityName || "")) || null; } -function getRowAiReason(row: ScanOpportunityRow, locale: string) { - const isEn = locale === "en-US"; - return ( - (isEn ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en) || - row.ai_model_cluster_note || - (isEn - ? row.ai_watchlist_reason_en || row.ai_watchlist_reason_zh - : row.ai_watchlist_reason_zh || row.ai_watchlist_reason_en) || - null - ); -} - -function getAiDecisionLabel(row: ScanOpportunityRow, locale: string) { - const decision = String(row.ai_decision || "neutral").toLowerCase(); - if (decision === "veto") return locale === "en-US" ? "Excluded" : "排除"; - if (decision === "downgrade") return locale === "en-US" ? "Downgraded" : "降级"; - if (decision === "watchlist") return locale === "en-US" ? "Watch" : "观察"; - if (row.ai_rank != null || decision === "approve") { - return locale === "en-US" ? `Pick ${row.ai_rank || ""}`.trim() : `推荐 ${row.ai_rank || ""}`.trim(); - } - return locale === "en-US" ? "Unreviewed" : "未复核"; -} - -function ScanAiAnalysisView({ - response, - rows, - logs, - error, - locale, -}: { - response: ScanTerminalResponse | null; - rows: ScanOpportunityRow[]; - logs: ScanAiLogEntry[]; - error?: string | null; - locale: string; -}) { - const isEn = locale === "en-US"; - const aiScan = response?.ai_scan || null; - const theses = aiScan?.city_theses || []; - const thesisByCity = useMemo( - () => new Map(theses.map((item) => [normalizeCityKey(item.city), item])), - [theses], - ); - const groups = useMemo(() => { - const map = new Map(); - rows.forEach((row) => { - const key = normalizeCityKey(row.city || row.city_display_name || row.display_name); - if (!key) return; - const current = map.get(key) || []; - current.push(row); - map.set(key, current); - }); - return Array.from(map.entries()).map(([key, items]) => ({ - key, - rows: items, - city: - items[0]?.city_display_name || - items[0]?.display_name || - items[0]?.city || - key, - thesis: thesisByCity.get(key), - })); - }, [rows, thesisByCity]); - - if (!rows.length) { - return ( -
-
-
- {isEn ? "Run a rule scan first" : "先完成规则扫描"} -
-
- {isEn - ? "V4 needs the current city candidates before it can analyze them." - : "V4 需要先拿到当前城市候选,才能做城市级研判。"} -
-
-
- ); - } - - return ( -
-
-
- {isEn ? "AI city forecast" : "AI 城市级预测"} -

- {aiScan?.status === "ready" - ? (isEn ? aiScan.summary_en || aiScan.summary_zh : aiScan.summary_zh || aiScan.summary_en) || - (isEn ? "V4 has reviewed the grouped city snapshot." : "V4 已复核城市分组快照。") - : error || - aiScan?.reason || - (isEn - ? "V4 analysis appears inside each opportunity row when AI review data is available." - : "当 AI 复核数据可用时,V4 分析会展示在每条机会的展开层中。")} -

-
-
- -
-
- {isEn ? "Run log" : "运行日志"} - - {aiScan?.status === "ready" - ? isEn - ? `${aiScan.sent_cities ?? "--"} cities · ${aiScan.sent_contracts ?? aiScan.sent_rows ?? "--"} contracts` - : `${aiScan.sent_cities ?? "--"} 城 · ${aiScan.sent_contracts ?? aiScan.sent_rows ?? "--"} 合约` - : isEn - ? "No V4 result yet" - : "暂无 V4 结果"} - -
-
- {logs.length ? ( - logs.map((entry) => ( -
- {entry.time} -
- {entry.title} - {entry.detail ? {entry.detail} : null} -
-
- )) - ) : ( -
- {isEn ? "No request has been sent." : "还没有发起请求。"} -
- )} -
-
- -
- {groups.map((group) => { - const cityThesis = - (isEn - ? group.thesis?.thesis_en || group.thesis?.summary_en || group.rows[0]?.ai_city_thesis_en - : group.thesis?.thesis_zh || group.thesis?.summary_zh || group.rows[0]?.ai_city_thesis_zh) || - (isEn ? "Waiting for AI city forecast." : "等待 AI 城市预测。"); - const clusterNote = - group.thesis?.model_cluster_note || group.rows[0]?.ai_city_model_cluster_note || null; - return ( -
-
-
- {group.city} -

{cityThesis}

-
- {group.thesis?.confidence || group.rows[0]?.ai_city_confidence ? ( - {group.thesis?.confidence || group.rows[0]?.ai_city_confidence} - ) : null} -
- {clusterNote ? ( -
{clusterNote}
- ) : null} -
- {group.rows.map((row) => { - const decision = String(row.ai_decision || "neutral").toLowerCase(); - const reason = getRowAiReason(row, locale); - const tempSymbol = row.temp_symbol || row.target_unit || "°C"; - return ( -
-
- - {normalizeTemperatureLabel(row.target_label, tempSymbol) || - row.market_question || - row.id} - - - {row.deb_prediction != null - ? `DEB ${formatTemperatureValue(row.deb_prediction, tempSymbol)} · ` - : ""} - {row.ai_forecast_confidence - ? `${isEn ? "confidence" : "信心"} ${row.ai_forecast_confidence}` - : isEn - ? "forecast mapping" - : "预测映射"} - -
- {getAiDecisionLabel(row, locale)} - {reason ?

{reason}

: null} -
- ); - })} -
-
- ); - })} -
-
- ); -} - -function ScanAiLogPanel({ - response, - logs, - locale, - loading, - error, -}: { - response: ScanTerminalResponse | null; - logs: ScanAiLogEntry[]; - locale: string; - loading: boolean; - error?: string | null; -}) { - const isEn = locale === "en-US"; - const aiScan = response?.ai_scan || null; - return ( -
- - {isEn ? "Debug log" : "调试日志"} - - {loading - ? isEn - ? "V4 running" - : "V4 运行中" - : error - ? isEn - ? "V4 failed" - : "V4 失败" - : aiScan?.status === "ready" - ? isEn - ? `${aiScan.sent_cities ?? "--"} cities · ${aiScan.sent_contracts ?? aiScan.sent_rows ?? "--"} contracts` - : `${aiScan.sent_cities ?? "--"} 城 · ${aiScan.sent_contracts ?? aiScan.sent_rows ?? "--"} 合约` - : isEn - ? "Collapsed" - : "已折叠"} - - -
- {logs.length ? ( - logs.map((entry) => ( -
- {entry.time} -
- {entry.title} - {entry.detail ? {entry.detail} : null} -
-
- )) - ) : ( -
- {isEn ? "No request has been sent." : "还没有发起请求。"} -
- )} -
-
- ); -} - function CalendarView({ rows, locale, @@ -690,6 +391,449 @@ function CalendarView({ ); } +function findDetailForCity( + detailsByName: Record, + cityName?: string | null, +) { + const target = normalizeCityKey(cityName); + if (!target) return null; + return ( + Object.values(detailsByName).find((detail) => + [detail?.name, detail?.display_name].some( + (value) => normalizeCityKey(value) === target, + ), + ) || null + ); +} + +function AiCityTemperatureChart({ detail }: { detail: CityDetail }) { + const { locale } = useI18n(); + const chartData = useMemo( + () => 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 obs" : "METAR 实况"); + const canvasRef = useChart(() => { + if (!chartData) { + return { + data: { datasets: [], labels: [] }, + type: "line", + } satisfies ChartConfiguration<"line">; + } + const forecastPoints = chartData.datasets.hasMgmHourly + ? chartData.datasets.mgmHourlyPoints + : chartData.datasets.debPast.map( + (value, index) => value ?? chartData.datasets.debFuture[index], + ); + return { + data: { + datasets: [ + { + borderColor: "#4DA3FF", + borderWidth: 2, + data: forecastPoints, + fill: false, + label: forecastLabel, + pointRadius: 0, + spanGaps: true, + tension: 0.32, + }, + { + backgroundColor: "#22C55E", + borderColor: "#22C55E", + borderWidth: 0, + data: chartData.datasets.metarPoints, + fill: false, + label: observationLabel, + pointHoverRadius: 5, + pointRadius: 3.5, + showLine: false, + }, + ], + labels: chartData.times, + }, + options: { + interaction: { intersect: false, mode: "index" }, + layout: { padding: { bottom: 2, left: 0, right: 8, top: 8 } }, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: "rgba(11, 18, 32, 0.96)", + borderColor: "rgba(77, 163, 255, 0.38)", + borderWidth: 1, + }, + }, + responsive: true, + scales: { + x: { + grid: { color: "rgba(159, 178, 199, 0.08)" }, + ticks: { + callback: (_value, index) => + typeof index === "number" && index % 4 === 0 + ? chartData.times[index] + : "", + color: "#6B7A90", + font: { size: 10 }, + maxTicksLimit: 6, + maxRotation: 0, + }, + }, + y: { + grid: { color: "rgba(159, 178, 199, 0.08)" }, + max: chartData.max, + min: chartData.min, + ticks: { + 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, forecastLabel, observationLabel]); + + return ( +
+
+ + {locale === "en-US" ? "Intraday path" : "今日日内分析"} +
+
+ +
+ {chartData ? ( +
+ {forecastLabel} + {observationLabel} +
+ ) : null} +
+ ); +} + +function AiPinnedCityCard({ + item, + detail, + row, + locale, + onRemove, +}: { + item: AiPinnedCity; + detail: CityDetail | null; + row: ScanOpportunityRow | null; + locale: string; + onRemove: () => void; +}) { + const isEn = locale === "en-US"; + const displayName = + detail?.display_name || + row?.city_display_name || + row?.display_name || + item.displayName || + item.cityName; + const tempSymbol = detail?.temp_symbol || row?.temp_symbol || "°C"; + const modelView = detail ? getModelView(detail, detail.local_date) : null; + const modelValues = modelView + ? Object.values(modelView.models || {}) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)) + : []; + const modelMin = modelValues.length ? Math.min(...modelValues) : null; + const modelMax = modelValues.length ? Math.max(...modelValues) : null; + const paceView = detail ? getTodayPaceView(detail, locale as "zh-CN" | "en-US") : null; + const peakWindow = + paceView?.peakWindowText || + (row ? getPeakWindowLabel(row) : null) || + "--"; + const deb = detail?.deb?.prediction ?? row?.deb_prediction ?? null; + const currentTemp = + detail?.airport_primary?.temp ?? + detail?.airport_current?.temp ?? + detail?.current?.temp ?? + row?.current_temp ?? + null; + const modelRange = + modelMin != null && modelMax != null + ? `${formatTemperatureValue(modelMin, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(modelMax, tempSymbol, { digits: 1 })}` + : "--"; + const paceTone = paceView?.biasTone || "neutral"; + const paceText = + paceView?.summary || + (isEn + ? "Waiting for intraday observations to compare against the DEB path." + : "等待更多日内实测,用来对照 DEB 预测路径。"); + const report = detail?.current?.raw_metar || detail?.airport_current?.raw_metar || ""; + const weatherLine = + detail?.current?.wx_desc || + detail?.airport_current?.wx_desc || + detail?.airport_primary?.wx_desc || + ""; + + return ( +
+
+
+ + {isEn ? "AI city forecast" : "AI 城市预测"} + +

{displayName}

+
+ {detail?.local_time || row?.local_time || "--"} + + DEB{" "} + {deb != null + ? formatTemperatureValue(deb, tempSymbol, { digits: 1 }) + : "--"} + + {isEn ? "Model" : "模型"} {modelRange} + {isEn ? "Peak" : "峰值"} {peakWindow} +
+
+
+ {isEn ? "Expected high" : "预计最高温"} + + {paceView?.paceAdjustedHigh != null + ? formatTemperatureValue(paceView.paceAdjustedHigh, tempSymbol, { digits: 1 }) + : deb != null + ? formatTemperatureValue(deb, tempSymbol, { digits: 1 }) + : "--"} + + +
+
+ + {detail ? ( +
+
+
+ {isEn ? "Final read" : "最终判断"} + + {paceTone === "warm" + ? isEn + ? "Running above DEB path" + : "实测高于 DEB 路径" + : paceTone === "cold" + ? isEn + ? "Running below DEB path" + : "实测低于 DEB 路径" + : isEn + ? "Tracking DEB path" + : "基本贴合 DEB 路径"} + +

{paceText}

+
+
+ + {isEn ? "Observed" : "实测"}{" "} + + {currentTemp != null + ? formatTemperatureValue(currentTemp, tempSymbol, { digits: 1 }) + : "--"} + + + + {isEn ? "Path delta" : "路径偏差"} {paceView?.deltaText || "--"} + + + {isEn ? "Model support" : "模型支持"} {modelValues.length} + +
+
+ +
+ +
+
+ {isEn ? "Airport / climate read" : "机场报文与天气解读"} +
+

{weatherLine || (isEn ? "No weather text decoded yet." : "暂无天气文本解读。")}

+

+ {report + ? `${detail.risk?.icao || detail.current?.station_code || ""} ${report}`.trim() + : isEn + ? "No raw METAR available." + : "暂无原始 METAR 报文。"} +

+

{paceView?.kicker || ""}

+
+
+ +
+
+ {isEn ? "Multi-model support" : "多模型支撑"} +
+ +
+ +
+ ) : ( +
+ {isEn ? "Loading today analysis for this city..." : "正在加载该城市的今日日内分析..."} +
+ )} +
+ ); +} + +function AiPinnedForecastView({ + items, + rows, + detailsByName, + locale, + onRemoveCity, +}: { + items: AiPinnedCity[]; + rows: ScanOpportunityRow[]; + detailsByName: Record; + locale: string; + onRemoveCity: (cityName: string) => void; +}) { + const isEn = locale === "en-US"; + if (!items.length) { + return ( +
+
+
+ {isEn ? "Click a city on the map" : "从分布视图点击城市"} +
+
+ {isEn + ? "Selected cities will appear here as pinned AI forecast blocks." + : "被点击的城市会加入 AI 预测页,并保留为可固定的城市分析区块。"} +
+
+
+ ); + } + + return ( +
+
+
+ {isEn ? "Pinned city workspace" : "固定城市工作区"} + + {isEn + ? `${items.length} cities under AI forecast` + : `${items.length} 个城市正在 AI 预测`} + +
+

+ {isEn + ? "Map clicks add cities here. Pinned city analysis stays here until you remove it." + : "地图点击会把城市加入这里;已固定的城市分析会保留,直到你手动移除。"} +

+
+
+ {items.map((item) => { + const detail = findDetailForCity(detailsByName, item.cityName); + const row = findRowForCity(rows, item.cityName); + const key = normalizeCityKey(item.cityName); + return ( + onRemoveCity(item.cityName)} + /> + ); + })} +
+
+ ); +} + +function AiForecastKPIBar({ + pinnedCount, + activeCityName, + activeDetail, + activeRow, + locale, +}: { + pinnedCount: number; + activeCityName?: string | null; + activeDetail?: CityDetail | null; + activeRow?: ScanOpportunityRow | null; + locale: string; +}) { + const isEn = locale === "en-US"; + const tempSymbol = activeDetail?.temp_symbol || activeRow?.temp_symbol || "°C"; + const displayName = + activeDetail?.display_name || + activeRow?.city_display_name || + activeRow?.display_name || + activeCityName || + "--"; + const deb = activeDetail?.deb?.prediction ?? activeRow?.deb_prediction ?? null; + const paceView = activeDetail + ? getTodayPaceView(activeDetail, locale as "zh-CN" | "en-US") + : null; + const peakWindow = + paceView?.peakWindowText || + (activeRow ? getPeakWindowLabel(activeRow) : null) || + "--"; + const cards = [ + { + label: isEn ? "AI Workspace" : "AI 工作区", + value: String(pinnedCount), + note: isEn ? "Pinned cities from map clicks" : "地图点选后固定到 AI 预测", + tone: "green", + }, + { + label: isEn ? "Current City" : "当前城市", + value: displayName, + note: isEn ? "City briefing stays in the right rail" : "右侧城市简报同步显示,不自动切页", + tone: "blue", + }, + { + label: isEn ? "Forecast Center" : "预测中枢", + value: + deb != null + ? formatTemperatureValue(deb, tempSymbol, { digits: 1 }) + : "--", + note: isEn ? "DEB final blended forecast" : "DEB 最终融合预测,不含市场价格", + tone: "cyan", + }, + { + label: isEn ? "Peak Window" : "峰值窗口", + value: peakWindow, + note: isEn ? "Key window for daily high judgment" : "用于判断是否接近今日最高温", + tone: "amber", + }, + ]; + + return ( +
+ {cards.map((card) => ( +
+
{card.label}
+
{card.value}
+
{card.note}
+
+ ))} +
+ ); +} + function ScanTerminalScreen() { const store = useDashboardStore(); const { locale, toggleLocale } = useI18n(); @@ -698,34 +842,20 @@ function ScanTerminalScreen() { const accountHref = store.proAccess.authenticated ? "/account" : "/auth/login?next=%2Faccount"; - const [activeFilters, setActiveFilters] = useState(DEFAULT_FILTERS); - const [terminalData, setTerminalData] = useState(null); - const [loading, setLoading] = useState(false); - const [aiLoading, setAiLoading] = useState(false); - const [aiError, setAiError] = useState(null); - const [error, setError] = useState(null); + const [terminalData] = useState(null); const [selectedRowId, setSelectedRowId] = useState(null); const [activeView, setActiveView] = useState("map"); const [mapSelectedCityName, setMapSelectedCityName] = useState(null); + const [aiPinnedCities, setAiPinnedCities] = useState([]); const [showScanPaywall, setShowScanPaywall] = useState(false); - const [aiLogs, setAiLogs] = useState([]); const [userLocalTime, setUserLocalTime] = useState("--"); const [themeMode, setThemeMode] = useState("dark"); - const deferredRows = useDeferredValue(terminalData?.rows || []); - const preloadedSnapshotRef = useRef(null); + const lastMapSelectedCityRef = useRef(""); - const prependAiLogs = (entries: ScanAiLogEntry[]) => { - setAiLogs((current) => [...entries, ...current].slice(0, 8)); - }; const timeSortedRows = useMemo( - () => sortRowsByUserTime(deferredRows), - [deferredRows], + () => sortRowsByUserTime(terminalData?.rows || []), + [terminalData?.rows], ); - const marketFocus = useMemo( - () => getMarketFocus(timeSortedRows, locale), - [locale, timeSortedRows], - ); - const selectedRow = useMemo(() => { if (!timeSortedRows.length) return null; return timeSortedRows.find((row) => row.id === selectedRowId) || timeSortedRows[0] || null; @@ -737,6 +867,18 @@ function ScanTerminalScreen() { mapSelectedCityName || store.selectedCity, ); }, [mapSelectedCityName, store.selectedCity, timeSortedRows]); + const kpiCityName = + mapSelectedCityName || + store.selectedCity || + aiPinnedCities[0]?.cityName || + null; + const kpiDetail = + findDetailForCity(store.cityDetailsByName, kpiCityName) || + (store.selectedDetail && + normalizeCityKey(store.selectedDetail.name) === normalizeCityKey(kpiCityName) + ? store.selectedDetail + : null); + const kpiRow = findRowForCity(timeSortedRows, kpiCityName); const mapFallbackRow = useMemo(() => { const rawCityName = mapSelectedCityName || store.selectedCity; @@ -826,204 +968,8 @@ function ScanTerminalScreen() { store.selectedDetail, ]); - const fetchTerminal = async (filters: FilterState, force = false) => { - if (!isPro) return; - setLoading(true); - setAiError(null); - try { - const response = await dashboardClient.getScanTerminal(filters, { force }); - const displayRows = response.rows.length - ? response.rows - : terminalData?.rows || []; - startTransition(() => { - setTerminalData((current) => - mergeRefreshingScanSnapshot(current, response, locale), - ); - setActiveFilters(filters); - setError(response.status === "failed" ? response.stale_reason || null : null); - setSelectedRowId((current) => { - if (current && displayRows.some((row) => row.id === current)) { - return current; - } - return sortRowsByUserTime(displayRows)[0]?.id || response.top_signal?.id || null; - }); - }); - prependAiLogs([ - { - id: `rule-${Date.now()}`, - time: formatLogTime(), - tone: displayRows.length ? "success" : "warning", - title: isEn ? "Rule scan snapshot ready" : "规则扫描快照已就绪", - detail: isEn - ? `snapshot ${response.snapshot_id || "--"} · ${displayRows.length} visible rows` - : `快照 ${response.snapshot_id || "--"} · 可见候选 ${displayRows.length} 条`, - }, - ]); - } catch (fetchError) { - setError(String(fetchError)); - } finally { - setLoading(false); - } - }; - - const runAiReview = async () => { - if (aiLoading) return; - if (!terminalData?.rows.length) { - prependAiLogs([ - { - id: `no-rows-${Date.now()}`, - time: formatLogTime(), - tone: "warning", - title: isEn ? "V4 not started" : "V4 未启动", - detail: isEn - ? "Run the rule scan first. V4 only reviews existing candidate rows." - : "需要先完成规则扫描。V4 只复核已有候选,不会凭空抓市场。", - }, - ]); - return; - } - const snapshotId = terminalData.snapshot_id || null; - const rowCount = terminalData.rows.length; - prependAiLogs([ - { - id: `queued-${Date.now()}`, - time: formatLogTime(), - tone: "info", - title: isEn ? "V4 review queued" : "V4 复核已排队", - detail: isEn - ? `snapshot ${snapshotId || "--"} · ${rowCount} rule candidates grouped by city` - : `快照 ${snapshotId || "--"} · ${rowCount} 条候选将按城市分组`, - }, - { - id: `send-${Date.now() + 1}`, - time: formatLogTime(), - tone: "info", - title: isEn ? "Sending city-level context" : "正在发送城市级上下文", - detail: isEn - ? "Each city includes EMOS distribution, model cluster and contracts." - : "每城包含 EMOS 分布、模型集群和候选合约。", - }, - ]); - setActiveView("list"); - setAiLoading(true); - setAiError(null); - try { - const response = await dashboardClient.reviewScanTerminalWithAi({ - filters: terminalData.filters || activeFilters, - snapshotId, - }); - const review = response.ai_scan; - startTransition(() => { - setTerminalData(response); - setActiveFilters(response.filters || activeFilters); - setError(response.status === "failed" ? response.stale_reason || null : null); - setSelectedRowId((current) => { - if (current && response.rows.some((row) => row.id === current)) { - return current; - } - return sortRowsByUserTime(response.rows)[0]?.id || response.top_signal?.id || null; - }); - }); - prependAiLogs([ - { - id: `done-${Date.now()}`, - time: formatLogTime(), - tone: review?.status === "ready" ? "success" : "warning", - title: - review?.status === "ready" - ? review.cached - ? isEn - ? "V4 cache hit" - : "V4 命中缓存" - : isEn - ? "V4 review completed" - : "V4 复核完成" - : isEn - ? "V4 fell back to rule scan" - : "V4 已回退规则扫描", - detail: - review?.status === "ready" - ? isEn - ? `${review.model || "V4"} · ${formatDuration(review.duration_ms)} · ${review.sent_cities ?? "--"} cities / ${review.sent_contracts ?? review.sent_rows ?? "--"} contracts · ${review.recommended_count ?? 0} picks / ${review.vetoed_count ?? 0} veto` - : `${review.model || "V4"} · ${formatDuration(review.duration_ms)} · ${review.sent_cities ?? "--"} 城 / ${review.sent_contracts ?? review.sent_rows ?? "--"} 合约 · 推荐 ${review.recommended_count ?? 0} / 排除 ${review.vetoed_count ?? 0}` - : review?.reason || (isEn ? "No AI result returned." : "没有返回 AI 结果。"), - }, - ...(review?.usage - ? [ - { - id: `usage-${Date.now() + 1}`, - time: formatLogTime(), - tone: "info" as const, - title: isEn ? "Token usage" : "Token 用量", - detail: isEn - ? `input ${review.usage.prompt_tokens ?? "--"} · output ${review.usage.completion_tokens ?? "--"} · total ${review.usage.total_tokens ?? "--"}` - : `输入 ${review.usage.prompt_tokens ?? "--"} · 输出 ${review.usage.completion_tokens ?? "--"} · 总计 ${review.usage.total_tokens ?? "--"}`, - }, - ] - : []), - ]); - } catch (reviewError) { - setAiError(String(reviewError)); - prependAiLogs([ - { - id: `error-${Date.now()}`, - time: formatLogTime(), - tone: "error", - title: isEn ? "V4 request failed" : "V4 请求失败", - detail: String(reviewError), - }, - ]); - } finally { - setAiLoading(false); - } - }; - useEffect(() => { - if (!isPro) return; - void fetchTerminal(DEFAULT_FILTERS, false); - }, [isPro]); - - useEffect(() => { - if (!isPro || !terminalData?.rows.length) return; - const snapshotKey = - terminalData.snapshot_id || - terminalData.generated_at || - terminalData.rows.map((row) => row.id).join("|"); - if (!snapshotKey || preloadedSnapshotRef.current === snapshotKey) return; - preloadedSnapshotRef.current = snapshotKey; - - const seen = new Set(); - const cities = sortRowsByUserTime(terminalData.rows) - .map((row) => String(row.city || row.city_display_name || row.display_name || "").trim()) - .filter((city) => { - const key = normalizeCityKey(city); - if (!key || seen.has(key)) return false; - seen.add(key); - return true; - }) - .slice(0, 12); - - cities.forEach((cityName) => { - void store.ensureCityDetail(cityName, false, "market").catch(() => {}); - }); - }, [ - isPro, - store.ensureCityDetail, - terminalData?.rows, - terminalData?.generated_at, - terminalData?.snapshot_id, - ]); - - useEffect(() => { - if (!isPro) return; - const intervalId = window.setInterval(() => { - void fetchTerminal(activeFilters, false); - }, SCAN_AUTO_REFRESH_MS); - return () => window.clearInterval(intervalId); - }, [activeFilters, isPro]); - - useEffect(() => { - if (!store.proAccess.loading && !isPro && activeView !== "map") { + if (!store.proAccess.loading && !isPro && activeView === "calendar") { setActiveView("map"); } }, [activeView, isPro, store.proAccess.loading]); @@ -1053,31 +999,8 @@ function ScanTerminalScreen() { resolvedView === "map" && mapFocusedCity ? mapFocusedRow || mapFallbackRow : selectedRow; - const scanStatus = terminalData?.status || (loading ? "loading" : error ? "failed" : "ready"); - const staleReason = - terminalData?.stale_reason || error || null; - const aiScan = terminalData?.ai_scan || null; - const aiStatusText = aiLoading - ? isEn - ? "V4 reviewing" - : "V4 复核中" - : aiError - ? isEn - ? "V4 failed" - : "V4 失败" - : aiScan?.status === "ready" - ? isEn - ? aiScan.cached - ? "V4 cached" - : "V4 reviewed" - : aiScan.cached - ? "V4 缓存" - : "V4 已复核" - : aiScan?.status - ? isEn - ? "Rule scan" - : "使用规则扫描" - : null; + const scanStatus = terminalData?.status || "ready"; + const staleReason = terminalData?.stale_reason || null; useEffect(() => { if (!activeDetailRow) return; @@ -1086,11 +1009,74 @@ function ScanTerminalScreen() { } }, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]); + const addAiPinnedCity = useCallback((cityName: string) => { + const cleanName = String(cityName || "").trim(); + const key = normalizeCityKey(cleanName); + if (!key) return; + const matchedRow = findRowForCity(timeSortedRows, cleanName); + const prettyName = prettifyCityName(cleanName); + const displayName = + matchedRow?.city_display_name || + matchedRow?.display_name || + getLocalizedCityName(cleanName, prettyName || cleanName, locale) || + prettyName || + cleanName; + setAiPinnedCities((current) => { + const existing = current.findIndex( + (item) => normalizeCityKey(item.cityName) === key, + ); + const nextItem = { + cityName: matchedRow?.city || cleanName, + displayName, + addedAt: Date.now(), + }; + if (existing >= 0) { + const next = [...current]; + next[existing] = { ...next[existing], ...nextItem }; + return [ + next[existing], + ...next.filter((_, index) => index !== existing), + ]; + } + return [nextItem, ...current].slice(0, 8); + }); + void store.ensureCityDetail(cleanName, false, "full").catch(() => {}); + }, [locale, store.ensureCityDetail, timeSortedRows]); + + const removeAiPinnedCity = useCallback((cityName: string) => { + const key = normalizeCityKey(cityName); + setAiPinnedCities((current) => + current.filter((item) => normalizeCityKey(item.cityName) !== key), + ); + }, []); + + useEffect(() => { + aiPinnedCities.forEach((item) => { + if (!findDetailForCity(store.cityDetailsByName, item.cityName)) { + void store.ensureCityDetail(item.cityName, false, "full").catch(() => {}); + } + }); + }, [aiPinnedCities, store.cityDetailsByName, store.ensureCityDetail]); + const handleMapCitySelect = useCallback((cityName: string) => { setMapSelectedCityName(cityName); + lastMapSelectedCityRef.current = normalizeCityKey(cityName); const matchedRow = findRowForCity(timeSortedRows, cityName); setSelectedRowId(matchedRow?.id || null); - }, [timeSortedRows]); + addAiPinnedCity(cityName); + }, [addAiPinnedCity, timeSortedRows]); + + useEffect(() => { + if (activeView !== "map") return; + const selectedCity = String(store.selectedCity || "").trim(); + const selectedKey = normalizeCityKey(selectedCity); + if (!selectedKey || selectedKey === lastMapSelectedCityRef.current) return; + lastMapSelectedCityRef.current = selectedKey; + setMapSelectedCityName(selectedCity); + const matchedRow = findRowForCity(timeSortedRows, selectedCity); + setSelectedRowId(matchedRow?.id || null); + addAiPinnedCity(selectedCity); + }, [activeView, addAiPinnedCity, store.selectedCity, timeSortedRows]); const handleSelectRow = useCallback((row: ScanOpportunityRow) => { const cityName = row.city || row.city_display_name || row.display_name || ""; @@ -1129,6 +1115,17 @@ function ScanTerminalScreen() {
); } + if (resolvedView === "list") { + return ( + + ); + } if (!isPro) { return (
@@ -1155,20 +1152,7 @@ function ScanTerminalScreen() { /> ); } - return ( - <> - - - ); + return null; }; if (store.proAccess.loading) { @@ -1196,21 +1180,9 @@ function ScanTerminalScreen() {
{isEn ? "Market Scan Terminal" : "市场扫描台"} - {loading - ? isEn - ? "Refreshing current market snapshot" - : "正在刷新当前市场快照" - : terminalData?.stale - ? isEn - ? "Showing the last successful snapshot" - : "当前显示上次成功快照" - : isEn - ? isPro - ? "Read-only market scan with DEB-first win-rate signal" - : "Free preview: distribution view and city briefing" - : isPro - ? "只读市场扫描,主信号按 DEB 最终融合预测优先" - : "免费预览:分布视图和城市简报可查看"} + {isEn + ? "Click cities on the map to build an AI forecast workspace" + : "点击地图城市加入 AI 预测工作区,按城市查看 DEB / 模型 / METAR"}
@@ -1251,12 +1223,6 @@ function ScanTerminalScreen() { > {themeMode === "light" ? : } - {isPro ? ( - - ) : null} {store.proAccess.authenticated ? (
- {isPro ? ( - - ) : null} +
@@ -1286,6 +1251,7 @@ function ScanTerminalScreen() { type="button" className={resolvedView === "map" ? "active" : ""} onClick={() => { + lastMapSelectedCityRef.current = normalizeCityKey(store.selectedCity); setActiveView("map"); }} > @@ -1294,16 +1260,11 @@ function ScanTerminalScreen() {
- {marketFocus ? ( - - {isEn - ? `Focus: ${marketFocus.label}` - : `当前主盘:${marketFocus.label}`} - - ) : null} {terminalData?.stale ? ( {isEn ? "Delayed snapshot" : "延迟快照"} ) : null} - {loading ? ( - - {isEn ? "Refreshing" : "刷新中"} - - ) : null} - {aiStatusText ? ( - - {aiStatusText} - - ) : null}
@@ -1354,18 +1298,7 @@ function ScanTerminalScreen() {
{staleReason}
) : ( - <> - {renderMainView()} - {isPro ? ( - - ) : null} - + renderMainView() )} diff --git a/frontend/components/dashboard/WeatherAuraLayer.tsx b/frontend/components/dashboard/WeatherAuraLayer.tsx index 27e8dec1..8fb18523 100644 --- a/frontend/components/dashboard/WeatherAuraLayer.tsx +++ b/frontend/components/dashboard/WeatherAuraLayer.tsx @@ -475,7 +475,7 @@ export function WeatherAuraLayer() { `radial-gradient(circle at 78% 20%, ${hexToRgba(aura.secondary, 0.14 * aura.intensity)}, transparent 34%)`, `radial-gradient(circle at 52% 78%, ${hexToRgba(aura.tertiary, 0.12 * aura.intensity)}, transparent 38%)`, aura.effect === "rain" || aura.effect === "storm" - ? `linear-gradient(180deg, ${hexToRgba("#67e8f9", 0.06 * aura.effectIntensity)}, transparent 45%)` + ? `linear-gradient(180deg, ${hexToRgba("#6FB7FF", 0.06 * aura.effectIntensity)}, transparent 45%)` : aura.effect === "snow" ? `linear-gradient(180deg, ${hexToRgba("#e2e8f0", 0.06 * aura.effectIntensity)}, transparent 45%)` : aura.effect === "fog" diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index c88ebb2d..bc55413c 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -939,24 +939,15 @@ export function DashboardStoreProvider({ setSelectedForecastDate(null); setFutureModalDate(null); setForecastModalMode(null); - const depth: CityDetailDepth = proAccessRef.current.subscriptionActive - ? "market" - : "panel"; setLoadingState((current) => ({ ...current, cityDetail: true })); void Promise.allSettled([ ensureCitySummary(cityName), - ensureCityDetail(cityName, false, depth), + ensureCityDetail(cityName, false, "panel"), ]) .then(([, detail]) => { if (selectedCityRef.current !== cityName) return; if (detail.status === "fulfilled") { setSelectedForecastDate(detail.value.local_date); - if ( - proAccessRef.current.subscriptionActive && - !detail.value.market_scan - ) { - void ensureCityMarketScan(cityName, false); - } } }) .finally(() => { @@ -965,15 +956,6 @@ export function DashboardStoreProvider({ }); }; - useEffect(() => { - if (!selectedCity) return; - if (!proAccess.subscriptionActive) return; - const detail = cityDetailsByName[selectedCity]; - if (!detail) return; - if (detail.market_scan) return; - void ensureCityMarketScan(selectedCity, false); - }, [cityDetailsByName, proAccess.subscriptionActive, selectedCity]); - const clearCityFocus = () => { selectedCityRef.current = null; setSelectedCity(null); @@ -1220,11 +1202,6 @@ export function DashboardStoreProvider({ const hasFullCachedDetail = detailSatisfiesDepth(cachedDetail, "full") && !hasSparseDetailCoverage(cachedDetail, dateStr); - const hasMarketCachedDetail = detailSatisfiesDepth( - cachedDetail, - "market", - dateStr, - ); const todayDate = cachedDetail?.local_date || cachedDetail?.forecast?.daily?.[0]?.date || @@ -1235,9 +1212,6 @@ export function DashboardStoreProvider({ setSelectedForecastDate(dateStr); setFutureModalDate(dateStr); setForecastModalMode(modalMode); - if (!hasMarketCachedDetail || forceRefresh) { - void ensureCityDetail(cityName, forceRefresh, "market").catch(() => {}); - } if (!hasFullCachedDetail || forceRefresh) { setLoadingState((current) => ({ ...current, @@ -1282,11 +1256,6 @@ export function DashboardStoreProvider({ const hasFullCachedDetail = detailSatisfiesDepth(cachedDetail, "full") && !hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date); - const hasMarketCachedDetail = detailSatisfiesDepth( - cachedDetail, - "market", - cachedDetail?.local_date, - ); const targetDate = cachedDetail?.local_date || cachedDetail?.forecast?.daily?.[0]?.date || @@ -1306,13 +1275,6 @@ export function DashboardStoreProvider({ ...current, futureDeep: needsDetailRefresh, })); - if (!hasMarketCachedDetail || forceRefresh) { - void ensureCityDetail( - cityName, - Boolean(forceRefresh), - "market", - ).catch(() => {}); - } void ensureCityDetail( cityName, needsDetailRefresh, diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index 67585828..8d436e71 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -326,6 +326,42 @@ function buildTemperatureTickLabels(times: string[]) { }); } +function getNiceTemperatureScale(values: number[], tempSymbol?: string | null) { + const numericValues = values.filter((value) => Number.isFinite(Number(value))); + if (!numericValues.length) { + return { max: 1, min: 0, step: 1 }; + } + + const rawMin = Math.min(...numericValues); + const rawMax = Math.max(...numericValues); + const spread = Math.max(0.1, rawMax - rawMin); + const isFahrenheit = normalizeTemperatureSymbol(tempSymbol) === "°F"; + const padding = Math.max(isFahrenheit ? 1.5 : 0.8, spread * 0.12); + const paddedMin = rawMin - padding; + const paddedMax = rawMax + padding; + const paddedSpread = Math.max(0.1, paddedMax - paddedMin); + const candidates = isFahrenheit ? [1, 2, 5, 10, 20] : [0.5, 1, 2, 5, 10]; + let step = + candidates.find((candidate) => candidate >= paddedSpread / 4) || + candidates[candidates.length - 1]; + let min = Math.floor(paddedMin / step) * step; + let max = Math.ceil(paddedMax / step) * step; + + if (min < 0 && rawMin >= 0 && rawMin <= step * 1.25) min = 0; + if (max <= min) max = min + step * 4; + + while ((max - min) / step + 1 > 6) { + const nextStep = candidates.find((candidate) => candidate > step); + if (!nextStep) break; + step = nextStep; + min = Math.floor(paddedMin / step) * step; + max = Math.ceil(paddedMax / step) * step; + if (min < 0 && rawMin >= 0 && rawMin <= step * 1.25) min = 0; + } + + return { max, min, step }; +} + function buildSeriesPoints( times: string[], values: Array, @@ -905,8 +941,10 @@ export function getTemperatureChartData( if (!allValues.length) return null; - const min = Math.floor(Math.min(...allValues)) - 1; - const max = Math.ceil(Math.max(...allValues)) + 1; + const yScale = getNiceTemperatureScale(allValues, detail.temp_symbol); + const min = yScale.min; + const max = yScale.max; + const yTickStep = yScale.step; const tafMarkersRaw = Array.isArray(detail.taf?.signal?.markers) ? detail.taf?.signal?.markers || [] : []; @@ -1240,6 +1278,7 @@ export function getTemperatureChartData( times, xMax, xMin, + yTickStep, }; } diff --git a/frontend/package.json b/frontend/package.json index a92b2e40..93f07402 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,8 +3,8 @@ "version": "1.5.4", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", + "dev": "node scripts/sync-next-server-chunks.mjs --clean-root && next dev", + "build": "next build && node scripts/sync-next-server-chunks.mjs", "start": "next start", "lint": "next lint" }, diff --git a/frontend/scripts/sync-next-server-chunks.mjs b/frontend/scripts/sync-next-server-chunks.mjs new file mode 100644 index 00000000..c0861643 --- /dev/null +++ b/frontend/scripts/sync-next-server-chunks.mjs @@ -0,0 +1,29 @@ +import { copyFile, mkdir, readdir, unlink } from "node:fs/promises"; +import path from "node:path"; + +const serverDir = path.join(process.cwd(), ".next", "server"); +const chunksDir = path.join(serverDir, "chunks"); +const cleanRoot = process.argv.includes("--clean-root"); + +try { + const entries = await readdir(chunksDir, { withFileTypes: true }); + await mkdir(serverDir, { recursive: true }); + await Promise.all( + entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".js")) + .map(async (entry) => { + const target = path.join(serverDir, entry.name); + if (cleanRoot) { + await unlink(target).catch((error) => { + if (error?.code !== "ENOENT") throw error; + }); + return; + } + await copyFile(path.join(chunksDir, entry.name), target); + }), + ); +} catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } +}