From 2b1d7c0b65ed62cf28dbf1ca516b073a4b44499c Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Tue, 28 Apr 2026 20:19:17 +0800
Subject: [PATCH] Improve dashboard maintainability before the next release
The dashboard had several oversized orchestration, component, and CSS files that made product-copy changes and mobile/performance work risky. This refactor preserves behavior while splitting scan terminal CSS, opportunity helpers, future forecast panels, history/detail charts, and probability/model sections into smaller ownership boundaries.
Constraint: No user-visible version bump because this batch is architecture and performance cleanup, not a release announcement.
Rejected: Rewrite dashboard state management in the same batch | too broad for a safe upload after CSS and component splitting.
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep new component/CSS boundaries instead of moving product copy back into the large dashboard files.
Tested: npm run build; npm run test:business; git diff --check
Not-tested: Browser visual smoke test after push
---
.../components/dashboard/Dashboard.module.css | 8021 -----------------
.../DashboardHomeIntelligence.module.css | 2248 +++++
.../dashboard/DashboardMap.module.css | 846 ++
.../dashboard/DashboardModalGuide.module.css | 303 +
.../dashboard/DashboardShell.module.css | 827 ++
.../dashboard/DetailMiniTemperatureChart.tsx | 135 +
frontend/components/dashboard/DetailPanel.tsx | 141 +-
.../dashboard/DetailPanelChrome.module.css | 1 -
.../dashboard/DetailPanelContent.module.css | 290 +
.../dashboard/DetailPanelSections.module.css | 1065 +++
.../dashboard/FutureForecastForwardView.tsx | 80 +
.../dashboard/FutureForecastModal.module.css | 1543 ++++
.../dashboard/FutureForecastModal.tsx | 1332 +--
.../dashboard/FutureForecastModal.utils.ts | 252 +
.../dashboard/FutureForecastModalChart.tsx | 411 +
.../dashboard/FutureForecastModalPanels.tsx | 87 +
.../dashboard/FutureForecastModalStatus.tsx | 63 +
.../FutureForecastModalWeatherIcon.tsx | 26 +
.../dashboard/FutureForecastTodayCards.tsx | 281 +
.../FutureForecastTodayDecisionBrief.tsx | 87 +
.../FutureForecastTodayEvidenceGrid.tsx | 129 +
.../components/dashboard/HistoryChart.tsx | 163 +
.../dashboard/HistoryModal.module.css | 530 ++
.../components/dashboard/HistoryModal.tsx | 167 +-
.../components/dashboard/ModelForecast.tsx | 283 +
.../components/dashboard/OpportunityTable.tsx | 2050 +----
.../dashboard/OpportunityTable.utils.ts | 87 +
.../components/dashboard/PanelSections.tsx | 1210 +--
.../dashboard/ProbabilityDistribution.tsx | 956 ++
.../dashboard/ScanTerminal.module.css | 2278 -----
.../dashboard/ScanTerminalBoard.module.css | 651 ++
.../dashboard/ScanTerminalCalendar.module.css | 2 +
.../dashboard/ScanTerminalDashboard.tsx | 56 +-
.../dashboard/ScanTerminalDetail.module.css | 353 +
.../dashboard/ScanTerminalFilters.module.css | 241 +
.../dashboard/ScanTerminalList.module.css | 544 ++
.../ScanTerminalOpportunity.module.css | 2 +
.../dashboard/ScanTerminalShell.module.css | 249 +
.../dashboard/ScanTerminalState.module.css | 242 +
.../dashboard/opportunity-ai-meta.ts | 213 +
.../dashboard/opportunity-airport-read.ts | 274 +
.../components/dashboard/opportunity-copy.ts | 10 +
.../dashboard/opportunity-detail.ts | 37 +
.../dashboard/opportunity-evidence-summary.ts | 127 +
.../dashboard/opportunity-format.ts | 124 +
.../dashboard/opportunity-groups.ts | 185 +
.../dashboard/opportunity-model-summary.ts | 46 +
.../dashboard/opportunity-observation.ts | 328 +
.../dashboard/opportunity-target.ts | 48 +
.../dashboard/opportunity-v4-decision.ts | 293 +
.../dashboard/opportunity-v4-forecast.ts | 250 +
.../dashboard/opportunity-v4-risk.ts | 104 +
.../dashboard/opportunity-v4-types.ts | 26 +
.../dashboard/opportunity-window-phase.ts | 59 +
.../dashboard/scan-terminal/CalendarView.tsx | 6 +-
.../scan-terminal/OpportunityOverview.tsx | 4 +-
56 files changed, 15303 insertions(+), 15063 deletions(-)
create mode 100644 frontend/components/dashboard/DashboardHomeIntelligence.module.css
create mode 100644 frontend/components/dashboard/DashboardMap.module.css
create mode 100644 frontend/components/dashboard/DashboardModalGuide.module.css
create mode 100644 frontend/components/dashboard/DashboardShell.module.css
create mode 100644 frontend/components/dashboard/DetailMiniTemperatureChart.tsx
create mode 100644 frontend/components/dashboard/DetailPanelContent.module.css
create mode 100644 frontend/components/dashboard/DetailPanelSections.module.css
create mode 100644 frontend/components/dashboard/FutureForecastForwardView.tsx
create mode 100644 frontend/components/dashboard/FutureForecastModal.module.css
create mode 100644 frontend/components/dashboard/FutureForecastModal.utils.ts
create mode 100644 frontend/components/dashboard/FutureForecastModalChart.tsx
create mode 100644 frontend/components/dashboard/FutureForecastModalPanels.tsx
create mode 100644 frontend/components/dashboard/FutureForecastModalStatus.tsx
create mode 100644 frontend/components/dashboard/FutureForecastModalWeatherIcon.tsx
create mode 100644 frontend/components/dashboard/FutureForecastTodayCards.tsx
create mode 100644 frontend/components/dashboard/FutureForecastTodayDecisionBrief.tsx
create mode 100644 frontend/components/dashboard/FutureForecastTodayEvidenceGrid.tsx
create mode 100644 frontend/components/dashboard/HistoryChart.tsx
create mode 100644 frontend/components/dashboard/HistoryModal.module.css
create mode 100644 frontend/components/dashboard/ModelForecast.tsx
create mode 100644 frontend/components/dashboard/OpportunityTable.utils.ts
create mode 100644 frontend/components/dashboard/ProbabilityDistribution.tsx
create mode 100644 frontend/components/dashboard/ScanTerminalBoard.module.css
create mode 100644 frontend/components/dashboard/ScanTerminalDetail.module.css
create mode 100644 frontend/components/dashboard/ScanTerminalFilters.module.css
create mode 100644 frontend/components/dashboard/ScanTerminalList.module.css
create mode 100644 frontend/components/dashboard/ScanTerminalShell.module.css
create mode 100644 frontend/components/dashboard/ScanTerminalState.module.css
create mode 100644 frontend/components/dashboard/opportunity-ai-meta.ts
create mode 100644 frontend/components/dashboard/opportunity-airport-read.ts
create mode 100644 frontend/components/dashboard/opportunity-copy.ts
create mode 100644 frontend/components/dashboard/opportunity-detail.ts
create mode 100644 frontend/components/dashboard/opportunity-evidence-summary.ts
create mode 100644 frontend/components/dashboard/opportunity-format.ts
create mode 100644 frontend/components/dashboard/opportunity-groups.ts
create mode 100644 frontend/components/dashboard/opportunity-model-summary.ts
create mode 100644 frontend/components/dashboard/opportunity-observation.ts
create mode 100644 frontend/components/dashboard/opportunity-target.ts
create mode 100644 frontend/components/dashboard/opportunity-v4-decision.ts
create mode 100644 frontend/components/dashboard/opportunity-v4-forecast.ts
create mode 100644 frontend/components/dashboard/opportunity-v4-risk.ts
create mode 100644 frontend/components/dashboard/opportunity-v4-types.ts
create mode 100644 frontend/components/dashboard/opportunity-window-phase.ts
diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css
index d717a344..63693cba 100644
--- a/frontend/components/dashboard/Dashboard.module.css
+++ b/frontend/components/dashboard/Dashboard.module.css
@@ -120,8024 +120,3 @@
background: #dbeafe;
filter: saturate(0.95) brightness(1.12);
}
-
-/* ── Map ── */
-.root :global(.map) {
- position: absolute;
- top: calc(var(--header-height) + 16px);
- right: 392px;
- bottom: 164px;
- left: calc(var(--sidebar-width) + 22px);
- z-index: 1;
- overflow: hidden;
- border: 1px solid rgba(115, 137, 161, 0.16);
- border-radius: 24px;
- background: #040912;
- box-shadow:
- 0 32px 100px rgba(0, 0, 0, 0.5),
- inset 0 1px 0 rgba(255, 255, 255, 0.04),
- inset 0 0 0 1px rgba(0, 224, 164, 0.04);
-}
-
-.weatherAura {
- position: absolute;
- inset: 0;
- z-index: 2;
- pointer-events: none;
- opacity: 0.96;
- mix-blend-mode: screen;
- overflow: hidden;
-}
-
-.weatherAura :global(canvas) {
- width: 100%;
- height: 100%;
- display: block;
- opacity: 0.94;
- filter: saturate(1.05) blur(0.3px);
-}
-
-.weatherAuraScrim {
- position: absolute;
- inset: 0;
- background:
- linear-gradient(
- 180deg,
- rgba(3, 8, 19, 0.64) 0%,
- rgba(5, 10, 20, 0.16) 24%,
- rgba(4, 8, 18, 0.1) 54%,
- rgba(3, 6, 14, 0.42) 100%
- ),
- radial-gradient(
- circle at 50% 60%,
- rgba(0, 224, 164, 0.08) 0%,
- rgba(123, 97, 255, 0) 48%
- );
-}
-
-.weatherAura[data-reduced-motion="true"] :global(canvas) {
- display: none;
-}
-
-.root :global(.map .leaflet-tile),
-.root :global(.map .leaflet-marker-icon),
-.root :global(.map .leaflet-marker-shadow),
-.root :global(.map .leaflet-container img),
-.root :global(.map .leaflet-container svg) {
- max-width: none !important;
- max-height: none !important;
-}
-
-.root :global(.map .leaflet-tile) {
- width: 256px !important;
- height: 256px !important;
-}
-
-/* Remove Leaflet default styling for cleaner look */
-.root :global(.leaflet-control-attribution) {
- background: var(--bg-glass) !important;
- color: var(--text-muted) !important;
- backdrop-filter: blur(8px);
- border: 1px solid var(--border-subtle) !important;
- font-size: 10px !important;
- border-radius: 6px !important;
- padding: 2px 8px !important;
-}
-.root :global(.leaflet-control-attribution a) {
- color: var(--text-secondary) !important;
-}
-.root :global(.leaflet-control-zoom) {
- border: none !important;
- box-shadow: var(--shadow-lg) !important;
-}
-.root :global(.leaflet-control-zoom a) {
- background: var(--bg-glass) !important;
- color: var(--text-primary) !important;
- backdrop-filter: blur(12px) !important;
- border: 1px solid var(--border-glass) !important;
- width: 36px !important;
- height: 36px !important;
- line-height: 36px !important;
- font-size: 16px !important;
- border-radius: 8px !important;
- transition: var(--transition);
-}
-.root :global(.leaflet-control-zoom a:hover) {
- background: rgba(99, 102, 241, 0.2) !important;
- border-color: var(--accent-blue) !important;
-}
-
-/* ── Header ── */
-.root :global(.header) {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- height: var(--header-height);
- z-index: 1000;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 24px;
- padding: 0 16px;
- background: rgba(7, 11, 18, 0.94);
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
- border-bottom: 1px solid var(--border-glass);
-}
-
-.root :global(.brand) {
- display: flex;
- align-items: center;
- gap: 12px;
- min-width: 0;
-}
-
-.root :global(.brand-mark) {
- width: 30px;
- height: 30px;
- flex-shrink: 0;
- border-radius: 10px;
- border: 1px solid rgba(0, 224, 164, 0.22);
- background: rgba(8, 15, 28, 0.78);
- display: inline-flex;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- box-shadow:
- inset 0 0 0 1px rgba(148, 163, 184, 0.08),
- 0 0 20px rgba(0, 224, 164, 0.12);
-}
-
-.root :global(.brand-mark img) {
- width: 24px;
- height: 24px;
- display: block;
- object-fit: contain;
-}
-
-.root :global(.brand h1) {
- font-size: 18px;
- font-weight: 800;
- letter-spacing: -0.03em;
- background: linear-gradient(
- 135deg,
- var(--accent-cyan) 0%,
- var(--accent-blue) 100%
- );
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- background-clip: text;
-}
-
-.root :global(.subtitle) {
- font-size: 11px;
- font-weight: 500;
- color: var(--text-muted);
- letter-spacing: 0.04em;
- opacity: 0.8;
-}
-
-.root :global(.header-nav) {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- flex: 1 1 auto;
- min-width: 0;
-}
-
-.root :global(.header-nav-link) {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- padding: 12px 18px;
- border-radius: 10px;
- color: rgba(203, 213, 225, 0.82);
- text-decoration: none;
- font-size: 14px;
- font-weight: 550;
- transition: var(--transition);
- position: relative;
-}
-
-.root :global(.header-nav-link:hover) {
- color: #f8fafc;
- background: rgba(30, 41, 59, 0.44);
-}
-
-.root :global(.header-nav-link.active) {
- color: #f8fafc;
- background: transparent;
- box-shadow: none;
-}
-
-.root :global(.header-nav-link.active::after) {
- content: "";
- position: absolute;
- left: 18px;
- right: 18px;
- bottom: 4px;
- height: 2px;
- border-radius: 999px;
- background: var(--accent-cyan);
-}
-
-.root :global(.header-right) {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-shrink: 0;
-}
-
-.root :global(.lang-switch) {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 3px;
- border-radius: 10px;
- border: 1px solid var(--border-glass);
- background: var(--bg-glass);
-}
-
-.root :global(.lang-btn) {
- border: none;
- background: transparent;
- color: var(--text-muted);
- font-size: 11px;
- font-weight: 600;
- line-height: 1;
- padding: 6px 8px;
- border-radius: 7px;
- cursor: pointer;
- transition: var(--transition);
-}
-
-.root :global(.lang-btn:hover) {
- color: var(--text-primary);
- background: rgba(99, 102, 241, 0.12);
-}
-
-.root :global(.lang-btn.active) {
- color: var(--text-primary);
- background: rgba(0, 224, 164, 0.12);
- box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.2);
-}
-
-.root :global(.live-badge) {
- display: flex;
- align-items: center;
- gap: 5px;
- padding: 4px 10px;
- border-radius: 9999px;
- background: rgba(0, 224, 164, 0.08);
- border: 1px solid rgba(0, 224, 164, 0.22);
- font-size: 10px;
- font-weight: 700;
- color: var(--accent-green);
- letter-spacing: 1.2px;
- text-transform: uppercase;
-}
-
-.root :global(.pulse-dot) {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--accent-green);
- animation: pulse 2s ease-in-out infinite;
-}
-
-@keyframes pulse {
- 0%,
- 100% {
- opacity: 1;
- box-shadow: 0 0 0 0 rgba(0, 224, 164, 0.4);
- }
- 50% {
- opacity: 0.7;
- box-shadow: 0 0 0 6px rgba(0, 224, 164, 0);
- }
-}
-
-.root :global(.refresh-btn) {
- width: 32px;
- height: 32px;
- border-radius: 8px;
- border: 1px solid var(--border-glass);
- background: rgba(13, 19, 33, 0.6);
- color: var(--text-secondary);
- cursor: pointer;
- transition: all 150ms ease;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-.root :global(.refresh-btn:hover) {
- background: rgba(0, 224, 164, 0.1);
- border-color: rgba(0, 224, 164, 0.3);
- color: var(--accent-cyan);
-}
-.root :global(.refresh-btn.spinning) {
- animation: spin 1s linear infinite;
-}
-
-.root :global(.locale-switch) {
- height: 32px;
- padding: 3px;
- border-radius: 10px;
- border: 1px solid rgba(0, 224, 164, 0.2);
- background: rgba(8, 15, 27, 0.82);
- color: rgba(148, 163, 184, 0.86);
- display: inline-flex;
- align-items: center;
- gap: 2px;
- font-size: 11px;
- font-weight: 750;
- cursor: pointer;
- transition: var(--transition);
-}
-
-.root :global(.locale-switch span) {
- min-width: 32px;
- height: 24px;
- border-radius: 7px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
-}
-
-.root :global(.locale-switch span.active) {
- color: #f8fafc;
- background: rgba(0, 224, 164, 0.16);
- box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.24);
-}
-
-.root :global(.locale-switch:hover) {
- border-color: rgba(0, 224, 164, 0.34);
- background: rgba(11, 22, 40, 0.94);
-}
-
-.root :global(.header-utility-btn) {
- min-width: 32px;
- height: 32px;
- padding: 0 12px;
- border-radius: 8px;
- border: 1px solid rgba(115, 137, 161, 0.18);
- background: rgba(8, 15, 27, 0.82);
- color: rgba(226, 232, 240, 0.8);
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- text-decoration: none;
- font-size: 12px;
- font-weight: 650;
- transition: var(--transition);
-}
-
-.root :global(.header-utility-btn:hover) {
- color: #f8fafc;
- border-color: rgba(0, 224, 164, 0.34);
- background: rgba(11, 22, 40, 0.94);
-}
-
-.root :global(.header-utility-btn.active) {
- color: #f8fafc;
- border-color: rgba(123, 97, 255, 0.34);
-}
-
-.root :global(.header-utility-btn.more) {
- padding: 0;
- width: 32px;
-}
-@keyframes spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-/* ── City List Sidebar ── */
-.root :global(.city-list) {
- position: fixed;
- top: calc(var(--header-height) + 12px);
- left: 12px;
- width: var(--sidebar-width);
- max-height: calc(100vh - var(--header-height) - 24px);
- z-index: 900;
- background: var(--bg-glass);
- backdrop-filter: blur(var(--glass-blur));
- border: 1px solid var(--border-glass);
- border-radius: 18px;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- box-shadow: var(--shadow-lg);
-}
-
-.root :global(.city-list-header) {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 14px 16px 10px;
- border-bottom: 1px solid var(--border-subtle);
- font-size: 13px;
- font-weight: 700;
- color: var(--text-primary);
- letter-spacing: -0.01em;
-}
-
-.root :global(.city-search) {
- display: flex;
- align-items: center;
- gap: 8px;
- margin: 0 14px 12px;
- padding: 10px 12px;
- border-radius: 12px;
- border: 1px solid rgba(115, 137, 161, 0.14);
- background: rgba(6, 12, 22, 0.82);
- color: rgba(148, 163, 184, 0.86);
-}
-
-.root :global(.city-search input) {
- width: 100%;
- border: none;
- outline: none;
- background: transparent;
- color: #f8fafc;
- font: inherit;
- font-size: 13px;
-}
-
-.root :global(.city-search input::placeholder) {
- color: rgba(148, 163, 184, 0.72);
-}
-
-.root :global(.city-count) {
- background: rgba(123, 97, 255, 0.2);
- color: var(--accent-blue);
- font-size: 11px;
- font-weight: 700;
- font-variant-numeric: tabular-nums;
- padding: 2px 8px;
- border-radius: 9999px;
- border: 1px solid rgba(123, 97, 255, 0.15);
-}
-
-.root :global(.city-list-items) {
- overflow-y: auto;
- flex: 1;
- padding: 0 6px 8px;
-}
-.root :global(.city-list-items::-webkit-scrollbar) {
- width: 4px;
-}
-.root :global(.city-list-items::-webkit-scrollbar-track) {
- background: transparent;
-}
-.root :global(.city-list-items::-webkit-scrollbar-thumb) {
- background: var(--border-glass);
- border-radius: 2px;
-}
-
-.root :global(.sidebar-footer) {
- border-top: 1px solid var(--border-subtle);
- padding: 10px 12px;
- color: rgba(148, 163, 184, 0.72);
- font-size: 10px;
- line-height: 1.55;
-}
-
-.root :global(.city-group) {
- border: 1px solid rgba(115, 137, 161, 0.12);
- border-radius: 14px;
- background: rgba(10, 17, 30, 0.48);
- margin-bottom: 8px;
- overflow: hidden;
-}
-
-.root :global(.city-group:last-child) {
- margin-bottom: 0;
-}
-
-.root :global(.city-group-header) {
- width: 100%;
- border: none;
- background: rgba(15, 28, 47, 0.56);
- color: var(--text-secondary);
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 7px 10px;
- cursor: pointer;
- font-family: inherit;
- transition: background 150ms ease;
-}
-
-.root :global(.city-group-header:hover) {
- background: rgba(18, 34, 55, 0.82);
-}
-
-.root :global(.city-group-title) {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.4px;
- text-transform: uppercase;
-}
-
-.root :global(.city-group-indicator) {
- width: 3px;
- height: 14px;
- border-radius: 2px;
- flex-shrink: 0;
-}
-.root :global(.city-group-indicator.high) {
- background: var(--risk-high);
- box-shadow: 0 0 6px rgba(239, 68, 68, 0.3);
-}
-.root :global(.city-group-indicator.medium) {
- background: var(--risk-medium);
- box-shadow: 0 0 6px rgba(245, 158, 11, 0.3);
-}
-.root :global(.city-group-indicator.low) {
- background: var(--risk-low);
- box-shadow: 0 0 6px rgba(34, 197, 94, 0.3);
-}
-.root :global(.city-group-indicator.other) {
- background: var(--text-muted);
- opacity: 0.5;
-}
-
-.root :global(.city-group-meta) {
- display: inline-flex;
- align-items: center;
- gap: 8px;
-}
-
-.root :global(.city-group-count) {
- min-width: 18px;
- height: 18px;
- border-radius: 9px;
- padding: 0 6px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- background: rgba(123, 97, 255, 0.26);
- color: var(--text-primary);
- font-size: 10px;
- font-weight: 700;
-}
-
-.root :global(.city-group-arrow) {
- font-size: 11px;
- color: var(--text-muted);
- transform: rotate(-90deg);
- transition: transform 0.2s ease;
-}
-
-.root :global(.city-group-arrow.expanded) {
- transform: rotate(0deg);
-}
-
-.root :global(.city-group-items) {
- padding: 4px;
-}
-
-.root :global(.city-group.collapsed .city-group-items) {
- display: none;
-}
-
-.root :global(.city-item) {
- width: 100%;
- display: flex;
- flex-direction: column;
- gap: 4px;
- padding: 8px 10px;
- border-radius: 10px;
- cursor: pointer;
- transition: var(--transition);
- border: 1px solid transparent;
- background: transparent;
- color: inherit;
- font-family: inherit;
- text-align: left;
-}
-.root :global(.city-item:hover) {
- background: rgba(10, 26, 44, 0.84);
- border-color: rgba(0, 224, 164, 0.18);
- box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.03);
-}
-.root :global(.city-item.active) {
- background:
- linear-gradient(135deg, rgba(0, 224, 164, 0.12), rgba(123, 97, 255, 0.1)),
- rgba(9, 18, 32, 0.92);
- border-color: rgba(0, 224, 164, 0.24);
- box-shadow:
- 0 0 20px rgba(0, 224, 164, 0.08),
- inset 0 0 0 1px rgba(0, 224, 164, 0.08);
-}
-
-.root :global(.city-item-main) {
- display: flex;
- align-items: center;
- gap: 8px;
- width: 100%;
-}
-
-.root :global(.city-item .city-name-text) {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-primary);
-}
-
-.root :global(.city-item-info) {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding-left: 20px; /* Align with name text, after the dot */
- font-size: 10px;
- color: var(--text-muted);
-}
-
-.root :global(.city-item .city-max-info) {
- color: var(--accent-blue);
- font-weight: 500;
-}
-
-.root :global(.city-item .city-deviation-info) {
- font-weight: 600;
-}
-
-.root :global(.city-item .city-deviation-cold) {
- color: #4DA3FF;
-}
-
-.root :global(.city-item .city-deviation-hot) {
- color: #f59e0b;
-}
-
-.root :global(.city-item .city-deviation-normal) {
- color: #22d3ee;
-}
-
-.root :global(.city-item .city-deviation-info.strong) {
- text-shadow: 0 0 10px rgba(56, 189, 248, 0.18);
-}
-
-.root :global(.city-item .city-deviation-hot.strong) {
- text-shadow: 0 0 10px rgba(245, 158, 11, 0.24);
-}
-
-.root :global(.city-item .risk-dot) {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- flex-shrink: 0;
-}
-.root :global(.city-item .risk-dot.high) {
- background: var(--risk-high);
- box-shadow: 0 0 6px var(--risk-high);
-}
-.root :global(.city-item .risk-dot.medium) {
- background: var(--risk-medium);
- box-shadow: 0 0 6px var(--risk-medium);
-}
-.root :global(.city-item .risk-dot.low) {
- background: var(--risk-low);
- box-shadow: 0 0 6px var(--risk-low);
-}
-
-.root :global(.city-clock-icon) {
- display: inline-block;
- vertical-align: -1px;
- margin-right: 3px;
- opacity: 0.6;
-}
-
-.root :global(.city-item .city-temp) {
- margin-left: auto;
- font-size: 13px;
- font-weight: 700;
- font-variant-numeric: tabular-nums;
- color: var(--accent-cyan);
- opacity: 0;
- transition: opacity 200ms ease;
-}
-.root :global(.city-item .city-temp.loaded) {
- opacity: 1;
-}
-
-/* ── Homepage Intelligence Layer ── */
-.root :global(.home-intelligence-panel) {
- position: fixed;
- top: calc(var(--header-height) + 16px);
- right: 18px;
- bottom: 20px;
- width: min(360px, calc(100vw - var(--sidebar-width) - 56px));
- z-index: 890;
- display: flex;
- flex-direction: column;
- gap: 16px;
- padding: 18px;
- overflow: hidden;
- border: 1px solid rgba(34, 211, 238, 0.18);
- border-radius: 24px;
- background:
- radial-gradient(
- circle at 18% 0%,
- rgba(34, 211, 238, 0.16),
- transparent 32%
- ),
- radial-gradient(
- circle at 100% 20%,
- rgba(59, 130, 246, 0.18),
- transparent 34%
- ),
- linear-gradient(155deg, rgba(5, 12, 25, 0.86), rgba(12, 19, 35, 0.78));
- backdrop-filter: blur(24px) saturate(130%);
- box-shadow:
- 0 28px 80px rgba(0, 0, 0, 0.42),
- inset 0 1px 0 rgba(255, 255, 255, 0.06);
- animation: home-panel-enter 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
-}
-
-.root :global(.home-panel-glow) {
- position: absolute;
- inset: -28% -30% auto auto;
- width: 260px;
- height: 260px;
- border-radius: 999px;
- background: radial-gradient(
- circle,
- rgba(34, 211, 238, 0.18),
- transparent 62%
- );
- pointer-events: none;
-}
-
-.root :global(.home-panel-kicker),
-.root :global(.home-panel-airport),
-.root :global(.home-signal-label),
-.root :global(.home-pro-card span),
-.root :global(.opportunity-strip-heading span) {
- color: rgba(148, 163, 184, 0.92);
- font-size: 10px;
- font-weight: 800;
- letter-spacing: 0.16em;
- text-transform: uppercase;
-}
-
-.root :global(.home-panel-kicker) {
- position: relative;
- display: inline-flex;
- align-items: center;
- gap: 8px;
-}
-
-.root :global(.home-panel-pulse) {
- width: 8px;
- height: 8px;
- border-radius: 999px;
- background: var(--accent-cyan);
- box-shadow: 0 0 18px rgba(34, 211, 238, 0.7);
- animation: pulse 2s ease-in-out infinite;
-}
-
-.root :global(.home-panel-city) {
- position: relative;
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 16px;
-}
-
-.root :global(.home-panel-city h2) {
- margin-top: 6px;
- color: #f8fafc;
- font-size: clamp(28px, 4vw, 42px);
- font-weight: 900;
- line-height: 0.92;
- letter-spacing: -0.07em;
-}
-
-.root :global(.home-risk-badge) {
- flex-shrink: 0;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- min-height: 26px;
- border-radius: 999px;
- padding: 5px 10px 5px 8px;
- border: 1px solid rgba(148, 163, 184, 0.2);
- background: rgba(9, 18, 36, 0.58);
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
- color: rgba(226, 232, 240, 0.88);
- font-size: 10px;
- font-weight: 900;
- letter-spacing: 0.01em;
- white-space: nowrap;
-}
-
-.root :global(.home-risk-badge::before) {
- content: "";
- width: 6px;
- height: 6px;
- border-radius: 999px;
- background: currentColor;
- opacity: 0.88;
- box-shadow: 0 0 8px color-mix(in srgb, currentColor 24%, transparent);
-}
-
-.root :global(.home-risk-badge.high) {
- border-color: rgba(248, 113, 113, 0.34);
- color: #fda4af;
- background: rgba(69, 10, 10, 0.32);
-}
-
-.root :global(.home-risk-badge.medium) {
- border-color: rgba(251, 191, 36, 0.3);
- color: #fcd34d;
- background: rgba(67, 36, 6, 0.28);
-}
-
-.root :global(.home-risk-badge.low) {
- border-color: rgba(52, 211, 153, 0.28);
- color: #86efac;
- background: rgba(7, 47, 35, 0.28);
-}
-
-.root :global(.home-panel-subtitle) {
- max-width: 310px;
- color: rgba(203, 213, 225, 0.78);
- font-size: 13px;
- line-height: 1.6;
-}
-
-.root :global(.home-metric-grid) {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 10px;
-}
-
-.root :global(.home-metric-card) {
- min-height: 86px;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- gap: 12px;
- padding: 13px;
- border: 1px solid rgba(148, 163, 184, 0.13);
- border-radius: 18px;
- background: rgba(2, 6, 23, 0.36);
-}
-
-.root :global(.home-metric-card.primary) {
- background:
- linear-gradient(135deg, rgba(34, 211, 238, 0.2), rgba(59, 130, 246, 0.08)),
- rgba(2, 6, 23, 0.42);
- border-color: rgba(34, 211, 238, 0.24);
-}
-
-.root :global(.home-metric-card span) {
- color: rgba(148, 163, 184, 0.86);
- font-size: 11px;
- font-weight: 700;
-}
-
-.root :global(.home-metric-card strong) {
- color: #f8fafc;
- font-size: 24px;
- font-weight: 900;
- letter-spacing: -0.06em;
-}
-
-.root :global(.home-signal-card) {
- display: grid;
- grid-template-columns: 48px 1fr;
- gap: 14px;
- padding: 14px;
- border: 1px solid rgba(34, 211, 238, 0.16);
- border-radius: 20px;
- background: rgba(8, 13, 27, 0.48);
-}
-
-.root :global(.home-signal-line) {
- display: flex;
- align-items: flex-end;
- justify-content: center;
- gap: 5px;
- height: 68px;
-}
-
-.root :global(.home-signal-line span) {
- width: 7px;
- border-radius: 999px;
- background: linear-gradient(180deg, #22d3ee, #3b82f6);
- opacity: 0.88;
- animation: home-signal-wave 1.8s ease-in-out infinite;
-}
-
-.root :global(.home-signal-line span:nth-child(1)) {
- height: 32px;
-}
-
-.root :global(.home-signal-line span:nth-child(2)) {
- height: 56px;
- animation-delay: 180ms;
-}
-
-.root :global(.home-signal-line span:nth-child(3)) {
- height: 42px;
- animation-delay: 360ms;
-}
-
-.root :global(.home-signal-card strong) {
- display: block;
- margin: 5px 0 4px;
- color: #e2e8f0;
- font-size: 15px;
-}
-
-.root :global(.home-signal-card p) {
- color: rgba(148, 163, 184, 0.9);
- font-size: 12px;
- line-height: 1.55;
-}
-
-.root :global(.home-pro-card) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 14px;
- padding: 14px;
- border: 1px solid rgba(251, 191, 36, 0.26);
- border-radius: 20px;
- background:
- linear-gradient(135deg, rgba(251, 191, 36, 0.1), transparent 60%),
- rgba(15, 23, 42, 0.52);
-}
-
-.root :global(.home-pro-card.active) {
- border-color: rgba(34, 211, 238, 0.28);
- background:
- linear-gradient(135deg, rgba(34, 211, 238, 0.12), transparent 60%),
- rgba(15, 23, 42, 0.52);
-}
-
-.root :global(.home-pro-card strong) {
- display: block;
- margin-top: 5px;
- color: #f8fafc;
- font-size: 12px;
- line-height: 1.45;
-}
-
-.root :global(.home-pro-card a),
-.root :global(.home-pro-card button) {
- flex-shrink: 0;
- border: 1px solid rgba(34, 211, 238, 0.32);
- border-radius: 999px;
- background: rgba(34, 211, 238, 0.1);
- color: #a5f3fc;
- cursor: pointer;
- font: inherit;
- font-size: 12px;
- font-weight: 800;
- padding: 8px 12px;
- text-decoration: none;
- transition:
- transform 160ms ease,
- border-color 160ms ease,
- background 160ms ease;
-}
-
-.root :global(.home-pro-card a:hover),
-.root :global(.home-pro-card button:hover) {
- transform: translateY(-1px);
- border-color: rgba(34, 211, 238, 0.62);
- background: rgba(34, 211, 238, 0.16);
-}
-
-.root :global(.home-intelligence-panel.full) {
- bottom: 18px;
- width: min(372px, calc(100vw - var(--sidebar-width) - 56px));
- gap: 14px;
- overflow-y: auto;
- border-color: rgba(34, 211, 238, 0.22);
- background:
- radial-gradient(
- circle at 100% 0%,
- rgba(37, 99, 235, 0.18),
- transparent 38%
- ),
- radial-gradient(
- circle at 0% 100%,
- rgba(34, 211, 238, 0.06),
- transparent 32%
- ),
- linear-gradient(165deg, rgba(3, 10, 22, 0.96), rgba(5, 18, 38, 0.94));
- box-shadow:
- inset 1px 0 0 rgba(255, 255, 255, 0.04),
- -10px 0 34px rgba(0, 0, 0, 0.18);
-}
-
-.root :global(.home-intelligence-panel.full::-webkit-scrollbar) {
- width: 4px;
-}
-
-.root :global(.home-intelligence-panel.full::-webkit-scrollbar-thumb) {
- background: rgba(34, 211, 238, 0.28);
- border-radius: 999px;
-}
-
-/* Close button removed — panel always visible */
-
-.root :global(.home-panel-header-left) {
- display: inline-flex;
- align-items: center;
- gap: 8px;
-}
-
-.root :global(.home-panel-live-indicator) {
- width: 7px;
- height: 7px;
- border-radius: 999px;
- background: #22c55e;
- box-shadow: 0 0 10px rgba(34, 197, 94, 0.6);
- animation: pulse 2s ease-in-out infinite;
-}
-
-.root :global(.home-panel-header) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- padding-bottom: 10px;
- border-bottom: 1px solid rgba(115, 137, 161, 0.12);
-}
-
-.root :global(.home-focus-title) {
- color: rgba(226, 232, 240, 0.92);
- font-size: 10px;
- font-weight: 800;
- letter-spacing: 0.14em;
- text-transform: uppercase;
-}
-
-.root :global(.home-why-link) {
- border: 1px solid rgba(96, 165, 250, 0.22);
- border-radius: 999px;
- padding: 4px 10px;
- background: rgba(96, 165, 250, 0.06);
- color: #93c5fd;
- cursor: pointer;
- font: inherit;
- font-size: 10px;
- font-weight: 700;
- transition: all 180ms ease;
-}
-
-.root :global(.home-why-link:hover) {
- border-color: rgba(96, 165, 250, 0.42);
- background: rgba(96, 165, 250, 0.12);
- color: #bfdbfe;
-}
-
-.root :global(.home-top-opportunity-label) {
- display: inline-flex;
- align-self: flex-start;
- align-items: center;
- gap: 7px;
- padding: 5px 8px;
- border: 1px solid rgba(34, 211, 238, 0.18);
- border-radius: 999px;
- background: rgba(8, 145, 178, 0.1);
- color: #6FB7FF;
- font-size: 10px;
- font-weight: 900;
- letter-spacing: 0.12em;
- text-transform: uppercase;
-}
-
-.root :global(.home-top-opportunity-label::before) {
- content: "";
- width: 6px;
- height: 6px;
- border-radius: 999px;
- background: #22c55e;
- box-shadow: 0 0 12px rgba(34, 197, 94, 0.72);
-}
-
-.root :global(.home-card-titlebar) {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 14px;
-}
-
-.root :global(.home-card-titlebar h2) {
- color: #f8fafc;
- font-size: 22px;
- font-weight: 900;
- line-height: 1;
- letter-spacing: -0.045em;
-}
-
-.root :global(.home-card-titlebar p),
-.root :global(.home-card-meta-row) {
- color: rgba(148, 163, 184, 0.88);
- font-size: 12px;
- font-weight: 650;
-}
-
-.root :global(.home-card-titlebar p) {
- margin-top: 7px;
-}
-
-.root :global(.home-card-meta-row) {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.root :global(.home-card-live-dot) {
- width: 6px;
- height: 6px;
- border-radius: 999px;
- background: #22c55e;
- box-shadow: 0 0 12px rgba(34, 197, 94, 0.72);
-}
-
-.root :global(.home-weather-hero) {
- display: grid;
- grid-template-columns: minmax(0, 1fr) 130px;
- gap: 14px;
- align-items: center;
- padding: 16px;
- border: 1px solid rgba(34, 211, 238, 0.14);
- border-radius: 18px;
- background:
- radial-gradient(
- circle at 88% 24%,
- rgba(245, 158, 11, 0.12),
- transparent 24%
- ),
- linear-gradient(180deg, rgba(8, 16, 30, 0.62), rgba(4, 10, 22, 0.36));
-}
-
-.root :global(.home-weather-main strong) {
- display: block;
- color: #f8fafc;
- font-size: 62px;
- font-weight: 900;
- line-height: 0.96;
- letter-spacing: -0.08em;
-}
-
-.root :global(.home-weather-label),
-.root :global(.home-weather-sub),
-.root :global(.home-weather-stat span),
-.root :global(.home-weather-stat small) {
- display: block;
- color: rgba(148, 163, 184, 0.88);
- font-size: 12px;
-}
-
-.root :global(.home-weather-label) {
- margin-bottom: 8px;
- color: #6FB7FF;
- font-size: 10px;
- font-weight: 900;
- letter-spacing: 0.14em;
- text-transform: uppercase;
-}
-
-.root :global(.home-weather-sub) {
- margin-top: 10px;
-}
-
-.root :global(.home-weather-side) {
- display: grid;
- justify-items: end;
- gap: 10px;
-}
-
-.root :global(.home-weather-icon) {
- position: relative;
- width: 88px;
- height: 76px;
-}
-
-.root :global(.home-weather-stat) {
- min-width: 118px;
- padding: 9px 10px;
- border: 1px solid rgba(148, 163, 184, 0.12);
- border-radius: 12px;
- background: rgba(2, 8, 18, 0.35);
- text-align: right;
-}
-
-.root :global(.home-weather-stat strong) {
- display: block;
- margin-top: 3px;
- color: #f8fafc;
- font-size: 18px;
- font-weight: 900;
-}
-
-.root :global(.home-weather-stat small) {
- margin-top: 2px;
- font-size: 10px;
-}
-
-.root :global(.home-weather-icon .sun),
-.root :global(.home-weather-icon .mist),
-.root :global(.home-weather-icon .wind),
-.root :global(.home-weather-icon .bolt) {
- position: absolute;
-}
-
-.root :global(.home-weather-icon .sun) {
- top: 8px;
- left: 6px;
- width: 26px;
- height: 26px;
- border-radius: 999px;
- background: radial-gradient(circle, #fde68a 0%, #f59e0b 70%, #f97316 100%);
- box-shadow:
- 0 0 0 7px rgba(251, 191, 36, 0.12),
- 0 0 22px rgba(245, 158, 11, 0.26);
-}
-
-.root :global(.home-weather-icon .cloud) {
- position: absolute;
- border-radius: 999px;
- background: linear-gradient(180deg, #b8c4d2, #6B7A90);
- filter: drop-shadow(0 10px 18px rgba(0, 0, 0, 0.28));
-}
-
-.root :global(.home-weather-icon .cloud-a) {
- left: 10px;
- top: 24px;
- width: 70px;
- height: 30px;
-}
-
-.root :global(.home-weather-icon .cloud-b) {
- left: 31px;
- top: 10px;
- width: 37px;
- height: 37px;
-}
-
-.root :global(.home-weather-icon .rain) {
- position: absolute;
- top: 58px;
- width: 4px;
- height: 13px;
- border-radius: 999px;
- background: #4DA3FF;
- transform: rotate(18deg);
-}
-
-.root :global(.home-weather-icon .rain-a) {
- left: 29px;
-}
-
-.root :global(.home-weather-icon .rain-b) {
- left: 48px;
-}
-
-.root :global(.home-weather-icon .rain-c) {
- left: 67px;
-}
-
-.root :global(.home-weather-icon .mist) {
- left: 20px;
- width: 46px;
- height: 2px;
- border-radius: 999px;
- background: rgba(191, 219, 254, 0.85);
- box-shadow: 0 0 10px rgba(148, 163, 184, 0.18);
-}
-
-.root :global(.home-weather-icon .mist-a) {
- top: 58px;
-}
-
-.root :global(.home-weather-icon .mist-b) {
- top: 64px;
- left: 28px;
- width: 38px;
-}
-
-.root :global(.home-weather-icon .wind) {
- left: 20px;
- width: 44px;
- height: 2px;
- border-radius: 999px;
- background: linear-gradient(
- 90deg,
- rgba(125, 211, 252, 0),
- rgba(125, 211, 252, 0.92)
- );
-}
-
-.root :global(.home-weather-icon .wind::after) {
- content: "";
- position: absolute;
- right: -3px;
- top: -1px;
- width: 9px;
- height: 4px;
- border-top: 2px solid rgba(125, 211, 252, 0.92);
- border-right: 2px solid rgba(125, 211, 252, 0.92);
- border-radius: 0 8px 0 0;
-}
-
-.root :global(.home-weather-icon .wind-a) {
- top: 28px;
-}
-
-.root :global(.home-weather-icon .wind-b) {
- top: 42px;
- left: 30px;
- width: 34px;
-}
-
-.root :global(.home-weather-icon .bolt) {
- top: 44px;
- left: 20px;
- width: 0;
- height: 0;
- border-left: 6px solid transparent;
- border-right: 2px solid transparent;
- border-top: 18px solid #fbbf24;
- transform: skewX(-16deg);
- filter: drop-shadow(0 0 12px rgba(251, 191, 36, 0.32));
-}
-
-.root :global(.home-weather-icon .bolt::after) {
- content: "";
- position: absolute;
- left: -5px;
- top: -4px;
- width: 0;
- height: 0;
- border-left: 5px solid transparent;
- border-right: 2px solid transparent;
- border-top: 12px solid #fde047;
- transform: translateX(5px) translateY(8px);
-}
-
-.root :global(.home-weather-icon.weather-clear .cloud),
-.root :global(.home-weather-icon.weather-clear .rain),
-.root :global(.home-weather-icon.weather-clear .mist),
-.root :global(.home-weather-icon.weather-clear .wind),
-.root :global(.home-weather-icon.weather-clear .bolt) {
- display: none;
-}
-
-.root :global(.home-weather-icon.weather-partly .rain),
-.root :global(.home-weather-icon.weather-partly .mist),
-.root :global(.home-weather-icon.weather-partly .wind),
-.root :global(.home-weather-icon.weather-partly .bolt) {
- display: none;
-}
-
-.root :global(.home-weather-icon.weather-cloudy .sun),
-.root :global(.home-weather-icon.weather-cloudy .rain),
-.root :global(.home-weather-icon.weather-cloudy .mist),
-.root :global(.home-weather-icon.weather-cloudy .wind),
-.root :global(.home-weather-icon.weather-cloudy .bolt) {
- display: none;
-}
-
-.root :global(.home-weather-icon.weather-rain .sun),
-.root :global(.home-weather-icon.weather-rain .mist),
-.root :global(.home-weather-icon.weather-rain .wind),
-.root :global(.home-weather-icon.weather-rain .bolt) {
- display: none;
-}
-
-.root :global(.home-weather-icon.weather-storm .sun),
-.root :global(.home-weather-icon.weather-storm .mist),
-.root :global(.home-weather-icon.weather-storm .wind) {
- display: none;
-}
-
-.root :global(.home-weather-icon.weather-mist .sun),
-.root :global(.home-weather-icon.weather-mist .rain),
-.root :global(.home-weather-icon.weather-mist .wind),
-.root :global(.home-weather-icon.weather-mist .bolt) {
- display: none;
-}
-
-.root :global(.home-weather-icon.weather-wind .sun),
-.root :global(.home-weather-icon.weather-wind .rain),
-.root :global(.home-weather-icon.weather-wind .mist),
-.root :global(.home-weather-icon.weather-wind .bolt) {
- display: none;
-}
-
-.root :global(.home-max-so-far) {
- grid-column: 2;
- color: rgba(203, 213, 225, 0.86);
- font-size: 12px;
- text-align: right;
-}
-
-.root :global(.home-max-so-far strong) {
- display: block;
- margin-top: 4px;
- font-size: 16px;
- letter-spacing: -0.03em;
-}
-
-.root :global(.home-max-so-far small) {
- color: rgba(148, 163, 184, 0.9);
- font-size: 11px;
-}
-
-.root :global(.home-deb-card),
-.root :global(.home-card-section) {
- border-radius: 14px;
- border: 1px solid rgba(115, 137, 161, 0.12);
- background: linear-gradient(
- 180deg,
- rgba(8, 16, 30, 0.88),
- rgba(6, 12, 24, 0.74)
- );
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, 0.04),
- 0 2px 8px rgba(0, 0, 0, 0.18);
- transition: border-color 200ms ease;
-}
-
-.root :global(.home-deb-card:hover),
-.root :global(.home-card-section:hover) {
- border-color: rgba(34, 211, 238, 0.18);
-}
-
-.root :global(.home-deb-card) {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- gap: 10px;
- padding: 12px 14px;
-}
-
-.root :global(.home-deb-card span),
-.root :global(.home-card-section h3) {
- color: #6FB7FF;
- font-size: 12px;
- font-weight: 850;
-}
-
-.root :global(.home-deb-card small),
-.root :global(.home-card-section h3 small) {
- color: rgba(148, 163, 184, 0.82);
- font-weight: 700;
-}
-
-.root :global(.home-deb-card strong) {
- display: inline-block;
- margin-top: 9px;
- color: #f8fafc;
- font-size: 26px;
- font-weight: 900;
- letter-spacing: -0.055em;
-}
-
-.root :global(.home-deb-card em) {
- margin-left: 10px;
- color: #f87171;
- font-size: 12px;
- font-style: normal;
- font-weight: 850;
-}
-
-.root :global(.home-market-metrics svg) {
- width: 100%;
- height: 42px;
-}
-
-.root :global(.home-market-metrics polyline) {
- fill: none;
- stroke: #18e6d4;
- stroke-linecap: round;
- stroke-linejoin: round;
- stroke-width: 2.2;
-}
-
-.root :global(.home-card-section) {
- padding: 12px 14px;
-}
-
-.root :global(.home-card-section h3) {
- margin-bottom: 10px;
- color: rgba(226, 232, 240, 0.94);
-}
-
-.root :global(.home-card-section.intraday) {
- border-color: rgba(34, 211, 238, 0.2);
- background:
- radial-gradient(
- circle at 0% 35%,
- rgba(34, 211, 238, 0.12),
- transparent 44%
- ),
- linear-gradient(180deg, rgba(7, 14, 26, 0.94), rgba(9, 17, 30, 0.82));
-}
-
-.root :global(.home-intraday-chart) {
- position: relative;
- padding: 6px 0 18px 34px;
-}
-
-.root :global(.home-intraday-chart svg) {
- width: calc(100% - 34px);
- margin-left: 34px;
- height: 78px;
-}
-
-.root :global(.home-intraday-y-axis) {
- position: absolute;
- inset: 0 auto 18px 0;
- width: 30px;
- pointer-events: none;
-}
-
-.root :global(.home-intraday-y-label) {
- position: absolute;
- left: 0;
- transform: translateY(-50%);
- color: rgba(148, 163, 184, 0.72);
- font-size: 10px;
- font-weight: 700;
-}
-
-.root :global(.home-intraday-x-axis) {
- position: absolute;
- left: 34px;
- right: 10px;
- bottom: 0;
- height: 14px;
- pointer-events: none;
-}
-
-.root :global(.home-intraday-x-label) {
- position: absolute;
- bottom: 0;
- transform: translateX(-50%);
- color: rgba(148, 163, 184, 0.72);
- font-size: 10px;
- font-weight: 700;
- white-space: nowrap;
-}
-
-.root :global(.home-intraday-chart line) {
- stroke: rgba(115, 137, 161, 0.14);
- stroke-width: 0.9;
-}
-
-.root :global(.home-intraday-chart polyline) {
- fill: none;
- stroke: #22d3ee;
- stroke-width: 1.8;
- stroke-linecap: round;
- stroke-linejoin: round;
- filter: drop-shadow(0 0 8px rgba(34, 211, 238, 0.22));
-}
-
-.root :global(.home-intraday-chart circle) {
- 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));
-}
-
-.root :global(.home-intraday-reports) {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 7px;
- margin-top: 8px;
-}
-
-.root :global(.home-intraday-reports span) {
- min-width: 0;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 6px;
- padding: 7px 8px;
- border: 1px solid rgba(34, 211, 238, 0.12);
- border-radius: 10px;
- background: rgba(2, 8, 18, 0.55);
-}
-
-.root :global(.home-intraday-reports b) {
- color: rgba(148, 163, 184, 0.9);
- font-size: 10px;
- font-weight: 800;
-}
-
-.root :global(.home-intraday-reports strong) {
- color: #e0f2fe;
- font-size: 11px;
- font-weight: 900;
-}
-
-.root :global(.home-card-section.forecast) {
- border-color: rgba(99, 102, 241, 0.24);
-}
-
-.root :global(.home-forecast-grid) {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 8px;
-}
-
-.root :global(.home-forecast-item) {
- min-width: 0;
- padding: 10px 8px;
- border: 1px solid rgba(148, 163, 184, 0.14);
- border-radius: 12px;
- background: rgba(15, 23, 42, 0.5);
- text-align: center;
-}
-
-.root :global(.home-forecast-item span) {
- display: block;
- color: rgba(148, 163, 184, 0.84);
- font-size: 10px;
- font-weight: 800;
-}
-
-.root :global(.home-forecast-item strong) {
- display: block;
- margin-top: 6px;
- color: #f8fafc;
- font-size: 16px;
- font-weight: 900;
- letter-spacing: -0.04em;
-}
-
-.root :global(.home-model-stack) {
- display: flex;
- flex-wrap: wrap;
- gap: 7px;
-}
-
-.root :global(.home-model-stack span) {
- display: inline-flex;
- align-items: center;
- gap: 5px;
- padding: 6px 8px;
- border: 1px solid rgba(148, 163, 184, 0.18);
- border-radius: 6px;
- background: rgba(15, 23, 42, 0.72);
- color: rgba(226, 232, 240, 0.92);
- font-size: 11px;
- font-weight: 800;
-}
-
-.root :global(.home-model-stack i),
-.root :global(.key-signals i.active) {
- width: 6px;
- height: 6px;
- border-radius: 999px;
- background: #22c55e;
- box-shadow: 0 0 10px rgba(34, 197, 94, 0.62);
-}
-
-.root :global(.home-card-section.probability) {
- border-color: rgba(251, 113, 133, 0.18);
- background:
- radial-gradient(
- circle at 100% 0%,
- rgba(251, 113, 133, 0.08),
- transparent 42%
- ),
- linear-gradient(180deg, rgba(7, 14, 26, 0.94), rgba(9, 17, 30, 0.82));
-}
-
-.root :global(.home-probability-list),
-.root :global(.home-probability-ladder) {
- display: grid;
- gap: 8px;
-}
-
-.root :global(.home-probability-row),
-.root :global(.home-probability-ladder-row) {
- display: grid;
- grid-template-columns: 58px minmax(0, 1fr) 42px;
- align-items: center;
- gap: 9px;
- color: rgba(226, 232, 240, 0.95);
- font-size: 12px;
- font-weight: 850;
-}
-
-.root :global(.home-probability-row div),
-.root :global(.home-probability-track) {
- position: relative;
- height: 14px;
- overflow: hidden;
- border-radius: 999px;
- background: rgba(115, 137, 161, 0.12);
-}
-
-.root :global(.home-probability-row i),
-.root :global(.home-probability-track i) {
- position: absolute;
- inset: 0 auto 0 0;
- border-radius: inherit;
- background: linear-gradient(90deg, #3b82f6, #18e6d4);
- box-shadow: 0 0 10px rgba(34, 211, 238, 0.14);
-}
-
-.root :global(.home-probability-row strong),
-.root :global(.home-probability-ladder-row strong) {
- position: relative;
- z-index: 1;
- display: block;
- color: #f8fafc;
- font-size: 12px;
- line-height: 1;
- text-align: right;
-}
-
-.root :global(.home-probability-threshold) {
- color: rgba(226, 232, 240, 0.95);
- font-size: 12px;
- font-weight: 900;
-}
-
-.root :global(.home-card-empty) {
- color: rgba(148, 163, 184, 0.9);
- font-size: 12px;
- line-height: 1.5;
-}
-
-.root :global(.home-card-section.market) {
- position: relative;
- border-color: rgba(34, 211, 238, 0.16);
-}
-
-.root :global(.home-card-section.market.locked) {
- border-color: rgba(251, 191, 36, 0.28);
-}
-
-.root :global(.home-market-header),
-.root :global(.home-market-ticket),
-.root :global(.home-market-prices),
-.root :global(.home-market-metrics) {
- display: flex;
- align-items: center;
-}
-
-.root :global(.home-market-header),
-.root :global(.home-market-ticket) {
- justify-content: space-between;
- gap: 10px;
-}
-
-.root :global(.home-market-header span) {
- color: rgba(148, 163, 184, 0.88);
- font-size: 11px;
- font-weight: 750;
-}
-
-.root :global(.home-market-ticket) {
- margin-top: 8px;
-}
-
-.root :global(.home-market-question strong) {
- display: block;
- color: #f8fafc;
- font-size: 15px;
-}
-
-.root :global(.home-market-question span) {
- display: block;
- margin-top: 3px;
- color: rgba(148, 163, 184, 0.8);
- font-size: 11px;
-}
-
-.root :global(.home-market-prices) {
- gap: 6px;
-}
-
-.root :global(.home-market-prices span) {
- border-radius: 7px;
- padding: 7px 9px;
- font-size: 12px;
- font-weight: 900;
-}
-
-.root :global(.home-market-prices .yes) {
- color: #6FB7FF;
- background: rgba(8, 145, 178, 0.2);
-}
-
-.root :global(.home-market-prices .no) {
- color: #fca5a5;
- background: rgba(127, 29, 29, 0.24);
-}
-
-.root :global(.home-market-metrics) {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr)) 94px;
- gap: 8px;
- margin-top: 10px;
-}
-
-.root :global(.home-market-metrics span) {
- color: rgba(148, 163, 184, 0.84);
- font-size: 11px;
-}
-
-.root :global(.home-market-metrics strong) {
- display: block;
- margin-top: 3px;
- color: #22c55e;
- font-size: 13px;
-}
-
-.root :global(.home-market-lock) {
- display: inline-flex;
- margin-top: 10px;
- color: #fde68a;
- font-size: 12px;
- font-weight: 850;
- text-decoration: none;
-}
-
-.root :global(.key-signals) {
- border-color: transparent;
- background: transparent;
- padding: 0 4px;
-}
-
-.root :global(.key-signals ul) {
- display: grid;
- gap: 7px;
- list-style: none;
-}
-
-.root :global(.key-signals li) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- color: rgba(203, 213, 225, 0.88);
- font-size: 12px;
-}
-
-.root :global(.key-signals i) {
- width: 8px;
- height: 8px;
- border-radius: 999px;
- background: rgba(148, 163, 184, 0.42);
-}
-
-.root :global(.key-signals i.amber.active) {
- background: #f59e0b;
- box-shadow: 0 0 10px rgba(245, 158, 11, 0.62);
-}
-
-.root :global(.key-signals p) {
- margin-top: 9px;
- color: rgba(100, 116, 139, 0.9);
- font-size: 11px;
-}
-
-.root :global(.home-intelligence-panel.full .home-pro-card) {
- margin-top: 0;
-}
-
-.root :global(.home-opportunity-strip) {
- position: fixed;
- left: calc(var(--sidebar-width) + 22px);
- right: 392px;
- bottom: 18px;
- z-index: 880;
- display: flex;
- flex-direction: column;
- gap: 16px;
- padding: 16px;
- border: 1px solid rgba(148, 163, 184, 0.14);
- border-radius: 24px;
- background:
- radial-gradient(
- circle at 82% -10%,
- rgba(59, 130, 246, 0.14),
- transparent 34%
- ),
- radial-gradient(
- circle at 8% 50%,
- rgba(34, 211, 238, 0.12),
- transparent 32%
- ),
- rgba(7, 13, 25, 0.76);
- backdrop-filter: blur(20px) saturate(130%);
- box-shadow: 0 24px 70px rgba(0, 0, 0, 0.34);
- animation: opportunity-strip-enter 580ms cubic-bezier(0.16, 1, 0.3, 1) 120ms
- both;
-}
-
-.root :global(.opportunity-strip-topline) {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 18px;
-}
-
-.root :global(.opportunity-strip-copy) {
- display: flex;
- flex-direction: column;
- gap: 7px;
- min-width: 0;
- flex: 1;
-}
-
-.root :global(.opportunity-strip-kicker) {
- color: rgba(148, 163, 184, 0.8);
- font-size: 11px;
- font-weight: 800;
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}
-
-.root :global(.opportunity-strip-copy strong) {
- color: #f8fafc;
- font-size: 19px;
- font-weight: 860;
- line-height: 1.25;
- letter-spacing: -0.02em;
-}
-
-.root :global(.opportunity-strip-copy p) {
- max-width: 620px;
- color: rgba(191, 219, 254, 0.74);
- font-size: 12px;
- line-height: 1.6;
-}
-
-.root :global(.opportunity-strip-tape) {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
- justify-content: flex-end;
- max-width: 460px;
-}
-
-.root :global(.opportunity-tape-pill) {
- display: flex;
- min-width: 92px;
- flex-direction: column;
- gap: 3px;
- padding: 10px 12px;
- border-radius: 14px;
- border: 1px solid rgba(148, 163, 184, 0.16);
- background: rgba(9, 16, 30, 0.7);
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
-}
-
-.root :global(.opportunity-tape-pill span) {
- color: rgba(148, 163, 184, 0.82);
- font-size: 10px;
- font-weight: 760;
- letter-spacing: 0.04em;
- text-transform: uppercase;
-}
-
-.root :global(.opportunity-tape-pill strong) {
- color: #f8fafc;
- font-size: 16px;
- font-weight: 860;
- line-height: 1;
-}
-
-.root :global(.opportunity-tape-pill.accent-cyan strong) {
- color: #6FB7FF;
-}
-
-.root :global(.opportunity-tape-pill.accent-green strong) {
- color: #4ade80;
-}
-
-.root :global(.opportunity-tape-pill.accent-slate strong) {
- color: rgba(226, 232, 240, 0.78);
-}
-
-.root :global(.opportunity-tape-pill.accent-outline) {
- min-width: auto;
- justify-content: center;
- padding: 9px 11px;
- background: rgba(6, 11, 23, 0.62);
-}
-
-.root :global(.opportunity-tape-pill.accent-outline span) {
- color: rgba(191, 219, 254, 0.92);
- font-size: 11px;
- letter-spacing: 0;
- text-transform: none;
-}
-
-.root :global(.opportunity-strip-main) {
- display: grid;
- grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.92fr);
- gap: 14px;
-}
-
-.root :global(.opportunity-hero-card) {
- position: relative;
- overflow: hidden;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 16px;
- min-height: 244px;
- padding: 18px;
- border: 1px solid rgba(56, 189, 248, 0.18);
- border-radius: 22px;
- background:
- radial-gradient(
- circle at 100% 0%,
- rgba(14, 165, 233, 0.22),
- transparent 28%
- ),
- radial-gradient(
- circle at 12% 88%,
- rgba(16, 185, 129, 0.1),
- transparent 28%
- ),
- linear-gradient(135deg, rgba(7, 14, 28, 0.96), rgba(12, 27, 48, 0.92));
- color: inherit;
- cursor: pointer;
- font: inherit;
- text-align: left;
- box-shadow:
- 0 18px 42px rgba(2, 6, 23, 0.28),
- inset 0 1px 0 rgba(255, 255, 255, 0.04);
- transition:
- transform 160ms ease,
- border-color 160ms ease,
- box-shadow 160ms ease;
-}
-
-.root :global(.opportunity-hero-card:hover) {
- transform: translateY(-2px);
- border-color: rgba(34, 211, 238, 0.36);
- box-shadow:
- 0 24px 52px rgba(2, 6, 23, 0.36),
- 0 0 0 1px rgba(34, 211, 238, 0.08);
-}
-
-.root :global(.opportunity-hero-header) {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 18px;
-}
-
-.root :global(.opportunity-hero-copy) {
- display: flex;
- min-width: 0;
- flex: 1;
- flex-direction: column;
- gap: 6px;
-}
-
-.root :global(.opportunity-hero-kicker) {
- color: #6FB7FF;
- font-size: 11px;
- font-weight: 800;
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}
-
-.root :global(.opportunity-hero-title-row) {
- display: flex;
- align-items: baseline;
- gap: 10px;
- min-width: 0;
-}
-
-.root :global(.opportunity-hero-title-row strong) {
- overflow: hidden;
- color: #f8fafc;
- font-size: 28px;
- font-weight: 880;
- line-height: 1;
- letter-spacing: -0.04em;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.root :global(.opportunity-hero-date) {
- color: rgba(186, 230, 253, 0.76);
- font-size: 12px;
- font-weight: 720;
-}
-
-.root :global(.opportunity-hero-copy p) {
- max-width: 520px;
- color: rgba(226, 232, 240, 0.82);
- font-size: 13px;
- line-height: 1.55;
-}
-
-.root :global(.opportunity-hero-tags) {
- display: flex;
- flex-wrap: wrap;
- justify-content: flex-end;
- gap: 8px;
-}
-
-.root :global(.opportunity-hero-tag) {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-height: 30px;
- padding: 0 11px;
- border-radius: 999px;
- border: 1px solid rgba(148, 163, 184, 0.16);
- background: rgba(7, 13, 25, 0.58);
- color: rgba(226, 232, 240, 0.92);
- font-size: 11px;
- font-weight: 820;
- white-space: nowrap;
-}
-
-.root :global(.opportunity-hero-tag.engine) {
- border-color: rgba(34, 211, 238, 0.24);
- color: #6FB7FF;
-}
-
-.root :global(.opportunity-hero-tag.signal-yes) {
- border-color: rgba(74, 222, 128, 0.26);
- color: #86efac;
-}
-
-.root :global(.opportunity-hero-tag.signal-no) {
- border-color: rgba(251, 113, 133, 0.24);
- color: #fda4af;
-}
-
-.root :global(.opportunity-hero-tag.signal-neutral) {
- color: rgba(191, 219, 254, 0.88);
-}
-
-.root :global(.opportunity-hero-tag.risk.high) {
- border-color: rgba(251, 113, 133, 0.24);
- color: #fda4af;
-}
-
-.root :global(.opportunity-hero-tag.risk.medium) {
- border-color: rgba(251, 191, 36, 0.24);
- color: #fcd34d;
-}
-
-.root :global(.opportunity-hero-tag.risk.low) {
- border-color: rgba(74, 222, 128, 0.24);
- color: #86efac;
-}
-
-.root :global(.opportunity-hero-body) {
- display: grid;
- gap: 14px;
-}
-
-.root :global(.opportunity-hero-edgeblock) {
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding: 14px 16px;
- border-radius: 18px;
- border: 1px solid rgba(34, 211, 238, 0.18);
- background: linear-gradient(
- 135deg,
- rgba(8, 15, 28, 0.84),
- rgba(13, 30, 52, 0.82)
- );
-}
-
-.root :global(.opportunity-hero-edgeblock span) {
- color: rgba(148, 163, 184, 0.8);
- font-size: 11px;
- font-weight: 760;
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}
-
-.root :global(.opportunity-hero-edgeblock strong) {
- color: #4ade80;
- font-size: 44px;
- font-weight: 900;
- line-height: 0.95;
- letter-spacing: -0.05em;
-}
-
-.root :global(.opportunity-hero-edgeblock em) {
- color: rgba(191, 219, 254, 0.84);
- font-size: 12px;
- font-style: normal;
- line-height: 1.5;
-}
-
-.root :global(.opportunity-hero-metrics) {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 10px;
-}
-
-.root :global(.opportunity-hero-metric) {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: 5px;
- padding: 12px 13px;
- border-radius: 14px;
- border: 1px solid rgba(148, 163, 184, 0.14);
- background: rgba(9, 16, 30, 0.6);
-}
-
-.root :global(.opportunity-hero-metric span) {
- color: rgba(148, 163, 184, 0.8);
- font-size: 10px;
- font-weight: 760;
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}
-
-.root :global(.opportunity-hero-metric strong) {
- overflow: hidden;
- color: #f8fafc;
- font-size: 20px;
- font-weight: 860;
- line-height: 1;
- letter-spacing: -0.03em;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.root :global(.opportunity-hero-book) {
- display: grid;
- grid-template-columns: repeat(2, minmax(110px, 132px)) minmax(0, 1fr);
- gap: 12px;
- align-items: stretch;
-}
-
-.root :global(.opportunity-book-side) {
- display: flex;
- flex-direction: column;
- justify-content: center;
- gap: 6px;
- padding: 12px 14px;
- border-radius: 16px;
- border: 1px solid rgba(148, 163, 184, 0.16);
- background: rgba(6, 11, 23, 0.62);
-}
-
-.root :global(.opportunity-book-side span) {
- font-size: 11px;
- font-weight: 760;
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}
-
-.root :global(.opportunity-book-side strong) {
- font-size: 26px;
- font-weight: 880;
- line-height: 1;
- letter-spacing: -0.03em;
-}
-
-.root :global(.opportunity-book-side.yes) {
- border-color: rgba(34, 211, 238, 0.22);
-}
-
-.root :global(.opportunity-book-side.yes span),
-.root :global(.opportunity-book-side.yes strong) {
- color: #22d3ee;
-}
-
-.root :global(.opportunity-book-side.no) {
- border-color: rgba(251, 113, 133, 0.22);
-}
-
-.root :global(.opportunity-book-side.no span),
-.root :global(.opportunity-book-side.no strong) {
- color: #fda4af;
-}
-
-.root :global(.opportunity-hero-sparkline-wrap) {
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 12px 14px;
- border-radius: 16px;
- border: 1px solid rgba(148, 163, 184, 0.14);
- background:
- radial-gradient(
- circle at 100% 0%,
- rgba(59, 130, 246, 0.14),
- transparent 32%
- ),
- rgba(8, 15, 28, 0.66);
-}
-
-.root :global(.opportunity-hero-sparkline-wrap span) {
- color: rgba(148, 163, 184, 0.8);
- font-size: 10px;
- font-weight: 760;
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}
-
-.root :global(.opportunity-hero-sparkline) {
- width: 100%;
- height: 44px;
-}
-
-.root :global(.opportunity-hero-sparkline polyline) {
- fill: none;
- stroke: #18e6d4;
- stroke-width: 2.4;
- stroke-linecap: round;
- stroke-linejoin: round;
- filter: drop-shadow(0 0 10px rgba(34, 211, 238, 0.26));
-}
-
-.root :global(.opportunity-hero-footer) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- color: rgba(191, 219, 254, 0.82);
- font-size: 11px;
- font-weight: 700;
-}
-
-.root :global(.opportunity-side-grid) {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 12px;
-}
-
-.root :global(.opportunity-side-tile) {
- display: flex;
- min-height: 116px;
- flex-direction: column;
- justify-content: space-between;
- gap: 12px;
- padding: 14px;
- border-radius: 18px;
- border: 1px solid rgba(148, 163, 184, 0.14);
- background:
- radial-gradient(
- circle at 100% 0%,
- rgba(30, 64, 175, 0.12),
- transparent 30%
- ),
- rgba(9, 16, 30, 0.68);
-}
-
-.root :global(.opportunity-side-tile-head span) {
- color: rgba(241, 245, 249, 0.92);
- font-size: 13px;
- font-weight: 790;
-}
-
-.root :global(.opportunity-side-tile-body) {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 8px;
-}
-
-.root :global(.opportunity-side-stat) {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: 4px;
-}
-
-.root :global(.opportunity-side-stat b) {
- overflow: hidden;
- color: #f8fafc;
- font-size: 24px;
- font-weight: 880;
- line-height: 1;
- letter-spacing: -0.04em;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.root :global(.opportunity-side-stat span) {
- color: rgba(148, 163, 184, 0.82);
- font-size: 10px;
- font-weight: 680;
-}
-
-.root :global(.opportunity-side-stat b.accent-red) {
- color: #fb7185;
-}
-
-.root :global(.opportunity-side-stat b.accent-amber) {
- color: #fbbf24;
-}
-
-.root :global(.opportunity-side-stat b.accent-green) {
- color: #4ade80;
-}
-
-.root :global(.opportunity-side-stat b.accent-cyan) {
- color: #22d3ee;
-}
-
-.root :global(.opportunity-mini-grid) {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 12px;
-}
-
-.root :global(.opportunity-empty-state) {
- display: flex;
- min-height: 168px;
- align-items: center;
- justify-content: center;
- padding: 0 18px;
- border: 1px dashed rgba(34, 211, 238, 0.2);
- border-radius: 16px;
- background:
- radial-gradient(
- circle at 50% 0%,
- rgba(34, 211, 238, 0.08),
- transparent 44%
- ),
- rgba(7, 12, 22, 0.72);
-}
-
-.root :global(.opportunity-empty-copy) {
- display: grid;
- gap: 6px;
- justify-items: center;
- text-align: center;
-}
-
-.root :global(.opportunity-empty-copy strong) {
- color: rgba(241, 245, 249, 0.94);
- font-size: 15px;
- font-weight: 840;
-}
-
-.root :global(.opportunity-empty-copy span) {
- color: rgba(148, 163, 184, 0.88);
- font-size: 12px;
- line-height: 1.6;
-}
-
-.root :global(.opportunity-mini-card) {
- position: relative;
- overflow: hidden;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 12px;
- min-height: 152px;
- padding: 14px;
- border: 1px solid rgba(148, 163, 184, 0.14);
- border-radius: 18px;
- background:
- radial-gradient(
- circle at 100% 0%,
- rgba(30, 64, 175, 0.12),
- transparent 32%
- ),
- rgba(10, 16, 28, 0.84);
- color: inherit;
- cursor: pointer;
- font: inherit;
- text-align: left;
- transition:
- transform 160ms ease,
- border-color 160ms ease,
- background 160ms ease;
-}
-
-.root :global(.opportunity-mini-card:hover) {
- transform: translateY(-2px);
- border-color: rgba(34, 211, 238, 0.32);
- background:
- radial-gradient(
- circle at 100% 0%,
- rgba(30, 64, 175, 0.18),
- transparent 32%
- ),
- rgba(15, 23, 42, 0.92);
-}
-
-.root :global(.opportunity-mini-head) {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 8px;
-}
-
-.root :global(.opportunity-mini-rank) {
- width: 30px;
- height: 30px;
- border-radius: 12px;
- border: 1px solid rgba(148, 163, 184, 0.2);
- display: inline-flex;
- align-items: center;
- justify-content: center;
- color: #f8fafc;
- font-size: 13px;
- font-weight: 840;
- flex-shrink: 0;
- background: rgba(6, 11, 23, 0.46);
-}
-
-.root :global(.opportunity-mini-copy) {
- display: flex;
- min-width: 0;
- flex: 1;
- flex-direction: column;
- gap: 6px;
-}
-
-.root :global(.opportunity-pill) {
- margin-left: auto;
- padding: 4px 8px;
- border-radius: 999px;
- font-size: 10px;
- font-weight: 800;
- border: 1px solid rgba(148, 163, 184, 0.18);
- color: rgba(226, 232, 240, 0.86);
-}
-
-.root :global(.opportunity-pill.high) {
- color: #fda4af;
- border-color: rgba(251, 113, 133, 0.26);
-}
-
-.root :global(.opportunity-pill.medium) {
- color: #fbbf24;
- border-color: rgba(251, 191, 36, 0.24);
-}
-
-.root :global(.opportunity-pill.low) {
- color: #4ade80;
- border-color: rgba(74, 222, 128, 0.24);
-}
-
-.root :global(.opportunity-mini-copy strong) {
- overflow: hidden;
- color: #f8fafc;
- font-size: 15px;
- font-weight: 860;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.root :global(.opportunity-mini-copy p) {
- display: -webkit-box;
- overflow: hidden;
- color: rgba(203, 213, 225, 0.88);
- font-size: 11px;
- line-height: 1.45;
- min-height: 32px;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
-}
-
-.root :global(.opportunity-mini-metrics) {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 10px;
-}
-
-.root :global(.opportunity-yes) {
- color: #22d3ee;
- font-size: 12px;
- font-weight: 800;
-}
-
-.root :global(.opportunity-no) {
- color: #fda4af;
- font-size: 12px;
- font-weight: 800;
-}
-
-.root :global(.opportunity-edge) {
- color: rgba(34, 211, 238, 0.9);
- font-size: 18px;
- font-weight: 800;
-}
-
-.root :global(.opportunity-mini-sparkline) {
- width: 100%;
- height: 28px;
- margin-top: auto;
-}
-
-.root :global(.opportunity-mini-sparkline polyline) {
- fill: none;
- stroke: #18e6d4;
- stroke-width: 2;
- stroke-linecap: round;
- stroke-linejoin: round;
- filter: drop-shadow(0 0 8px rgba(34, 211, 238, 0.22));
-}
-
-/* ── Light Theme Dashboard Overrides ── */
-:global(html.light) .root {
- background:
- radial-gradient(
- circle at 22% 12%,
- rgba(14, 165, 233, 0.16),
- transparent 28%
- ),
- radial-gradient(
- circle at 78% 0%,
- rgba(59, 130, 246, 0.12),
- transparent 24%
- ),
- linear-gradient(180deg, #f8fbff 0%, #e7f1fb 100%);
-}
-
-:global(html.light) .root :global(.map) {
- background: #dfeefa;
- border-color: rgba(36, 68, 104, 0.18);
- filter: none;
- box-shadow:
- 0 28px 80px rgba(31, 64, 104, 0.18),
- inset 0 1px 0 rgba(255, 255, 255, 0.86);
-}
-
-:global(html.light) .root :global(.map .leaflet-tile) {
- filter: saturate(1.08) contrast(0.98) brightness(1.02);
-}
-
-:global(html.light) .weatherAura {
- opacity: 0.22;
- mix-blend-mode: multiply;
-}
-
-:global(html.light) .weatherAuraScrim {
- background:
- linear-gradient(
- 180deg,
- rgba(248, 252, 255, 0.2) 0%,
- rgba(248, 252, 255, 0.08) 42%,
- rgba(226, 239, 250, 0.28) 100%
- ),
- radial-gradient(
- circle at 50% 58%,
- rgba(14, 165, 233, 0.08) 0%,
- rgba(14, 165, 233, 0) 52%
- );
-}
-
-:global(html.light) .root :global(.home-panel-city h2),
-:global(html.light) .root :global(.home-weather-main strong),
-:global(html.light) .root :global(.home-weather-stat strong),
-:global(html.light) .root :global(.home-deb-card strong),
-:global(html.light) .root :global(.home-card-section h3),
-:global(html.light) .root :global(.opportunity-strip-copy strong),
-:global(html.light) .root :global(.opportunity-hero-title-row strong),
-:global(html.light) .root :global(.opportunity-hero-metric strong),
-:global(html.light) .root :global(.opportunity-hero-edgeblock strong),
-:global(html.light) .root :global(.opportunity-book-side strong),
-:global(html.light) .root :global(.opportunity-side-stat b),
-:global(html.light) .root :global(.opportunity-mini-copy strong),
-:global(html.light) .root :global(.city-item .city-name-text) {
- color: #0b1726;
-}
-
-:global(html.light) .root :global(.home-panel-subtitle),
-:global(html.light) .root :global(.home-weather-sub),
-:global(html.light) .root :global(.home-weather-label),
-:global(html.light) .root :global(.home-weather-stat span),
-:global(html.light) .root :global(.home-weather-stat small),
-:global(html.light) .root :global(.home-deb-card small),
-:global(html.light) .root :global(.home-card-section h3 small),
-:global(html.light) .root :global(.opportunity-strip-kicker),
-:global(html.light) .root :global(.opportunity-strip-copy p),
-:global(html.light) .root :global(.opportunity-tape-pill span),
-:global(html.light) .root :global(.opportunity-hero-kicker),
-:global(html.light) .root :global(.opportunity-hero-date),
-:global(html.light) .root :global(.opportunity-hero-copy p),
-:global(html.light) .root :global(.opportunity-hero-metric span),
-:global(html.light) .root :global(.opportunity-hero-edgeblock span),
-:global(html.light) .root :global(.opportunity-hero-edgeblock em),
-:global(html.light) .root :global(.opportunity-hero-footer),
-:global(html.light) .root :global(.opportunity-side-tile-head span),
-:global(html.light) .root :global(.opportunity-side-stat span),
-:global(html.light) .root :global(.opportunity-mini-copy p),
-:global(html.light) .root :global(.city-group-title),
-:global(html.light) .root :global(.home-panel-kicker),
-:global(html.light) .root :global(.home-panel-airport) {
- color: rgba(66, 84, 108, 0.86);
-}
-
-:global(html.light) .root :global(.header) {
- background: rgba(248, 252, 255, 0.9);
- border-bottom-color: rgba(36, 68, 104, 0.14);
- box-shadow: 0 12px 34px rgba(31, 64, 104, 0.08);
-}
-
-:global(html.light) .root :global(.brand-mark) {
- background: rgba(255, 255, 255, 0.82);
- border-color: rgba(14, 165, 233, 0.24);
- box-shadow:
- inset 0 0 0 1px rgba(255, 255, 255, 0.72),
- 0 10px 24px rgba(31, 64, 104, 0.12);
-}
-
-:global(html.light) .root :global(.locale-switch) {
- background: rgba(255, 255, 255, 0.82);
- border-color: rgba(36, 68, 104, 0.16);
- color: rgba(66, 84, 108, 0.78);
-}
-
-:global(html.light) .root :global(.locale-switch span.active) {
- color: #075985;
- background: rgba(14, 165, 233, 0.14);
- box-shadow: inset 0 0 0 1px rgba(14, 165, 233, 0.22);
-}
-
-:global(html.light) .root :global(.city-list),
-:global(html.light) .root :global(.home-intelligence-panel),
-:global(html.light) .root :global(.home-opportunity-strip) {
- background:
- radial-gradient(
- circle at 12% 0%,
- rgba(14, 165, 233, 0.08),
- transparent 34%
- ),
- rgba(248, 252, 255, 0.9);
- border-color: rgba(36, 68, 104, 0.16);
- box-shadow: 0 28px 70px rgba(31, 64, 104, 0.14);
- color: #0b1726;
-}
-
-:global(html.light) .root :global(.home-summary-card),
-:global(html.light) .root :global(.opportunity-hero-card),
-:global(html.light) .root :global(.opportunity-side-tile),
-:global(html.light) .root :global(.opportunity-mini-card),
-:global(html.light) .root :global(.opportunity-tape-pill),
-:global(html.light) .root :global(.opportunity-hero-metric),
-:global(html.light) .root :global(.opportunity-book-side),
-:global(html.light) .root :global(.opportunity-hero-sparkline-wrap),
-:global(html.light) .root :global(.home-deb-card),
-:global(html.light) .root :global(.home-card-section),
-:global(html.light) .root :global(.home-metric-card),
-:global(html.light) .root :global(.home-forecast-item),
-:global(html.light) .root :global(.home-intraday-reports span) {
- background:
- radial-gradient(
- circle at 100% 0%,
- rgba(14, 165, 233, 0.08),
- transparent 36%
- ),
- rgba(255, 255, 255, 0.92);
- border-color: rgba(36, 68, 104, 0.14);
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
- color: #0b1726;
-}
-
-:global(html.light) .root :global(.home-weather-hero) {
- background: linear-gradient(
- 135deg,
- rgba(238, 246, 255, 0.98),
- rgba(216, 233, 248, 0.92)
- );
- border-color: rgba(36, 68, 104, 0.14);
-}
-
-:global(html.light) .root :global(.home-card-section.intraday),
-:global(html.light) .root :global(.home-card-section.probability) {
- background:
- radial-gradient(circle at 0% 25%, rgba(14, 165, 233, 0.1), transparent 42%),
- rgba(255, 255, 255, 0.92);
-}
-
-:global(html.light) .root :global(.home-intraday-chart line) {
- stroke: rgba(66, 84, 108, 0.16);
-}
-
-:global(html.light) .root :global(.home-intraday-reports strong),
-:global(html.light) .root :global(.home-probability-threshold),
-:global(html.light) .root :global(.home-market-question strong),
-:global(html.light) .root :global(.home-market-metrics strong),
-:global(html.light) .root :global(.home-forecast-item strong) {
- color: #0b1726;
-}
-
-:global(html.light) .root :global(.home-probability-row div),
-:global(html.light) .root :global(.home-probability-track) {
- background: rgba(37, 57, 82, 0.12);
-}
-
-:global(html.light) .root :global(.city-group) {
- background: rgba(255, 255, 255, 0.54);
- border-color: rgba(36, 68, 104, 0.12);
-}
-
-:global(html.light) .root :global(.city-group-header:hover),
-:global(html.light) .root :global(.city-item:hover) {
- background: rgba(221, 235, 250, 0.86);
-}
-
-:global(html.light) .root :global(.city-item.active) {
- background:
- linear-gradient(90deg, rgba(14, 165, 233, 0.18), rgba(14, 165, 233, 0.06)),
- rgba(255, 255, 255, 0.88);
- border-color: rgba(14, 165, 233, 0.42);
-}
-
-:global(html.light) .root :global(.leaflet-control-attribution),
-:global(html.light) .root :global(.leaflet-control-zoom a) {
- background: rgba(248, 252, 255, 0.86) !important;
- border-color: rgba(36, 68, 104, 0.16) !important;
- color: #0b1726 !important;
-}
-
-@keyframes home-panel-enter {
- from {
- opacity: 0;
- transform: translate3d(24px, 0, 0) scale(0.98);
- }
- to {
- opacity: 1;
- transform: translate3d(0, 0, 0) scale(1);
- }
-}
-
-@keyframes opportunity-strip-enter {
- from {
- opacity: 0;
- transform: translate3d(0, 22px, 0);
- }
- to {
- opacity: 1;
- transform: translate3d(0, 0, 0);
- }
-}
-
-@keyframes home-signal-wave {
- 0%,
- 100% {
- transform: scaleY(0.72);
- opacity: 0.68;
- }
- 50% {
- transform: scaleY(1);
- opacity: 1;
- }
-}
-
-@media (max-width: 1360px) {
- .root :global(.map) {
- right: 18px;
- }
-
- .root :global(.home-intelligence-panel) {
- display: none;
- }
-
- .root :global(.home-opportunity-strip) {
- right: 18px;
- }
-}
-
-@media (max-width: 1240px) {
- .root :global(.opportunity-strip-topline) {
- flex-direction: column;
- }
-
- .root :global(.opportunity-strip-tape) {
- max-width: none;
- justify-content: flex-start;
- }
-
- .root :global(.opportunity-hero-metrics) {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .root :global(.opportunity-hero-book) {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .root :global(.opportunity-hero-sparkline-wrap) {
- grid-column: 1 / -1;
- }
-
- .root :global(.opportunity-mini-grid) {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-}
-
-@media (max-width: 1020px) {
- .root :global(.map) {
- left: 18px;
- bottom: 188px;
- }
-
- .root :global(.home-opportunity-strip) {
- left: 18px;
- }
-
- .root :global(.opportunity-strip-main) {
- grid-template-columns: 1fr;
- }
-
- .root :global(.opportunity-side-grid) {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-}
-
-@media (max-width: 720px) {
- .root :global(.map) {
- inset: var(--header-height) 0 0;
- border-radius: 0;
- border-left: 0;
- border-right: 0;
- }
-
- .root :global(.home-opportunity-strip) {
- display: none;
- }
-}
-
-/* ── Detail Panel ── */
-.root :global(.detail-panel) {
- position: fixed;
- top: 0;
- right: 0;
- width: var(--panel-width);
- height: 100vh;
- z-index: 950;
- background: linear-gradient(
- 180deg,
- rgba(10, 14, 26, 0.92) 0%,
- rgba(15, 23, 42, 0.95) 100%
- );
- backdrop-filter: blur(24px);
- border-left: 1px solid var(--border-glass);
- box-shadow: -10px 0 60px rgba(0, 0, 0, 0.5);
- transform: translateX(100%);
- transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-.root :global(.detail-panel.visible) {
- transform: translateX(0);
-}
-
-.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
- position: sticky;
- top: 16px;
- width: auto;
- min-width: 380px;
- height: calc(100vh - 32px);
- min-height: 0;
- max-height: calc(100vh - 32px);
- z-index: auto;
- transform: none;
- transition: none;
- border: 1px solid rgba(82, 114, 161, 0.18);
- border-left: 1px solid rgba(82, 114, 161, 0.18);
- border-radius: 22px;
- background: linear-gradient(180deg, rgba(15, 28, 47, 0.94), rgba(9, 18, 32, 0.94));
- box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
- backdrop-filter: blur(14px);
- align-self: start;
- overflow: hidden;
-}
-
-.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail.visible) {
- transform: none;
-}
-
-.root :global(.scan-city-detail-rail .panel-header) {
- padding: 20px 16px 16px;
-}
-
-.root :global(.panel-header) {
- padding: 20px 20px 16px;
- border-bottom: 1px solid var(--border-subtle);
- position: relative;
- flex-shrink: 0;
-}
-
-.root :global(.panel-close) {
- position: absolute;
- top: 16px;
- right: 16px;
- width: 32px;
- height: 32px;
- border-radius: 8px;
- border: 1px solid var(--border-glass);
- background: transparent;
- color: var(--text-muted);
- font-size: 14px;
- cursor: pointer;
- transition: var(--transition);
- display: flex;
- align-items: center;
- justify-content: center;
-}
-.root :global(.panel-close:hover) {
- background: rgba(248, 113, 113, 0.15);
- border-color: var(--accent-red);
- color: var(--accent-red);
-}
-
-.root :global(.panel-title-area h2) {
- font-size: 22px;
- font-weight: 700;
- letter-spacing: -0.02em;
- margin-bottom: 6px;
-}
-
-.root :global(.panel-loading-hint) {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 10px;
- padding: 6px 10px;
- border-radius: 999px;
- border: 1px solid rgba(34, 211, 238, 0.18);
- background: rgba(12, 24, 42, 0.78);
- color: var(--accent-cyan);
- font-size: 11px;
- font-weight: 600;
- letter-spacing: 0.2px;
-}
-
-.root :global(.panel-loading-spinner) {
- width: 10px;
- height: 10px;
- border-radius: 999px;
- border: 2px solid rgba(34, 211, 238, 0.22);
- border-top-color: rgba(34, 211, 238, 0.92);
- animation: loading-spin 0.8s linear infinite;
-}
-
-.root :global(.panel-meta) {
- display: flex;
- align-items: center;
- gap: 10px;
- flex-wrap: wrap;
-}
-
-.root :global(.city-loading-toast) {
- position: fixed;
- top: 78px;
- left: 50%;
- transform: translateX(-50%);
- z-index: 1200;
- display: inline-flex;
- align-items: center;
- gap: 10px;
- padding: 10px 14px;
- border-radius: 999px;
- border: 1px solid rgba(34, 211, 238, 0.18);
- background: linear-gradient(
- 180deg,
- rgba(10, 18, 34, 0.94),
- rgba(10, 18, 34, 0.82)
- );
- box-shadow:
- 0 18px 40px rgba(2, 6, 23, 0.38),
- 0 0 0 1px rgba(34, 211, 238, 0.04) inset;
- backdrop-filter: blur(18px);
- pointer-events: none;
-}
-
-.root :global(.city-loading-dot) {
- width: 10px;
- height: 10px;
- border-radius: 999px;
- background: var(--accent-cyan);
- box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.5);
- animation: city-loading-pulse 1.35s ease-out infinite;
-}
-
-.root :global(.city-loading-copy) {
- color: var(--text-primary);
- font-size: 12px;
- font-weight: 600;
- letter-spacing: 0.25px;
-}
-
-.root :global(.pro-locked) {
- filter: grayscale(0.8) opacity(0.7);
- position: relative;
-}
-
-.root :global(.pro-locked::after) {
- content: "PRO";
- position: absolute;
- top: -4px;
- right: -4px;
- background: var(--accent-blue);
- color: white;
- font-size: 7px;
- padding: 1px 3px;
- border-radius: 3px;
- font-weight: 900;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
-}
-
-.root :global(.risk-badge) {
- font-size: 11px;
- font-weight: 600;
- padding: 3px 10px;
- border-radius: 6px;
- letter-spacing: 0.5px;
-}
-.root :global(.risk-badge.high) {
- background: rgba(239, 68, 68, 0.15);
- color: var(--risk-high);
- border: 1px solid rgba(239, 68, 68, 0.3);
-}
-.root :global(.risk-badge.medium) {
- background: rgba(245, 158, 11, 0.15);
- color: var(--risk-medium);
- border: 1px solid rgba(245, 158, 11, 0.3);
-}
-.root :global(.risk-badge.low) {
- background: rgba(34, 197, 94, 0.15);
- color: var(--risk-low);
- border: 1px solid rgba(34, 197, 94, 0.3);
-}
-
-.root :global(.local-time) {
- font-size: 12px;
- color: var(--text-muted);
- font-variant-numeric: tabular-nums;
-}
-
-.root :global(.panel-body) {
- overflow-y: auto;
- flex: 1;
- position: relative;
- padding: 0 20px 24px;
-}
-
-.root :global(.panel-sync-blocker) {
- position: sticky;
- top: 0;
- z-index: 6;
- display: flex;
- align-items: center;
- gap: 10px;
- margin: 0 0 12px;
- padding: 12px 14px;
- border: 1px solid rgba(34, 211, 238, 0.22);
- border-radius: 8px;
- background: rgba(8, 16, 30, 0.96);
- color: var(--text-primary);
- box-shadow: 0 14px 34px rgba(2, 6, 23, 0.38);
- backdrop-filter: blur(18px);
- font-size: 12px;
- font-weight: 650;
- line-height: 1.45;
-}
-
-.root :global(.panel-content-stale) {
- opacity: 0.28;
- pointer-events: none;
- user-select: none;
-}
-
-.root :global(.panel-content-stale canvas) {
- visibility: hidden;
-}
-.root :global(.panel-body::-webkit-scrollbar) {
- width: 4px;
-}
-.root :global(.panel-body::-webkit-scrollbar-thumb) {
- background: var(--border-glass);
- border-radius: 2px;
-}
-
-.root :global(.panel-body section) {
- padding: 18px 0;
- border-bottom: 1px solid var(--border-subtle);
-}
-.root :global(.panel-body section:last-child) {
- border-bottom: none;
-}
-
-.root :global(.panel-body section.detail-scenery-card) {
- padding: 0;
- margin: 18px 0;
-}
-
-.root :global(.panel-body h3) {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-secondary);
- margin-bottom: 12px;
- letter-spacing: 0.3px;
-}
-
-/* ── Hero Section ── */
-.root :global(.hero-section) {
- text-align: center;
- padding-top: 12px !important;
-}
-
-.root :global(.hero-weather) {
- font-size: 13px;
- font-weight: 600;
- color: var(--accent-cyan);
- margin-bottom: -4px;
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
-}
-
-.root :global(.hero-temp) {
- display: flex;
- align-items: flex-start;
- justify-content: center;
- gap: 2px;
- margin-bottom: 2px;
-}
-
-.root :global(.hero-max-time) {
- font-size: 10px;
- font-weight: 500;
- color: var(--text-muted);
- margin-bottom: 16px;
- min-height: 12px;
-}
-.root :global(.hero-value) {
- font-size: 56px;
- font-weight: 800;
- letter-spacing: -0.04em;
- line-height: 1;
- background: linear-gradient(135deg, #fff 30%, var(--accent-cyan));
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- background-clip: text;
-}
-.root :global(.hero-unit) {
- font-size: 20px;
- font-weight: 400;
- color: var(--text-muted);
- margin-top: 8px;
-}
-
-.root :global(.hero-details) {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 8px;
- margin-bottom: 12px;
-}
-.root :global(.hero-item) {
- background: rgba(255, 255, 255, 0.03);
- border: 1px solid var(--border-subtle);
- border-radius: 10px;
- padding: 10px 8px;
- text-align: center;
-}
-.root :global(.hero-item .label) {
- display: block;
- font-size: 10px;
- color: var(--text-muted);
- margin-bottom: 4px;
-}
-.root :global(.hero-item .value) {
- display: block;
- font-size: 16px;
- font-weight: 700;
- color: var(--text-primary);
- font-variant-numeric: tabular-nums;
-}
-.root :global(.hero-item .value.highlight) {
- color: var(--accent-cyan);
- text-shadow: 0 0 12px rgba(34, 211, 238, 0.3);
-}
-
-.root :global(.hero-sub) {
- font-size: 12px;
- color: var(--text-muted);
- display: flex;
- justify-content: center;
- gap: 16px;
- flex-wrap: wrap;
-}
-.root :global(.hero-sub span) {
- white-space: nowrap;
-}
-
-/* ── Chart Section ── */
-.root :global(.chart-wrapper) {
- height: 180px;
- position: relative;
- background: rgba(255, 255, 255, 0.02);
- border-radius: 12px;
- border: 1px solid var(--border-subtle);
- padding: 12px;
-}
-
-.root :global(.chart-legend) {
- display: flex;
- justify-content: center;
- gap: 16px;
- margin-top: 8px;
- font-size: 11px;
- color: var(--text-muted);
-}
-
-/* ── Probability Bars ── */
-.root :global(.prob-bars) {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.root :global(.prob-calibration-head) {
- display: grid;
- gap: 6px;
- margin-bottom: 4px;
- padding: 10px;
- border: 1px solid rgba(34, 211, 238, 0.16);
- border-radius: 8px;
- background: rgba(15, 23, 42, 0.26);
-}
-
-.root :global(.prob-calibration-head > div) {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.root :global(.prob-calibration-head strong) {
- color: var(--text-primary);
- font-size: 13px;
- font-weight: 800;
-}
-
-.root :global(.prob-calibration-head p) {
- margin: 0;
- color: var(--text-muted);
- font-size: 11px;
- line-height: 1.45;
-}
-
-.root :global(.prob-source-chip) {
- display: inline-flex;
- align-items: center;
- min-height: 22px;
- padding: 3px 8px;
- border: 1px solid rgba(34, 211, 238, 0.28);
- border-radius: 8px;
- color: #6FB7FF;
- background: rgba(34, 211, 238, 0.08);
- font-size: 11px;
- font-weight: 900;
-}
-
-.root :global(.prob-row) {
- display: flex;
- align-items: center;
- gap: 10px;
-}
-
-.root :global(.prob-label) {
- width: 80px;
- font-size: 13px;
- font-weight: 600;
- font-variant-numeric: tabular-nums;
- text-align: right;
- flex-shrink: 0;
-}
-
-.root :global(.prob-bar-track) {
- flex: 1;
- height: 28px;
- background: rgba(255, 255, 255, 0.04);
- border-radius: 8px;
- overflow: hidden;
- position: relative;
-}
-
-.root :global(.prob-bar-fill) {
- height: 100%;
- border-radius: 8px;
- transition: width 0.8s cubic-bezier(0.16, 1, 0.3, 1);
- display: flex;
- align-items: center;
- padding-left: 10px;
- font-size: 12px;
- font-weight: 600;
- color: white;
- min-width: 40px;
-}
-
-.root :global(.prob-bar-fill.rank-0) {
- background: linear-gradient(90deg, var(--accent-blue), var(--accent-cyan));
- box-shadow: 0 0 12px rgba(99, 102, 241, 0.3);
-}
-.root :global(.prob-bar-fill.rank-1) {
- background: linear-gradient(
- 90deg,
- rgba(99, 102, 241, 0.6),
- rgba(34, 211, 238, 0.5)
- );
-}
-.root :global(.prob-bar-fill.rank-2) {
- background: rgba(99, 102, 241, 0.3);
-}
-.root :global(.prob-bar-fill.rank-3) {
- background: rgba(99, 102, 241, 0.15);
-}
-
-.root :global(.prob-market-inline) {
- min-width: 120px;
- text-align: right;
- font-size: 12px;
- font-weight: 700;
- border-radius: 999px;
- padding: 4px 10px;
- letter-spacing: 0.02em;
- font-variant-numeric: tabular-nums;
-}
-
-.root :global(.prob-market-inline.yes) {
- color: #4ade80;
- background: rgba(74, 222, 128, 0.12);
- border: 1px solid rgba(74, 222, 128, 0.3);
-}
-
-.root :global(.prob-market-inline.no) {
- color: #fb7185;
- background: rgba(251, 113, 133, 0.12);
- border: 1px solid rgba(251, 113, 133, 0.26);
-}
-
-.root :global(.prob-distribution-panel) {
- display: grid;
- gap: 8px;
- padding: 10px;
- border: 1px solid rgba(148, 163, 184, 0.14);
- border-radius: 8px;
- background: rgba(15, 23, 42, 0.24);
-}
-
-.root :global(.prob-distribution-head) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- margin-bottom: 2px;
-}
-
-.root :global(.prob-distribution-head span) {
- color: var(--cyan);
- font-size: 11px;
- font-weight: 900;
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.root :global(.prob-distribution-head em) {
- color: var(--text-muted);
- font-size: 11px;
- font-style: normal;
-}
-
-.root :global(.prob-price-card) {
- display: grid;
- gap: 8px;
- padding: 10px;
- border: 1px solid rgba(34, 211, 238, 0.16);
- border-radius: 8px;
- background: rgba(8, 20, 32, 0.52);
-}
-
-.root :global(.prob-price-head) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
-}
-
-.root :global(.prob-price-head span) {
- color: var(--cyan);
- font-size: 11px;
- font-weight: 800;
- text-transform: uppercase;
- letter-spacing: 0.04em;
-}
-
-.root :global(.prob-price-head strong) {
- color: var(--text-primary);
- font-size: 13px;
- font-weight: 800;
-}
-
-.root :global(.prob-price-grid) {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 8px;
-}
-
-.root :global(.prob-price-grid > div) {
- display: grid;
- gap: 3px;
- min-width: 0;
- padding: 8px;
- border: 1px solid rgba(148, 163, 184, 0.12);
- border-radius: 8px;
- background: rgba(15, 23, 42, 0.45);
-}
-
-.root :global(.prob-price-grid span),
-.root :global(.prob-price-grid em),
-.root :global(.prob-price-card p) {
- color: var(--text-muted);
- font-size: 11px;
- font-style: normal;
- line-height: 1.35;
-}
-
-.root :global(.prob-price-grid strong) {
- color: var(--text-primary);
- font-size: 13px;
- font-weight: 800;
- font-variant-numeric: tabular-nums;
-}
-
-.root :global(.prob-price-card p) {
- margin: 0;
-}
-
-.root :global(.prob-model-hint) {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 6px;
- margin-bottom: 10px;
- padding: 7px 9px;
- border: 1px solid rgba(148, 163, 184, 0.14);
- border-radius: 8px;
- background: rgba(15, 23, 42, 0.22);
-}
-
-.root :global(.prob-model-hint span) {
- color: var(--cyan);
- font-size: 11px;
- font-weight: 800;
-}
-
-.root :global(.prob-model-hint strong) {
- color: var(--text-secondary);
- font-size: 11px;
- font-weight: 800;
-}
-
-.root :global(.prob-model-hint em) {
- color: var(--text-muted);
- font-size: 11px;
- font-style: normal;
-}
-
-/* ── Model Bars ── */
-.root :global(.model-bars) {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.root :global(.model-row) {
- display: flex;
- align-items: center;
- gap: 10px;
- font-size: 12px;
-}
-
-.root :global(.model-name) {
- width: 80px;
- text-align: right;
- color: var(--text-muted);
- font-weight: 500;
- flex-shrink: 0;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.root :global(.model-row-rich) {
- align-items: flex-start;
-}
-
-.root :global(.model-row-rich .model-name) {
- width: 118px;
- white-space: normal;
- line-height: 1.25;
-}
-
-.root :global(.model-row-rich .model-name strong) {
- display: block;
- color: var(--text-primary);
- font-size: 12px;
-}
-
-.root :global(.model-row-rich .model-name span) {
- display: block;
- margin-top: 2px;
- color: var(--text-muted);
- font-size: 10px;
- font-weight: 500;
-}
-
-.root :global(.model-stack-summary) {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin-bottom: 2px;
-}
-
-.root :global(.model-stack-summary span) {
- border: 1px solid rgba(148, 163, 184, 0.16);
- border-radius: 6px;
- background: rgba(15, 23, 42, 0.5);
- color: var(--text-secondary);
- font-size: 11px;
- padding: 5px 8px;
-}
-
-.root :global(.model-stack-summary strong) {
- color: var(--text-primary);
- font-weight: 800;
-}
-
-.root :global(.model-group) {
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding: 8px;
- border-left: 2px solid rgba(148, 163, 184, 0.26);
- background: rgba(15, 23, 42, 0.28);
- border-radius: 6px;
-}
-
-.root :global(.model-group-cyan) {
- border-left-color: rgba(34, 211, 238, 0.75);
-}
-
-.root :global(.model-group-blue) {
- border-left-color: rgba(96, 165, 250, 0.75);
-}
-
-.root :global(.model-group-amber) {
- border-left-color: rgba(245, 158, 11, 0.8);
-}
-
-.root :global(.model-group-heading) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- color: var(--accent-cyan);
- font-size: 10px;
- font-weight: 800;
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}
-
-.root :global(.model-group-heading em) {
- color: var(--text-muted);
- font-style: normal;
- letter-spacing: 0;
-}
-
-.root :global(.model-bar-track) {
- flex: 1;
- height: 20px;
- background: rgba(255, 255, 255, 0.03);
- border-radius: 6px;
- position: relative;
- overflow: hidden;
-}
-
-.root :global(.model-bar-fill) {
- height: 100%;
- border-radius: 6px;
- background: linear-gradient(
- 90deg,
- rgba(14, 165, 233, 0.72),
- rgba(34, 211, 238, 0.92)
- );
- transition: width 0.6s ease-out;
-}
-
-.root :global(.model-bar-fill.deb) {
- background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green));
- box-shadow: 0 0 8px rgba(34, 211, 238, 0.3);
-}
-
-.root :global(.model-bar-value) {
- position: absolute;
- top: 50%;
- right: 8px;
- z-index: 3;
- transform: translateY(-50%);
- color: var(--text-primary);
- font-size: 11px;
- font-weight: 800;
- line-height: 1;
- max-width: calc(100% - 12px);
- overflow: visible;
- pointer-events: none;
- text-align: right;
- text-shadow: 0 1px 2px rgba(2, 6, 23, 0.9);
- white-space: nowrap;
-}
-
-.root :global(.model-bar-value.deb) {
- color: #ecfeff;
-}
-
-.root :global(.model-deb-line) {
- position: absolute;
- top: 0;
- bottom: 0;
- width: 2px;
- background: var(--accent-cyan);
- box-shadow: 0 0 6px var(--accent-cyan);
- z-index: 2;
-}
-
-/* ── Forecast Table ── */
-.root :global(.forecast-table) {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
- gap: 8px;
-}
-
-.root :global(.forecast-inline-note) {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 10px;
- padding: 8px 10px;
- border: 1px solid rgba(34, 211, 238, 0.18);
- border-radius: 8px;
- color: var(--text-secondary);
- background: rgba(34, 211, 238, 0.06);
- font-size: 12px;
- line-height: 1.45;
-}
-
-.root :global(.forecast-inline-note::before) {
- content: "";
- width: 8px;
- height: 8px;
- flex: 0 0 auto;
- border-radius: 999px;
- background: #22d3ee;
- box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.12);
- animation: pulseGlow 1.25s ease-in-out infinite;
-}
-
-.root :global(.forecast-day) {
- background: rgba(255, 255, 255, 0.03);
- border: 1px solid var(--border-subtle);
- border-radius: 10px;
- padding: 10px;
- text-align: center;
- transition: var(--transition);
-}
-.root :global(.forecast-day:hover) {
- border-color: var(--border-glass);
- background: rgba(255, 255, 255, 0.05);
-}
-.root :global(.forecast-day .f-date) {
- font-size: 11px;
- color: var(--text-muted);
- margin-bottom: 4px;
-}
-.root :global(.forecast-day .f-temp) {
- font-size: 18px;
- font-weight: 700;
- color: var(--text-primary);
-}
-.root :global(.forecast-day.today) {
- border-color: var(--accent-blue);
- background: rgba(99, 102, 241, 0.08);
-}
-.root :global(.forecast-day.today .f-date) {
- color: var(--accent-cyan);
-}
-
-.root :global(.forecast-day.selected) {
- border-color: var(--accent-cyan);
- background: rgba(34, 211, 238, 0.1);
- box-shadow: 0 0 12px rgba(34, 211, 238, 0.2);
-}
-.root :global(.forecast-day.selected .f-date) {
- color: var(--accent-cyan);
-}
-
-.root :global(.forecast-day-sync) {
- cursor: wait;
- opacity: 0.76;
- background: linear-gradient(
- 90deg,
- rgba(15, 23, 42, 0.86),
- rgba(30, 41, 59, 0.72),
- rgba(15, 23, 42, 0.86)
- );
- background-size: 220% 100%;
- animation: panelSkeletonSweep 1.35s ease-in-out infinite;
-}
-
-.root :global(.forecast-day-sync .f-temp) {
- color: var(--text-muted);
-}
-
-@keyframes panelSkeletonSweep {
- 0% {
- background-position: 120% 0;
- }
- 100% {
- background-position: -120% 0;
- }
-}
-
-@media (max-width: 720px) {
- .root :global(.forecast-table) {
- display: flex;
- gap: 10px;
- overflow-x: auto;
- padding-bottom: 4px;
- scroll-snap-type: x proximity;
- }
-
- .root :global(.forecast-table::-webkit-scrollbar) {
- height: 6px;
- }
-
- .root :global(.forecast-table::-webkit-scrollbar-thumb) {
- background: rgba(148, 163, 184, 0.32);
- border-radius: 999px;
- }
-
- .root :global(.forecast-day) {
- flex: 0 0 112px;
- min-width: 112px;
- scroll-snap-align: start;
- }
-}
-
-.root :global(.sun-info) {
- margin-top: 10px;
- font-size: 12px;
- color: var(--text-muted);
- display: flex;
- gap: 16px;
- justify-content: center;
-}
-
-/* ── AI Section ── */
-.root :global(.ai-box) {
- background: rgba(99, 102, 241, 0.06);
- border: 1px solid rgba(99, 102, 241, 0.15);
- border-radius: 12px;
- padding: 16px;
- font-size: 13px;
- line-height: 1.7;
- color: var(--text-secondary);
- white-space: pre-wrap;
- word-break: break-word;
-}
-
-.root :global(.ai-placeholder) {
- color: var(--text-muted);
- font-style: italic;
-}
-
-/* ── Risk Section ── */
-.root :global(.risk-info) {
- font-size: 12px;
- color: var(--text-secondary);
- line-height: 1.8;
- word-break: break-word; /* 确保数字和英文长句也能折行 */
- white-space: normal;
-}
-.root :global(.risk-info .risk-row) {
- display: flex;
- gap: 8px;
- align-items: flex-start;
-}
-.root :global(.risk-info .risk-label) {
- color: var(--text-muted);
- min-width: 60px;
- flex-shrink: 0;
-}
-
-/* ── Nearby Markers ── */
-.root :global(.nearby-marker) {
- display: flex;
- align-items: center;
- gap: 8px;
- background: rgba(13, 17, 28, 0.85);
- color: var(--text-primary);
- border: 1px solid rgba(34, 211, 238, 0.25);
- border-radius: 12px;
- padding: 5px 12px;
- font-size: 11px;
- font-weight: 500;
- box-shadow:
- 0 4px 16px rgba(0, 0, 0, 0.6),
- inset 0 0 10px rgba(34, 211, 238, 0.05);
- white-space: nowrap;
- backdrop-filter: blur(12px);
- pointer-events: none;
- animation: markerFadeIn 0.35s cubic-bezier(0.16, 1, 0.3, 1);
- transition: border-color 0.3s ease;
-}
-
-.root :global(.nearby-marker:hover) {
- border-color: var(--accent-cyan);
-}
-
-.root :global(.nearby-name) {
- color: rgba(255, 255, 255, 0.6);
- font-weight: 400;
- max-width: 120px;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.root :global(.nearby-temp) {
- font-weight: 800;
- color: var(--accent-cyan);
- font-size: 13px;
- text-shadow: 0 0 10px rgba(34, 211, 238, 0.3);
-}
-
-.root :global(.nearby-unit) {
- font-weight: 600;
- font-size: 10px;
- color: var(--accent-cyan);
- opacity: 0.8;
- margin-left: 1px;
-}
-
-.root :global(.wind-info) {
- display: flex;
- align-items: center;
- gap: 5px;
- margin-left: 4px;
- padding-left: 8px;
- border-left: 1.5px solid rgba(255, 255, 255, 0.15);
-}
-
-.root :global(.wind-arrow) {
- display: inline-block;
- font-size: 14px;
- color: var(--accent-green);
- text-shadow: 0 0 8px rgba(52, 211, 153, 0.4);
-}
-
-.root :global(.wind-speed) {
- font-size: 10px;
- font-weight: 700;
- color: var(--text-primary);
- font-variant-numeric: tabular-nums;
-}
-
-@keyframes markerFadeIn {
- from {
- opacity: 0;
- transform: translateY(5px);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
-
-/* ── Custom Map Markers ── */
-.root :global(.city-marker) {
- position: relative;
- display: flex;
- flex-direction: column;
- align-items: center;
- cursor: pointer;
- /* Remove transition: transform to avoid fighting with Leaflet's own positioning */
-}
-.root :global(.city-marker:hover) {
- transform: scale(1.15);
- z-index: 1000 !important;
-}
-
-.root :global(.marker-bubble) {
- min-width: 44px;
- padding: 4px 10px;
- border-radius: 14px;
- font-family: "Inter", sans-serif;
- font-size: 13px;
- font-weight: 700;
- text-align: center;
- color: white;
- white-space: nowrap;
- position: relative;
- box-shadow:
- 0 6px 18px rgba(0, 0, 0, 0.42),
- 0 0 0 1px rgba(255, 255, 255, 0.04);
- border: 1px solid rgba(255, 255, 255, 0.14);
-}
-
-.root :global(.marker-bubble::after) {
- content: "";
- position: absolute;
- bottom: -6px;
- left: 50%;
- transform: translateX(-50%);
- width: 0;
- height: 0;
- border-left: 6px solid transparent;
- border-right: 6px solid transparent;
- border-top: 6px solid;
- border-top-color: inherit;
-}
-
-.root :global(.marker-bubble.risk-high) {
- background: linear-gradient(135deg, #991b1b, #fb7185);
- border-color: rgba(251, 113, 133, 0.42);
- box-shadow:
- 0 6px 18px rgba(0, 0, 0, 0.42),
- 0 0 22px rgba(251, 113, 133, 0.18);
-}
-.root :global(.marker-bubble.risk-high::after) {
- border-top-color: #ef4444;
-}
-
-.root :global(.marker-bubble.risk-medium) {
- background: linear-gradient(135deg, #9a3412, #f59e0b);
- border-color: rgba(245, 158, 11, 0.42);
- box-shadow:
- 0 6px 18px rgba(0, 0, 0, 0.42),
- 0 0 22px rgba(245, 158, 11, 0.16);
-}
-.root :global(.marker-bubble.risk-medium::after) {
- border-top-color: #f59e0b;
-}
-
-.root :global(.marker-bubble.risk-low) {
- background: linear-gradient(135deg, #065f46, #22c55e);
- border-color: rgba(74, 222, 128, 0.42);
- box-shadow:
- 0 6px 18px rgba(0, 0, 0, 0.42),
- 0 0 22px rgba(74, 222, 128, 0.14);
-}
-.root :global(.marker-bubble.risk-low::after) {
- border-top-color: #22C55E;
-}
-
-.root :global(.marker-name) {
- font-size: 10px;
- font-weight: 600;
- color: rgba(255, 255, 255, 0.85);
- text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
- margin-top: 2px;
- white-space: nowrap;
-}
-
-/* Marker glow animation for selected city */
-.root :global(.city-marker.selected .marker-bubble) {
- animation: markerGlow 2s ease-in-out infinite;
-}
-@keyframes markerGlow {
- 0%,
- 100% {
- box-shadow:
- 0 6px 18px rgba(0, 0, 0, 0.42),
- 0 0 24px rgba(34, 211, 238, 0.12);
- }
- 50% {
- box-shadow:
- 0 6px 26px rgba(0, 0, 0, 0.46),
- 0 0 20px rgba(34, 211, 238, 0.24),
- 0 0 46px rgba(34, 211, 238, 0.14);
- }
-}
-
-/* ── Loading Overlay ── */
-.root :global(.loading-overlay) {
- position: fixed;
- inset: 0;
- z-index: 2000;
- background: radial-gradient(
- circle at 50% 50%,
- rgba(8, 14, 32, 0.76) 0%,
- rgba(4, 8, 22, 0.9) 44%,
- rgba(2, 6, 20, 0.96) 100%
- );
- display: flex;
- align-items: center;
- justify-content: center;
- backdrop-filter: blur(12px);
-}
-
-.root :global(.loading-card) {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 22px;
- min-width: 240px;
- padding: 28px 32px;
- border-radius: 28px;
- background: linear-gradient(
- 180deg,
- rgba(10, 18, 36, 0.78) 0%,
- rgba(8, 14, 30, 0.62) 100%
- );
- border: 1px solid rgba(92, 142, 255, 0.16);
- box-shadow:
- 0 24px 90px rgba(0, 0, 0, 0.42),
- inset 0 1px 0 rgba(255, 255, 255, 0.04);
-}
-
-.root :global(.loading-clouds) {
- position: relative;
- width: 220px;
- height: 26px;
- overflow: hidden;
-}
-
-.root :global(.loading-cloud) {
- position: absolute;
- top: 0;
- height: 18px;
- border-radius: 999px;
- background:
- radial-gradient(
- circle at 30% 50%,
- rgba(186, 230, 253, 0.22),
- transparent 54%
- ),
- linear-gradient(
- 180deg,
- rgba(148, 163, 184, 0.28) 0%,
- rgba(71, 85, 105, 0.1) 100%
- );
- filter: blur(0.3px);
- box-shadow:
- 0 6px 24px rgba(56, 189, 248, 0.06),
- inset 0 1px 0 rgba(255, 255, 255, 0.08);
- animation: cloud-drift 6.8s ease-in-out infinite;
-}
-
-.root :global(.loading-cloud-1) {
- left: 12px;
- width: 76px;
-}
-
-.root :global(.loading-cloud-2) {
- right: 8px;
- top: 7px;
- width: 98px;
- opacity: 0.75;
- animation-delay: -2.6s;
-}
-
-.root :global(.loading-windfield) {
- position: relative;
- width: 220px;
- height: 30px;
- overflow: hidden;
- opacity: 0.9;
-}
-
-.root :global(.loading-windline) {
- position: absolute;
- left: -28%;
- height: 1px;
- border-radius: 999px;
- background: linear-gradient(
- 90deg,
- rgba(34, 211, 238, 0) 0%,
- rgba(103, 232, 249, 0.5) 22%,
- rgba(125, 211, 252, 0.9) 55%,
- rgba(34, 211, 238, 0) 100%
- );
- filter: blur(0.2px);
- animation: wind-shift 2.8s linear infinite;
-}
-
-.root :global(.loading-windline-1) {
- top: 4px;
- width: 124px;
-}
-
-.root :global(.loading-windline-2) {
- top: 13px;
- width: 176px;
- animation-delay: -0.9s;
- opacity: 0.82;
-}
-
-.root :global(.loading-windline-3) {
- top: 22px;
- width: 142px;
- animation-delay: -1.6s;
- opacity: 0.66;
-}
-
-.root :global(.loading-copy) {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 8px;
- text-align: center;
-}
-
-.root :global(.loading-copy strong) {
- font-size: 13px;
- font-weight: 700;
- letter-spacing: 0.22em;
- text-transform: uppercase;
- color: rgba(103, 232, 249, 0.92);
-}
-
-.root :global(.loading-copy span) {
- max-width: 240px;
- font-size: 13px;
- font-weight: 500;
- line-height: 1.55;
- color: rgba(203, 213, 225, 0.82);
-}
-
-.root :global(.loading-radar) {
- position: relative;
- width: 108px;
- height: 108px;
- border-radius: 50%;
- background: radial-gradient(
- circle,
- rgba(22, 78, 99, 0.16) 0%,
- rgba(7, 18, 36, 0.08) 48%,
- rgba(5, 10, 22, 0.02) 76%,
- transparent 100%
- );
- box-shadow:
- inset 0 0 0 1px rgba(56, 189, 248, 0.12),
- 0 0 40px rgba(34, 211, 238, 0.08);
-}
-
-.root :global(.loading-radar-core) {
- position: absolute;
- top: 50%;
- left: 50%;
- width: 14px;
- height: 14px;
- border-radius: 50%;
- background: radial-gradient(circle, #6FB7FF 0%, #0891b2 100%);
- transform: translate(-50%, -50%);
- box-shadow:
- 0 0 12px rgba(34, 211, 238, 0.65),
- 0 0 28px rgba(59, 130, 246, 0.25);
-}
-
-.root :global(.loading-radar-ring) {
- position: absolute;
- inset: 0;
- border-radius: 50%;
- border: 1px solid rgba(56, 189, 248, 0.14);
-}
-
-.root :global(.loading-radar-ring-1) {
- transform: scale(0.62);
-}
-
-.root :global(.loading-radar-ring-2) {
- transform: scale(0.88);
-}
-
-.root :global(.loading-radar-sweep) {
- position: absolute;
- inset: 6px;
- border-radius: 50%;
- background: conic-gradient(
- from 90deg,
- rgba(34, 211, 238, 0) 0deg,
- rgba(34, 211, 238, 0) 260deg,
- rgba(34, 211, 238, 0.3) 312deg,
- rgba(96, 165, 250, 0.06) 360deg
- );
- mask: radial-gradient(circle, transparent 0 22px, #000 23px);
- animation: radar-sweep 2.4s linear infinite;
-}
-
-.root :global(.loading-radar-blip) {
- position: absolute;
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: rgba(253, 224, 71, 0.92);
- box-shadow: 0 0 12px rgba(253, 224, 71, 0.45);
- animation: radar-blip 1.8s ease-in-out infinite;
-}
-
-.root :global(.loading-radar-blip-1) {
- top: 26px;
- right: 24px;
- animation-delay: 0.15s;
-}
-
-.root :global(.loading-radar-blip-2) {
- bottom: 25px;
- left: 20px;
- background: rgba(74, 222, 128, 0.9);
- box-shadow: 0 0 12px rgba(74, 222, 128, 0.4);
- animation-delay: 0.9s;
-}
-
-.root :global(.loading-thermals) {
- display: flex;
- align-items: end;
- gap: 8px;
- height: 28px;
-}
-
-.root :global(.loading-thermal) {
- width: 10px;
- border-radius: 999px;
- background: linear-gradient(
- 180deg,
- rgba(251, 191, 36, 0.15) 0%,
- rgba(251, 146, 60, 0.8) 55%,
- rgba(239, 68, 68, 0.9) 100%
- );
- box-shadow: 0 0 18px rgba(251, 146, 60, 0.18);
- animation: thermal-rise 1.5s ease-in-out infinite;
- transform-origin: center bottom;
-}
-
-.root :global(.loading-thermal-1) {
- height: 11px;
- animation-delay: 0s;
-}
-
-.root :global(.loading-thermal-2) {
- height: 22px;
- animation-delay: 0.2s;
-}
-
-.root :global(.loading-thermal-3) {
- height: 16px;
- animation-delay: 0.42s;
-}
-
-.root :global(.loading-thermal-4) {
- height: 25px;
- animation-delay: 0.68s;
-}
-
-.root :global(.loading-drizzle) {
- position: relative;
- width: 220px;
- height: 26px;
- overflow: hidden;
- opacity: 0.88;
-}
-
-.root :global(.loading-drizzle-drop) {
- position: absolute;
- top: -2px;
- width: 2px;
- height: 16px;
- border-radius: 999px;
- background: linear-gradient(
- 180deg,
- rgba(125, 211, 252, 0) 0%,
- rgba(125, 211, 252, 0.9) 100%
- );
- box-shadow: 0 0 8px rgba(56, 189, 248, 0.12);
- animation: drizzle-fall 1.35s linear infinite;
-}
-
-.root :global(.loading-drizzle-drop-1) {
- left: 38px;
- animation-delay: -0.1s;
-}
-
-.root :global(.loading-drizzle-drop-2) {
- left: 76px;
- height: 18px;
- animation-delay: -0.52s;
-}
-
-.root :global(.loading-drizzle-drop-3) {
- left: 112px;
- height: 14px;
- animation-delay: -0.92s;
-}
-
-.root :global(.loading-drizzle-drop-4) {
- left: 156px;
- height: 17px;
- animation-delay: -0.38s;
-}
-
-.root :global(.loading-drizzle-drop-5) {
- left: 192px;
- height: 13px;
- animation-delay: -0.74s;
-}
-
-.root :global(.loading-overlay.hidden) {
- display: none;
-}
-
-.root :global(.loading-spinner) {
- width: 48px;
- height: 48px;
- border: 2px solid rgba(34, 211, 238, 0.1);
- border-top-color: var(--accent-cyan);
- border-radius: 50%;
- animation: loading-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
- box-shadow: 0 0 15px rgba(34, 211, 238, 0.1);
-}
-
-@keyframes loading-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-@keyframes city-loading-pulse {
- 0% {
- transform: scale(0.92);
- box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.44);
- }
- 70% {
- transform: scale(1);
- box-shadow: 0 0 0 10px rgba(34, 211, 238, 0);
- }
- 100% {
- transform: scale(0.92);
- box-shadow: 0 0 0 0 rgba(34, 211, 238, 0);
- }
-}
-
-@keyframes radar-sweep {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-@keyframes radar-blip {
- 0%,
- 100% {
- opacity: 0.2;
- transform: scale(0.72);
- }
- 40% {
- opacity: 1;
- transform: scale(1);
- }
- 60% {
- opacity: 0.9;
- transform: scale(1.18);
- }
-}
-
-@keyframes wind-shift {
- from {
- transform: translateX(0);
- opacity: 0;
- }
- 18% {
- opacity: 0.9;
- }
- 82% {
- opacity: 0.78;
- }
- to {
- transform: translateX(145%);
- opacity: 0;
- }
-}
-
-@keyframes thermal-rise {
- 0%,
- 100% {
- transform: scaleY(0.78);
- opacity: 0.72;
- }
- 50% {
- transform: scaleY(1.14);
- opacity: 1;
- }
-}
-
-@keyframes cloud-drift {
- 0%,
- 100% {
- transform: translateX(0);
- opacity: 0.7;
- }
- 50% {
- transform: translateX(12px);
- opacity: 0.95;
- }
-}
-
-@keyframes drizzle-fall {
- 0% {
- transform: translateY(-3px);
- opacity: 0;
- }
- 20% {
- opacity: 0.9;
- }
- 100% {
- transform: translateY(18px);
- opacity: 0;
- }
-}
-
-/* ── Nearby Stations (Premium Glassmorphism) ── */
-.root :global(.nearby-marker-premium) {
- display: flex;
- align-items: center;
- gap: 10px;
- background: rgba(10, 14, 26, 0.75);
- backdrop-filter: blur(12px);
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 999px; /* Pill shape */
- padding: 5px 14px 5px 10px;
- box-shadow:
- 0 8px 32px rgba(0, 0, 0, 0.4),
- inset 0 1px 1px rgba(255, 255, 255, 0.05);
- pointer-events: none;
- white-space: nowrap;
-}
-
-.root :global(.nearby-marker-shell) {
- display: inline-block;
- will-change: transform;
-}
-
-@keyframes nearby-fade-in {
- from {
- opacity: 0;
- transform: scale(0.9) translateY(4px);
- }
- to {
- opacity: 1;
- transform: scale(1) translateY(0);
- }
-}
-
-.root :global(.nearby-pulse) {
- position: relative;
- width: 12px;
- height: 12px;
- flex-shrink: 0;
-}
-
-.root :global(.pulse-core) {
- position: absolute;
- top: 50%;
- left: 50%;
- width: 6px;
- height: 6px;
- background: var(--accent-cyan);
- border-radius: 50%;
- transform: translate(-50%, -50%);
- box-shadow: 0 0 8px var(--accent-cyan);
-}
-
-.root :global(.pulse-ring) {
- position: absolute;
- top: 50%;
- left: 50%;
- width: 100%;
- height: 100%;
- border: 1.5px solid var(--accent-cyan);
- border-radius: 50%;
- transform: translate(-50%, -50%);
- opacity: 0.3;
- box-shadow: 0 0 10px rgba(34, 211, 238, 0.18);
-}
-
-.root :global(.nearby-content) {
- display: flex;
- flex-direction: column;
- line-height: 1.1;
-}
-
-.root :global(.nearby-label) {
- font-size: 9px;
- font-weight: 700;
- text-transform: uppercase;
- color: var(--text-muted);
- letter-spacing: 0.05em;
-}
-
-.root :global(.nearby-stats) {
- display: flex;
- align-items: baseline;
- gap: 2px;
-}
-
-.root :global(.nearby-temp-val) {
- font-size: 13px;
- font-weight: 800;
- color: var(--text-primary);
- text-shadow: 0 0 12px rgba(255, 255, 255, 0.1);
-}
-
-.root :global(.nearby-temp-unit) {
- font-size: 10px;
- color: var(--text-muted);
-}
-
-.root :global(.nearby-time) {
- display: flex;
- align-items: center;
- gap: 4px;
- margin-top: 3px;
- font-size: 11px;
- font-weight: 700;
- color: rgba(203, 213, 225, 0.95);
- white-space: nowrap;
-}
-
-.root :global(.nearby-time span + span)::before {
- content: "·";
- margin-right: 4px;
- color: rgba(148, 163, 184, 0.55);
-}
-
-.root :global(.nearby-time.is-stale) {
- color: #fbbf24;
-}
-
-.root :global(.nearby-wind) {
- display: flex;
- align-items: center;
- gap: 4px;
- padding-left: 10px;
- margin-left: 2px;
- border-left: 1px solid rgba(255, 255, 255, 0.1);
-}
-
-.root :global(.nearby-wind .wind-arrow) {
- font-size: 12px;
- color: var(--accent-cyan);
- text-shadow: 0 0 8px rgba(34, 211, 238, 0.4);
-}
-
-.root :global(.nearby-wind .wind-val) {
- font-size: 11px;
- font-weight: 600;
- color: var(--text-secondary);
-}
-
-/* ── Trend badge ── */
-.root :global(.trend-badge) {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 3px 10px;
- border-radius: 6px;
- font-size: 11px;
- font-weight: 600;
-}
-.root :global(.trend-badge.rising) {
- background: rgba(34, 197, 94, 0.12);
- color: var(--accent-green);
- border: 1px solid rgba(34, 197, 94, 0.25);
-}
-.root :global(.trend-badge.falling) {
- background: rgba(248, 113, 113, 0.12);
- color: var(--accent-red);
- border: 1px solid rgba(248, 113, 113, 0.25);
-}
-.root :global(.trend-badge.stagnant) {
- background: rgba(251, 191, 36, 0.12);
- color: var(--accent-yellow);
- border: 1px solid rgba(251, 191, 36, 0.25);
-}
-.root :global(.trend-badge.mixed) {
- background: rgba(167, 139, 250, 0.12);
- color: var(--accent-purple);
- border: 1px solid rgba(167, 139, 250, 0.25);
-}
-
-.root :global(.dead-market) {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 3px 10px;
- border-radius: 6px;
- font-size: 11px;
- font-weight: 700;
- background: rgba(248, 113, 113, 0.15);
- color: var(--accent-red);
- border: 1px solid rgba(248, 113, 113, 0.3);
- animation: deadPulse 2s ease-in-out infinite;
-}
-@keyframes deadPulse {
- 0%,
- 100% {
- opacity: 1;
- }
- 50% {
- opacity: 0.6;
- }
-}
-
-/* ── Responsive ── */
-@media (max-width: 1100px) {
- .root {
- --panel-width: 460px;
- }
- .root :global(.hero-value) {
- font-size: 52px;
- }
-}
-
-@media (max-width: 768px) {
- .root :global(.header) {
- height: auto;
- min-height: var(--header-height);
- align-items: flex-start;
- gap: 10px;
- padding: 10px 12px;
- }
-
- .root :global(.brand) {
- align-items: flex-start;
- gap: 8px;
- min-width: 0;
- }
-
- .root :global(.header-right) {
- flex: 1 1 auto;
- justify-content: flex-end;
- flex-wrap: wrap;
- gap: 8px;
- min-width: 0;
- }
-
- .root :global(.lang-switch) {
- order: 1;
- }
-
- .root :global(.account-btn),
- .root :global(.info-btn) {
- padding: 6px 10px;
- font-size: 12px;
- }
-
- .root :global(.live-badge) {
- order: 4;
- padding: 4px 10px;
- white-space: nowrap;
- }
-
- .root :global(.refresh-btn) {
- order: 5;
- }
-
- .root :global(.city-list) {
- display: flex;
- top: auto;
- left: 8px;
- right: 8px;
- bottom: 8px;
- width: auto;
- max-height: min(36vh, 320px);
- border-radius: 14px;
- z-index: 920;
- }
- .root :global(.detail-panel) {
- width: 100%;
- }
- .root {
- --panel-width: 100%;
- }
-
- .root :global(.city-list-header) {
- padding: 12px 14px;
- font-size: 14px;
- }
-
- .root :global(.city-list-items) {
- padding: 6px;
- }
-
- .root :global(.city-group-header) {
- padding: 8px 9px;
- }
-
- .root :global(.city-item) {
- padding: 8px 9px;
- }
-
- .root :global(.city-item .city-name-text),
- .root :global(.city-item .city-temp) {
- font-size: 12px;
- }
-
- .root :global(.city-item-info) {
- padding-left: 18px;
- flex-wrap: wrap;
- row-gap: 4px;
- }
-
- .root :global(.panel-header) {
- padding: 16px 16px 14px;
- }
-}
-
-@media (max-width: 600px) {
- .root :global(.header) {
- flex-direction: column;
- align-items: stretch;
- gap: 8px;
- padding: 8px 10px;
- }
-
- .root :global(.brand) {
- justify-content: space-between;
- width: 100%;
- }
-
- .root :global(.subtitle) {
- display: none;
- }
- .root :global(.brand h1) {
- font-size: 16px;
- }
-
- .root :global(.header-right) {
- width: 100%;
- justify-content: flex-start;
- gap: 6px;
- }
-
- .root :global(.lang-switch) {
- margin-right: auto;
- }
-
- .root :global(.account-btn),
- .root :global(.info-btn) {
- min-height: 34px;
- padding: 6px 9px;
- font-size: 11px;
- }
-
- .root :global(.account-btn span),
- .root :global(.info-btn span) {
- max-width: 88px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .root :global(.live-badge) {
- padding: 4px 8px;
- font-size: 10px;
- letter-spacing: 0.6px;
- }
-
- .root :global(.refresh-btn) {
- width: 34px;
- height: 34px;
- font-size: 16px;
- }
-
- .root :global(.hero-value) {
- font-size: 42px;
- }
- .root :global(.hero-details) {
- grid-template-columns: repeat(3, 1fr);
- gap: 4px;
- }
- .root :global(.hero-item .value) {
- font-size: 14px;
- }
-
- .root :global(.city-list) {
- left: 6px;
- right: 6px;
- bottom: 6px;
- max-height: min(40vh, 300px);
- }
-
- .root :global(.city-list-header) {
- padding: 10px 12px;
- }
-
- .root :global(.city-group-title) {
- font-size: 11px;
- }
-
- .root :global(.city-item-main) {
- gap: 6px;
- }
-
- .root :global(.city-item-info) {
- font-size: 9px;
- }
-
- .root :global(.panel-header) {
- padding: 14px 14px 12px;
- }
-
- .root :global(.city-marker:hover) {
- transform: none;
- }
-
- .root :global(.marker-bubble) {
- min-width: 48px;
- min-height: 32px;
- padding: 6px 10px;
- }
-
- .root :global(.marker-name) {
- font-size: 11px;
- }
-
- .root :global(.prob-price-grid) {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .root :global(.prob-price-head) {
- align-items: flex-start;
- flex-direction: column;
- }
-}
-
-/* ── History Modal ── */
-.root :global(.history-btn) {
- background: rgba(34, 211, 238, 0.1);
- color: var(--accent-cyan);
- border: 1px solid rgba(34, 211, 238, 0.3);
- padding: 4px 10px;
- border-radius: 6px;
- font-size: 11px;
- font-weight: 600;
- cursor: pointer;
- transition: all 0.2s;
- margin-left: 8px;
-}
-
-.root :global(.scan-select) {
- border: none;
- border-radius: 8px;
- background: linear-gradient(135deg, #4DA3FF, #00b383);
- color: #000;
- font-weight: 700;
- cursor: pointer;
- transition: all 0.2s;
- box-shadow: 0 4px 12px rgba(0, 224, 164, 0.2);
-}
-
-.root :global(.scan-cta-button:hover:not(:disabled)) {
- transform: translateY(-1px);
- box-shadow: 0 6px 20px rgba(0, 224, 164, 0.4);
- filter: brightness(1.1);
-}
-
-.root :global(.scan-cta-button:active:not(:disabled)) {
- transform: translateY(1px);
-}
-
-.root :global(.history-btn:hover) {
- background: rgba(34, 211, 238, 0.2);
- border-color: var(--accent-cyan);
-}
-.root :global(.history-btn:disabled) {
- opacity: 0.45;
- cursor: not-allowed;
-}
-
-.root :global(.modal-overlay) {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(2, 6, 23, 0.75);
- backdrop-filter: blur(12px);
- z-index: 10000;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 16px;
-}
-
-.root :global(.modal-content) {
- background: #16213A;
- border: 1px solid var(--border-subtle);
- border-radius: 16px;
- width: 100%;
- max-width: 700px;
- max-height: calc(100vh - 48px);
- box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.root :global(.modal-content.history-modal) {
- width: min(96vw, 1180px);
- max-width: 1180px;
- max-height: calc(100vh - 32px);
-}
-
-.root :global(.modal-header) {
- padding: 16px 20px;
- border-bottom: 1px solid var(--border-subtle);
- display: flex;
- justify-content: space-between;
- align-items: center;
-}
-.root :global(.modal-header h2) {
- font-size: 16px;
- font-weight: 600;
- color: var(--text-primary);
- margin: 0;
- min-width: 0;
-}
-.root :global(.future-modal-title-with-actions) {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
-.root :global(.future-refresh-btn) {
- background: transparent;
- border: none;
- cursor: pointer;
- color: var(--text-muted);
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 6px;
- border-radius: 6px;
- transition: all 0.2s ease;
-}
-
-.root :global(.future-refresh-btn:hover) {
- color: var(--accent-cyan);
- background: rgba(34, 211, 238, 0.1);
-}
-
-.root :global(.future-refresh-btn.spinning svg) {
- animation: spin 1s linear infinite;
- color: var(--accent-cyan);
-}
-
-@keyframes spin {
- 100% {
- transform: rotate(360deg);
- }
-}
-
-.root :global(.modal-close) {
- background: none;
- border: none;
- font-size: 20px;
- color: var(--text-muted);
- cursor: pointer;
- transition: color 0.2s;
-}
-.root :global(.modal-close:hover) {
- color: var(--accent-red);
-}
-
-.root :global(.modal-body) {
- padding: 20px;
- overflow-y: auto;
-}
-
-/* Keep scroll behavior but hide visual scrollbar in today's/future analysis modal */
-.root :global(.modal-content.large.future-modal .modal-body) {
- -ms-overflow-style: none;
- scrollbar-width: none;
-}
-
-.root
- :global(.modal-content.large.future-modal .modal-body::-webkit-scrollbar) {
- width: 0;
- height: 0;
-}
-
-.root :global(.history-modal .modal-body) {
- padding: 24px 28px 28px;
-}
-
-.root :global(.history-stats) {
- display: flex;
- gap: 12px;
- margin-bottom: 20px;
- flex-wrap: wrap;
-}
-.root :global(.h-stat-card) {
- flex: 1;
- min-width: 110px;
- background: rgba(255, 255, 255, 0.03);
- border: 1px solid var(--border-subtle);
- border-radius: 8px;
- padding: 12px;
- text-align: center;
-}
-.root :global(.h-stat-card .label) {
- display: block;
- font-size: 11px;
- color: var(--text-muted);
- margin-bottom: 4px;
-}
-.root :global(.h-stat-card .val) {
- display: block;
- font-size: 18px;
- font-weight: 700;
- color: var(--text-primary);
-}
-
-.root :global(.h-stat-card .h-stat-note) {
- display: block;
- margin-top: 6px;
- font-size: 11px;
- color: var(--text-muted);
- line-height: 1.35;
-}
-
-.root :global(.history-chart-wrapper) {
- position: relative;
- height: 300px;
- width: 100%;
-}
-
-.root :global(.history-modal .history-stats) {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 16px;
- margin-bottom: 24px;
-}
-
-.root :global(.history-modal .h-stat-card) {
- min-width: 0;
- padding: 16px 18px;
- border-radius: 12px;
- background: linear-gradient(
- 180deg,
- rgba(255, 255, 255, 0.035) 0%,
- rgba(255, 255, 255, 0.02) 100%
- );
-}
-
-.root :global(.history-modal .h-stat-card .label) {
- font-size: 12px;
- margin-bottom: 8px;
-}
-
-.root :global(.history-modal .h-stat-card .val) {
- font-size: 32px;
- line-height: 1.1;
- letter-spacing: -0.02em;
-}
-
-.root :global(.history-modal .history-chart-wrapper) {
- height: 420px;
- border: 1px solid var(--border-subtle);
- border-radius: 12px;
- background: rgba(255, 255, 255, 0.02);
- padding: 12px 14px 8px;
-}
-
-.root :global(.history-modal .history-chart-wrapper canvas) {
- width: 100% !important;
- height: 100% !important;
-}
-
-.root :global(.history-modal .history-modal-loading) {
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 420px;
-}
-
-.root :global(.history-modal .history-fetch-loading) {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 18px;
- width: min(100%, 420px);
- padding: 24px 26px;
- border-radius: 24px;
- border: 1px solid rgba(96, 165, 250, 0.16);
- background: linear-gradient(
- 180deg,
- rgba(10, 18, 36, 0.72) 0%,
- rgba(8, 14, 30, 0.5) 100%
- );
- box-shadow:
- 0 18px 48px rgba(0, 0, 0, 0.28),
- inset 0 1px 0 rgba(255, 255, 255, 0.03);
-}
-
-.root :global(.history-modal .history-fetch-scan) {
- position: relative;
- width: 84px;
- height: 84px;
- border-radius: 50%;
- background: radial-gradient(
- circle,
- rgba(14, 165, 233, 0.08) 0%,
- rgba(14, 165, 233, 0.02) 62%,
- transparent 100%
- );
-}
-
-.root :global(.history-modal .history-fetch-ring) {
- position: absolute;
- inset: 0;
- border-radius: 50%;
- border: 1px solid rgba(56, 189, 248, 0.16);
-}
-
-.root :global(.history-modal .history-fetch-ring-1) {
- transform: scale(0.7);
-}
-
-.root :global(.history-modal .history-fetch-ring-2) {
- transform: scale(0.92);
-}
-
-.root :global(.history-modal .history-fetch-sweep) {
- position: absolute;
- inset: 5px;
- border-radius: 50%;
- background: conic-gradient(
- from 120deg,
- rgba(34, 211, 238, 0) 0deg,
- rgba(34, 211, 238, 0.04) 220deg,
- rgba(56, 189, 248, 0.78) 300deg,
- rgba(253, 224, 71, 0.32) 332deg,
- rgba(34, 211, 238, 0) 360deg
- );
- animation: history-fetch-spin 2.2s linear infinite;
-}
-
-.root :global(.history-modal .history-fetch-core) {
- position: absolute;
- top: 50%;
- left: 50%;
- width: 12px;
- height: 12px;
- border-radius: 50%;
- transform: translate(-50%, -50%);
- background: rgba(103, 232, 249, 0.95);
- box-shadow: 0 0 18px rgba(34, 211, 238, 0.42);
-}
-
-.root :global(.history-modal .history-fetch-bars) {
- display: flex;
- align-items: end;
- gap: 7px;
- height: 22px;
-}
-
-.root :global(.history-modal .history-fetch-bar) {
- width: 8px;
- border-radius: 999px;
- background: linear-gradient(
- 180deg,
- rgba(250, 204, 21, 0.18) 0%,
- rgba(251, 146, 60, 0.9) 100%
- );
- animation: history-fetch-pulse 1.6s ease-in-out infinite;
-}
-
-.root :global(.history-modal .history-fetch-bar-1) {
- height: 10px;
-}
-
-.root :global(.history-modal .history-fetch-bar-2) {
- height: 18px;
- animation-delay: 0.18s;
-}
-
-.root :global(.history-modal .history-fetch-bar-3) {
- height: 14px;
- animation-delay: 0.34s;
-}
-
-.root :global(.history-modal .history-fetch-bar-4) {
- height: 20px;
- animation-delay: 0.5s;
-}
-
-.root :global(.history-modal .history-fetch-lines) {
- position: relative;
- width: 180px;
- height: 22px;
- overflow: hidden;
-}
-
-.root :global(.history-modal .history-fetch-line) {
- position: absolute;
- left: -26%;
- height: 1px;
- border-radius: 999px;
- background: linear-gradient(
- 90deg,
- rgba(34, 211, 238, 0) 0%,
- rgba(125, 211, 252, 0.72) 48%,
- rgba(34, 211, 238, 0) 100%
- );
- animation: history-fetch-flow 2.4s linear infinite;
-}
-
-.root :global(.history-modal .history-fetch-line-1) {
- top: 4px;
- width: 122px;
-}
-
-.root :global(.history-modal .history-fetch-line-2) {
- top: 11px;
- width: 168px;
- animation-delay: -0.8s;
-}
-
-.root :global(.history-modal .history-fetch-line-3) {
- top: 18px;
- width: 138px;
- animation-delay: -1.4s;
-}
-
-.root :global(.history-modal .history-fetch-copy) {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 8px;
- text-align: center;
-}
-
-.root :global(.history-modal .history-fetch-copy strong) {
- font-size: 12px;
- font-weight: 700;
- letter-spacing: 0.16em;
- text-transform: uppercase;
- color: rgba(103, 232, 249, 0.94);
-}
-
-.root :global(.history-modal .history-fetch-copy span) {
- max-width: 300px;
- font-size: 12px;
- line-height: 1.5;
- color: rgba(203, 213, 225, 0.82);
-}
-
-@keyframes history-fetch-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-@keyframes history-fetch-pulse {
- 0%,
- 100% {
- transform: scaleY(0.86);
- opacity: 0.8;
- }
- 50% {
- transform: scaleY(1.08);
- opacity: 1;
- }
-}
-
-@keyframes history-fetch-flow {
- from {
- transform: translateX(0);
- }
- to {
- transform: translateX(150%);
- }
-}
-
-.root :global(.history-modal .history-peak-reference) {
- margin-top: 18px;
- padding: 14px 16px;
- border: 1px solid rgba(148, 163, 184, 0.14);
- border-radius: 16px;
- background: rgba(15, 23, 42, 0.42);
-}
-
-.root :global(.history-modal .history-model-reference) {
- margin-top: 18px;
- padding: 14px 16px;
- border: 1px solid rgba(34, 211, 238, 0.18);
- border-radius: 16px;
- background: rgba(8, 47, 73, 0.2);
-}
-
-.root :global(.history-modal .history-peak-reference-title) {
- color: var(--text-primary);
- font-size: 14px;
- font-weight: 700;
- margin-bottom: 10px;
-}
-
-.root :global(.history-modal .history-model-reference-scroll) {
- display: grid;
- gap: 8px;
- max-height: 360px;
- overflow-y: auto;
- padding-right: 6px;
- scrollbar-width: thin;
- scrollbar-color: rgba(34, 211, 238, 0.5) rgba(15, 23, 42, 0.32);
-}
-
-.root :global(.history-modal .history-peak-reference-scroll) {
- display: grid;
- gap: 8px;
- max-height: 360px;
- overflow-y: auto;
- padding-right: 6px;
- scrollbar-width: thin;
- scrollbar-color: rgba(96, 165, 250, 0.5) rgba(15, 23, 42, 0.32);
-}
-
-.root
- :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar) {
- width: 8px;
-}
-
-.root
- :global(
- .history-modal .history-peak-reference-scroll::-webkit-scrollbar-track
- ) {
- background: rgba(15, 23, 42, 0.32);
- border-radius: 999px;
-}
-
-.root
- :global(
- .history-modal .history-peak-reference-scroll::-webkit-scrollbar-thumb
- ) {
- background: linear-gradient(
- 180deg,
- rgba(56, 189, 248, 0.72) 0%,
- rgba(96, 165, 250, 0.52) 100%
- );
- border-radius: 999px;
-}
-
-.root :global(.history-modal .history-peak-reference-row) {
- display: grid;
- grid-template-columns: minmax(72px, 88px) 1fr;
- gap: 10px;
- padding: 10px 12px;
- border-radius: 12px;
- background: rgba(255, 255, 255, 0.03);
-}
-
-.root :global(.history-modal .history-model-reference-row) {
- display: grid;
- grid-template-columns: minmax(72px, 88px) 1fr;
- gap: 10px;
- padding: 10px 12px;
- border-radius: 12px;
- background: rgba(255, 255, 255, 0.03);
-}
-
-.root :global(.history-modal .history-peak-reference-date) {
- color: var(--text-secondary);
- font-size: 12px;
- font-weight: 700;
-}
-
-.root :global(.history-modal .history-model-reference-body) {
- display: grid;
- gap: 8px;
- min-width: 0;
-}
-
-.root :global(.history-modal .history-model-reference-summary) {
- display: flex;
- flex-wrap: wrap;
- gap: 10px 14px;
- color: var(--text-secondary);
- font-size: 12px;
-}
-
-.root :global(.history-modal .history-model-reference-summary strong) {
- color: var(--text-primary);
-}
-
-.root :global(.history-modal .history-model-reference-models) {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 6px;
-}
-
-.root :global(.history-modal .history-model-reference-model) {
- display: grid;
- gap: 3px;
- min-width: 0;
- padding: 8px 10px;
- border: 1px solid rgba(148, 163, 184, 0.12);
- border-radius: 8px;
- background: rgba(15, 23, 42, 0.38);
- color: var(--text-secondary);
- font-size: 11px;
-}
-
-.root :global(.history-modal .history-model-name) {
- overflow: hidden;
- color: var(--accent-cyan);
- font-weight: 700;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.root :global(.history-modal .history-model-error) {
- color: var(--text-muted);
-}
-
-.root :global(.history-modal .history-peak-reference-meta) {
- color: var(--text-secondary);
- font-size: 12px;
- line-height: 1.7;
-}
-
-@media (max-width: 980px) {
- .root :global(.history-modal .modal-body) {
- padding: 18px;
- }
-
- .root :global(.history-modal .history-stats) {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 12px;
- }
-
- .root :global(.history-modal .history-chart-wrapper) {
- height: 360px;
- }
-
- .root :global(.history-modal .history-modal-loading) {
- min-height: 340px;
- }
-
- .root :global(.history-modal .history-fetch-loading) {
- width: min(100%, 360px);
- }
-
- .root :global(.history-modal .history-peak-reference-scroll) {
- max-height: 320px;
- }
-
- .root :global(.history-modal .history-model-reference-scroll) {
- max-height: 320px;
- }
-
- .root :global(.history-modal .history-model-reference-models) {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-}
-
-@media (max-width: 640px) {
- .root :global(.modal-content) {
- border-radius: 14px;
- max-height: calc(100vh - 16px);
- }
-
- .root :global(.modal-content.history-modal),
- .root :global(.modal-content.large.future-modal) {
- width: min(100vw - 12px, 100%);
- max-width: none;
- }
-
- .root :global(.modal-header) {
- padding: 14px 14px 12px;
- align-items: flex-start;
- gap: 12px;
- }
-
- .root :global(.modal-header h2) {
- font-size: 15px;
- line-height: 1.35;
- }
-
- .root :global(.modal-body) {
- padding: 14px;
- }
-
- .root :global(.history-modal .history-stats) {
- grid-template-columns: 1fr;
- }
-
- .root :global(.history-modal .h-stat-card .val) {
- font-size: 28px;
- }
-
- .root :global(.history-modal .history-chart-wrapper) {
- height: 300px;
- padding: 8px 10px 6px;
- }
-
- .root :global(.history-modal .history-modal-loading) {
- min-height: 280px;
- }
-
- .root :global(.history-modal .history-fetch-loading) {
- width: 100%;
- padding: 18px 16px;
- }
-
- .root :global(.history-modal .history-fetch-lines) {
- width: 150px;
- }
-
- .root :global(.history-modal .history-fetch-scan) {
- width: 72px;
- height: 72px;
- }
-
- .root :global(.history-modal .history-peak-reference) {
- padding: 12px;
- }
-
- .root :global(.history-modal .history-model-reference) {
- padding: 12px;
- }
-
- .root :global(.history-modal .history-peak-reference-scroll) {
- max-height: 260px;
- padding-right: 2px;
- }
-
- .root :global(.history-modal .history-model-reference-scroll) {
- max-height: 260px;
- padding-right: 2px;
- }
-
- .root :global(.history-modal .history-peak-reference-row) {
- grid-template-columns: 1fr;
- gap: 6px;
- }
-
- .root :global(.history-modal .history-model-reference-row) {
- grid-template-columns: 1fr;
- gap: 6px;
- }
-
- .root :global(.history-modal .history-model-reference-models) {
- grid-template-columns: 1fr;
- }
-}
-
-/* ── Info Button ── */
-.root :global(.account-btn) {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- padding: 6px 12px;
- border-radius: 8px;
- border: 1px solid rgba(34, 211, 238, 0.34);
- background: rgba(34, 211, 238, 0.08);
- color: var(--accent-cyan);
- font-size: 12px;
- font-weight: 700;
- letter-spacing: 0.02em;
- text-decoration: none;
- transition: var(--transition);
-}
-.root :global(.account-btn:hover) {
- background: rgba(34, 211, 238, 0.16);
- border-color: rgba(34, 211, 238, 0.62);
- color: #f8fbff;
- transform: translateY(-1px);
-}
-
-.root :global(.account-renew-badge) {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 6px 10px;
- border-radius: 999px;
- border: 1px solid rgba(245, 158, 11, 0.34);
- background: rgba(245, 158, 11, 0.1);
- color: #fbbf24;
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.01em;
- text-decoration: none;
- transition: var(--transition);
-}
-
-.root :global(.account-renew-badge:hover) {
- background: rgba(245, 158, 11, 0.18);
- border-color: rgba(245, 158, 11, 0.6);
- color: #fff6d5;
- transform: translateY(-1px);
-}
-
-.root :global(.account-renew-badge.expired) {
- border-color: rgba(239, 68, 68, 0.34);
- background: rgba(239, 68, 68, 0.12);
- color: #fca5a5;
-}
-
-.root :global(.trial-promo-badge) {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 6px 12px;
- border-radius: 999px;
- border: 1px solid rgba(34, 211, 238, 0.28);
- background: rgba(34, 211, 238, 0.1);
- color: #a5f3fc;
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.01em;
- text-decoration: none;
- transition: var(--transition);
-}
-
-.root :global(.trial-promo-badge:hover) {
- background: rgba(34, 211, 238, 0.16);
- border-color: rgba(34, 211, 238, 0.45);
- color: #ecfeff;
- transform: translateY(-1px);
-}
-
-.root :global(.info-btn) {
- background: rgba(99, 102, 241, 0.1);
- border: 1px solid rgba(99, 102, 241, 0.3);
- color: var(--accent-blue);
- padding: 6px 14px;
- border-radius: 8px;
- font-size: 13px;
- font-weight: 600;
- cursor: pointer;
- transition: var(--transition);
- display: flex;
- align-items: center;
- gap: 6px;
-}
-.root :global(.info-btn:hover) {
- background: rgba(99, 102, 241, 0.2);
- border-color: var(--accent-blue);
- transform: translateY(-1px);
-}
-
-.root :global(.info-btn.active) {
- background: rgba(34, 211, 238, 0.14);
- border-color: rgba(34, 211, 238, 0.42);
- color: #e0fbff;
-}
-
-/* ── Guide Modal Customizations ── */
-.root :global(.modal-content.large) {
- max-width: 900px;
-}
-
-.root :global(.modal-content.large.future-modal) {
- width: min(96vw, 1720px);
- max-width: 1720px;
-}
-
-.root :global(.future-modal-body) {
- position: relative;
- display: flex;
- flex-direction: column;
- gap: 14px;
- padding-right: 14px;
-}
-
-.root :global(.future-modal-body-refreshing > :not(.future-v2-refresh-lock)) {
- opacity: 0.18;
- filter: blur(1px);
- pointer-events: none;
- user-select: none;
-}
-
-.root :global(.future-v2-refresh-lock) {
- position: sticky;
- top: 0;
- z-index: 20;
- display: flex;
- align-items: flex-start;
- gap: 12px;
- padding: 14px 16px;
- border: 1px solid rgba(34, 211, 238, 0.34);
- border-radius: 8px;
- background: rgba(8, 13, 24, 0.96);
- box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34);
-}
-
-.root :global(.future-v2-refresh-lock strong) {
- display: block;
- color: #e0faff;
- font-size: 14px;
- font-weight: 900;
-}
-
-.root :global(.future-v2-refresh-lock p) {
- margin: 5px 0 0;
- color: var(--text-secondary);
- font-size: 12px;
- line-height: 1.55;
-}
-
-.root :global(.future-v2-refresh-spinner) {
- width: 18px;
- height: 18px;
- flex: 0 0 auto;
- margin-top: 1px;
- border: 2px solid rgba(34, 211, 238, 0.18);
- border-top-color: #22d3ee;
- border-radius: 999px;
- animation: spin 0.8s linear infinite;
-}
-
-.root :global(.future-v2-sync-strip) {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- gap: 10px;
-}
-
-.root :global(.future-v2-sync-strip-compact) {
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
- gap: 8px;
-}
-
-.root :global(.future-v2-sync-strip-compact .future-v2-sync-chip) {
- padding: 8px 10px;
- border-radius: 8px;
- background: rgba(15, 23, 42, 0.62);
-}
-
-.root :global(.future-v2-sync-strip-compact .future-v2-sync-copy span) {
- display: none;
-}
-
-.root :global(.future-v2-meteorology-brief) {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(320px, 0.56fr);
- gap: 14px;
- padding: 16px;
- border: 1px solid rgba(34, 211, 238, 0.22);
- border-radius: 8px;
- background: linear-gradient(
- 180deg,
- rgba(8, 47, 73, 0.42),
- rgba(15, 23, 42, 0.78)
- );
-}
-
-.root :global(.future-v2-meteorology-copy) {
- display: grid;
- gap: 9px;
- min-width: 0;
-}
-
-.root :global(.future-v2-anchor-row) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- min-width: 0;
-}
-
-.root :global(.future-v2-anchor-source) {
- display: inline-flex;
- align-items: center;
- min-height: 24px;
- padding: 4px 8px;
- border: 1px solid rgba(34, 211, 238, 0.28);
- border-radius: 8px;
- background: rgba(8, 47, 73, 0.36);
- color: #6FB7FF;
- font-size: 11px;
- font-weight: 800;
- line-height: 1.25;
- white-space: nowrap;
-}
-
-.root :global(.future-v2-meteorology-copy h3) {
- margin: 0;
- color: #f8fafc;
- font-size: 18px;
- font-weight: 800;
- line-height: 1.35;
- letter-spacing: 0;
-}
-
-.root :global(.future-v2-anchor-rule) {
- margin: 0;
- max-width: 860px;
- color: var(--text-muted);
- font-size: 12px;
- line-height: 1.55;
-}
-
-.root :global(.future-v2-meteorology-meta) {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.root :global(.future-v2-meteorology-meta span) {
- min-height: 26px;
- display: inline-flex;
- align-items: center;
- padding: 5px 8px;
- border: 1px solid rgba(148, 163, 184, 0.18);
- border-radius: 8px;
- background: rgba(15, 23, 42, 0.62);
- color: #cbd5e1;
- font-size: 12px;
- line-height: 1.35;
-}
-
-.root :global(.future-v2-decision-rail) {
- display: grid;
- grid-template-columns: minmax(0, 0.88fr) minmax(0, 1.35fr);
- gap: 10px;
- min-width: 0;
-}
-
-.root :global(.future-v2-decision-anchor) {
- min-width: 0;
- padding: 12px;
- border: 1px solid rgba(96, 165, 250, 0.2);
- border-radius: 8px;
- background: rgba(2, 6, 23, 0.5);
-}
-
-.root :global(.future-v2-decision-anchor span),
-.root :global(.future-v2-decision-grid span) {
- display: block;
- color: var(--text-muted);
- font-size: 11px;
- line-height: 1.3;
-}
-
-.root :global(.future-v2-decision-anchor strong) {
- display: block;
- margin-top: 8px;
- color: #f8fafc;
- font-size: 14px;
- font-weight: 800;
- line-height: 1.35;
- letter-spacing: 0;
-}
-
-.root :global(.future-v2-decision-anchor small) {
- display: block;
- margin-top: 6px;
- color: #6FB7FF;
- font-size: 11px;
- font-weight: 700;
- line-height: 1.3;
-}
-
-.root :global(.future-v2-decision-grid) {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px;
- min-width: 0;
-}
-
-.root :global(.future-v2-decision-grid div) {
- min-width: 0;
- padding: 10px;
- border: 1px solid rgba(34, 211, 238, 0.18);
- border-radius: 8px;
- background: rgba(2, 6, 23, 0.5);
-}
-
-.root :global(.future-v2-decision-grid strong) {
- display: block;
- margin-top: 7px;
- color: #6FB7FF;
- font-size: 18px;
- font-weight: 800;
- line-height: 1;
- letter-spacing: 0;
-}
-
-.root :global(.future-v2-meteorology-paths) {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 8px;
-}
-
-.root :global(.future-v2-meteorology-paths div) {
- min-width: 0;
- padding: 12px;
- border: 1px solid rgba(34, 211, 238, 0.18);
- border-radius: 8px;
- background: rgba(2, 6, 23, 0.5);
-}
-
-.root :global(.future-v2-meteorology-paths span) {
- display: block;
- color: var(--text-muted);
- font-size: 11px;
- line-height: 1.3;
-}
-
-.root :global(.future-v2-meteorology-paths strong) {
- display: block;
- margin-top: 8px;
- color: #6FB7FF;
- font-size: 22px;
- font-weight: 800;
- line-height: 1;
- letter-spacing: 0;
-}
-
-.root :global(.future-v2-sync-chip) {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- padding: 11px 12px;
- border-radius: 12px;
- border: 1px solid rgba(52, 211, 153, 0.16);
- background: rgba(16, 185, 129, 0.06);
-}
-
-.root :global(.future-v2-sync-chip.syncing) {
- border-color: rgba(34, 211, 238, 0.2);
- background: rgba(34, 211, 238, 0.07);
-}
-
-.root :global(.future-v2-sync-dot) {
- width: 9px;
- height: 9px;
- border-radius: 999px;
- background: #22C55E;
- margin-top: 5px;
- flex: 0 0 auto;
- box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.12);
-}
-
-.root :global(.future-v2-sync-chip.syncing .future-v2-sync-dot) {
- background: #22d3ee;
- box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.12);
- animation: pulseGlow 1.25s ease-in-out infinite;
-}
-
-.root :global(.future-v2-sync-copy) {
- display: grid;
- gap: 4px;
- min-width: 0;
-}
-
-.root :global(.future-v2-sync-copy strong) {
- color: var(--text-primary);
- font-size: 12px;
- font-weight: 700;
- line-height: 1.3;
-}
-
-.root :global(.future-v2-sync-copy span) {
- color: var(--text-muted);
- font-size: 11px;
- line-height: 1.45;
-}
-
-@keyframes pulseGlow {
- 0%,
- 100% {
- opacity: 0.9;
- transform: scale(1);
- }
-
- 50% {
- opacity: 1;
- transform: scale(1.08);
- }
-}
-
-.root :global(.future-v2-layout) {
- display: grid;
- grid-template-columns: 360px minmax(0, 1fr);
- gap: 14px;
- align-items: start;
-}
-
-.root :global(.future-v2-left) {
- display: grid;
- gap: 12px;
-}
-
-.root :global(.future-v2-right) {
- display: grid;
- gap: 14px;
-}
-
-.root :global(.future-v2-card) {
- background: rgba(255, 255, 255, 0.02);
- border: 1px solid var(--border-subtle);
- border-radius: 14px;
- padding: 14px;
-}
-
-.root :global(.future-v2-card-head) {
- display: grid;
- gap: 6px;
-}
-
-.root :global(.future-v2-card-kicker) {
- color: var(--text-muted);
- font-size: 11px;
- letter-spacing: 0.05em;
- text-transform: uppercase;
-}
-
-.root :global(.future-v2-focus-card) {
- background: linear-gradient(
- 180deg,
- rgba(34, 211, 238, 0.08) 0%,
- rgba(255, 255, 255, 0.02) 100%
- );
-}
-
-.root :global(.future-v2-support-card) {
- background: linear-gradient(
- 180deg,
- rgba(148, 163, 184, 0.08) 0%,
- rgba(255, 255, 255, 0.02) 100%
- );
-}
-
-.root :global(.future-v2-hero-card) {
- background: linear-gradient(
- 180deg,
- rgba(99, 102, 241, 0.12) 0%,
- rgba(255, 255, 255, 0.02) 100%
- );
-}
-
-.root :global(.future-v2-hero-title) {
- margin: 0;
- font-size: 18px;
- font-weight: 700;
- color: var(--text-primary);
- letter-spacing: -0.01em;
-}
-
-.root :global(.future-v2-hero-main) {
- display: flex;
- align-items: center;
- gap: 14px;
- margin-top: 14px;
-}
-
-.root :global(.future-v2-hero-temp) {
- font-size: 56px;
- font-weight: 800;
- letter-spacing: -0.04em;
- line-height: 1;
- color: #f8fafc;
- text-shadow: 0 10px 30px rgba(34, 211, 238, 0.14);
-}
-
-.root :global(.future-v2-hero-divider) {
- width: 1px;
- height: 56px;
- background: rgba(255, 255, 255, 0.12);
-}
-
-.root :global(.future-v2-hero-weather) {
- display: grid;
- gap: 4px;
- font-size: 15px;
- color: var(--text-secondary);
-}
-
-.root :global(.future-v2-hero-icon) {
- display: inline-flex;
- align-items: center;
- line-height: 1;
-}
-
-.root :global(.future-v2-hero-obs) {
- margin-top: 10px;
- color: var(--text-secondary);
- font-size: 26px;
- font-weight: 500;
- font-variant-numeric: tabular-nums;
-}
-
-.root :global(.future-v2-daylight) {
- margin-top: 14px;
- padding: 12px 12px 10px;
- border-radius: 12px;
- border: 1px solid rgba(245, 158, 11, 0.14);
- background: linear-gradient(
- 180deg,
- rgba(245, 158, 11, 0.08) 0%,
- rgba(34, 211, 238, 0.04) 100%
- );
-}
-
-.root :global(.future-v2-daylight-head) {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 10px;
- margin-bottom: 8px;
-}
-
-.root :global(.future-v2-daylight-head span) {
- color: var(--text-muted);
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 0.06em;
-}
-
-.root :global(.future-v2-daylight-head strong) {
- color: #fde68a;
- font-size: 12px;
- font-weight: 700;
-}
-
-.root :global(.future-v2-daylight-bar) {
- position: relative;
- height: 12px;
- border-radius: 999px;
- background: linear-gradient(
- 90deg,
- rgba(15, 23, 42, 0.85) 0%,
- rgba(56, 189, 248, 0.18) 15%,
- rgba(250, 204, 21, 0.24) 50%,
- rgba(56, 189, 248, 0.16) 85%,
- rgba(15, 23, 42, 0.85) 100%
- );
- overflow: hidden;
-}
-
-.root :global(.future-v2-daylight-bar::before) {
- content: "";
- position: absolute;
- inset: 0;
- width: var(--daylight-progress, 0%);
- background: linear-gradient(
- 90deg,
- rgba(34, 211, 238, 0.22) 0%,
- rgba(250, 204, 21, 0.4) 70%,
- rgba(251, 146, 60, 0.5) 100%
- );
-}
-
-.root :global(.future-v2-daylight-bar::after) {
- content: "";
- position: absolute;
- top: 50%;
- left: var(--daylight-progress, 0%);
- width: 14px;
- height: 14px;
- border-radius: 50%;
- background: radial-gradient(circle, #fff7cc 0%, #facc15 65%, #fb923c 100%);
- box-shadow:
- 0 0 0 3px rgba(250, 204, 21, 0.16),
- 0 0 18px rgba(250, 204, 21, 0.22);
- transform: translate(-50%, -50%);
-}
-
-.root :global(.future-v2-daylight-times) {
- margin-top: 8px;
- display: flex;
- justify-content: space-between;
- color: var(--text-secondary);
- font-size: 11px;
-}
-
-.root :global(.future-v2-mini-grid) {
- margin-top: 14px;
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 10px;
-}
-
-.root :global(.future-v2-mini-grid.future-v2-mini-grid-tight) {
- margin-top: 8px;
-}
-
-.root :global(.future-v2-mini-item) {
- border-radius: 10px;
- border: 1px solid var(--border-subtle);
- background: rgba(255, 255, 255, 0.025);
- padding: 10px;
- display: grid;
- gap: 4px;
-}
-
-.root :global(.future-v2-signal-item) {
- gap: 8px;
-}
-
-.root :global(.future-v2-signal-head) {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 8px;
-}
-
-.root :global(.future-v2-mini-item span) {
- color: var(--text-muted);
- font-size: 11px;
-}
-
-.root :global(.future-v2-mini-item strong) {
- color: var(--text-primary);
- font-size: 23px;
- line-height: 1.15;
- font-weight: 700;
-}
-
-.root :global(.future-v2-signal-tag) {
- font-style: normal;
- font-size: 10px;
- font-weight: 700;
- line-height: 1;
- padding: 5px 8px;
- border-radius: 999px;
- border: 1px solid rgba(255, 255, 255, 0.08);
- background: rgba(255, 255, 255, 0.04);
- color: var(--text-secondary);
-}
-
-.root :global(.future-v2-signal-tag.cyan) {
- color: #6FB7FF;
- border-color: rgba(34, 211, 238, 0.22);
- background: rgba(34, 211, 238, 0.08);
-}
-
-.root :global(.future-v2-signal-tag.blue) {
- color: #93c5fd;
- border-color: rgba(96, 165, 250, 0.22);
- background: rgba(96, 165, 250, 0.08);
-}
-
-.root :global(.future-v2-signal-tag.amber) {
- color: #fbbf24;
- border-color: rgba(251, 191, 36, 0.22);
- background: rgba(251, 191, 36, 0.08);
-}
-
-.root :global(.future-v2-signal-meter) {
- position: relative;
- height: 7px;
- border-radius: 999px;
- overflow: hidden;
- background: rgba(255, 255, 255, 0.06);
-}
-
-.root :global(.future-v2-signal-fill) {
- height: 100%;
- border-radius: inherit;
- background: linear-gradient(
- 90deg,
- rgba(203, 213, 225, 0.25) 0%,
- rgba(255, 255, 255, 0.32) 100%
- );
-}
-
-.root :global(.future-v2-signal-fill.cyan) {
- background: linear-gradient(90deg, #155e75 0%, #22d3ee 100%);
-}
-
-.root :global(.future-v2-signal-fill.blue) {
- background: linear-gradient(90deg, #2563eb 0%, #60a5fa 100%);
-}
-
-.root :global(.future-v2-signal-fill.amber) {
- background: linear-gradient(90deg, #b45309 0%, #f59e0b 100%);
-}
-
-.root :global(.future-v2-pace-card) {
- background:
- radial-gradient(
- circle at top right,
- rgba(34, 211, 238, 0.12) 0%,
- rgba(15, 23, 42, 0) 42%
- ),
- linear-gradient(
- 180deg,
- rgba(8, 15, 28, 0.96) 0%,
- rgba(255, 255, 255, 0.02) 100%
- );
-}
-
-.root :global(.future-v2-pace-head) {
- margin-top: 12px;
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- gap: 10px;
-}
-
-.root :global(.future-v2-pace-kicker) {
- color: var(--text-muted);
- font-size: 11px;
- letter-spacing: 0.06em;
- text-transform: uppercase;
-}
-
-.root :global(.future-v2-pace-delta) {
- margin-top: 10px;
- font-size: 34px;
- font-weight: 800;
- letter-spacing: -0.04em;
- line-height: 1.05;
-}
-
-.root :global(.future-v2-pace-delta.warm) {
- color: #fbbf24;
-}
-
-.root :global(.future-v2-pace-delta.cold) {
- color: #6FB7FF;
-}
-
-.root :global(.future-v2-pace-delta.neutral) {
- color: #e2e8f0;
-}
-
-.root :global(.future-v2-pace-summary) {
- margin-top: 8px;
- color: var(--text-secondary);
- font-size: 12px;
- line-height: 1.55;
-}
-
-.root :global(.future-v2-pace-signal-grid) {
- margin-top: 12px;
- display: grid;
- gap: 10px;
-}
-
-.root :global(.future-v2-pace-signal-card) {
- border-radius: 10px;
- border: 1px solid var(--border-subtle);
- background: rgba(255, 255, 255, 0.025);
- padding: 10px;
- display: grid;
- gap: 8px;
-}
-
-.root :global(.future-v2-pace-signal-note) {
- color: var(--text-secondary);
- font-size: 12px;
- line-height: 1.5;
-}
-
-.root :global(.future-v2-pace-meter) {
- position: relative;
- height: 10px;
- margin-top: 12px;
- border-radius: 999px;
- overflow: hidden;
- background: linear-gradient(
- 90deg,
- rgba(34, 211, 238, 0.12) 0%,
- rgba(255, 255, 255, 0.06) 46%,
- rgba(255, 255, 255, 0.06) 54%,
- rgba(251, 191, 36, 0.12) 100%
- );
-}
-
-.root :global(.future-v2-pace-meter-midline) {
- position: absolute;
- top: 0;
- bottom: 0;
- left: 50%;
- width: 1px;
- background: rgba(255, 255, 255, 0.2);
- transform: translateX(-50%);
- z-index: 1;
-}
-
-.root :global(.future-v2-pace-meter-fill) {
- position: absolute;
- top: 0;
- left: var(--pace-left, 46%);
- width: var(--pace-width, 8%);
- bottom: 0;
- border-radius: inherit;
-}
-
-.root :global(.future-v2-pace-meter-fill.warm) {
- background: linear-gradient(90deg, #b45309 0%, #fbbf24 100%);
-}
-
-.root :global(.future-v2-pace-meter-fill.cold) {
- background: linear-gradient(90deg, #0f766e 0%, #6FB7FF 100%);
-}
-
-.root :global(.future-v2-pace-meter-fill.neutral) {
- background: linear-gradient(90deg, #6B7A90 0%, #cbd5e1 100%);
-}
-
-.root :global(.intraday-scene-shell) {
- margin-top: 12px;
- margin-bottom: 10px;
- border-radius: 14px;
- border: 1px solid rgba(99, 102, 241, 0.14);
- background: radial-gradient(
- circle at 50% 18%,
- rgba(34, 211, 238, 0.14) 0%,
- rgba(255, 255, 255, 0.02) 48%,
- rgba(255, 255, 255, 0.015) 100%
- );
- overflow: hidden;
-}
-
-.root :global(.intraday-scene-frame) {
- height: 196px;
-}
-
-.root :global(.intraday-scene-frame canvas) {
- width: 100%;
- height: 100%;
- display: block;
-}
-
-.root :global(.intraday-scene-legend) {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px;
- padding: 10px 12px 12px;
- border-top: 1px solid rgba(255, 255, 255, 0.06);
- background: rgba(2, 6, 23, 0.18);
-}
-
-.root :global(.intraday-scene-chip) {
- display: flex;
- align-items: flex-start;
- gap: 8px;
- min-width: 0;
-}
-
-.root :global(.intraday-scene-chip-dot) {
- width: 8px;
- height: 8px;
- border-radius: 50%;
- margin-top: 5px;
- box-shadow: 0 0 10px currentColor;
- flex: 0 0 auto;
-}
-
-.root :global(.intraday-scene-chip-copy) {
- min-width: 0;
- display: grid;
- gap: 2px;
-}
-
-.root :global(.intraday-scene-chip-copy strong) {
- color: var(--text-primary);
- font-size: 11px;
- font-weight: 700;
-}
-
-.root :global(.intraday-scene-chip-copy span) {
- color: var(--text-secondary);
- font-size: 11px;
- line-height: 1.35;
-}
-
-.root :global(.future-v2-card-title) {
- margin: 0;
- color: var(--text-primary);
- font-size: 13px;
- font-weight: 700;
- letter-spacing: 0.02em;
-}
-
-.root :global(.future-v2-stack) {
- margin-top: 14px;
- display: grid;
- gap: 12px;
-}
-
-.root :global(.future-v2-subpanel) {
- border: 1px solid var(--border-subtle);
- border-radius: 12px;
- background: rgba(255, 255, 255, 0.025);
- padding: 12px;
-}
-
-.root :global(.future-v2-subpanel-title) {
- margin: 0;
- color: var(--text-primary);
- font-size: 12px;
- font-weight: 700;
- letter-spacing: 0.02em;
-}
-
-.root :global(.future-v2-market-v3) {
- margin-top: 16px;
- display: flex;
- flex-direction: column;
- gap: 16px;
- position: relative;
-}
-
-.root :global(.future-v2-market-card) {
- background:
- radial-gradient(
- circle at top right,
- rgba(34, 197, 94, 0.08) 0%,
- rgba(15, 23, 42, 0) 40%
- ),
- linear-gradient(
- 180deg,
- rgba(7, 16, 26, 0.96) 0%,
- rgba(255, 255, 255, 0.02) 100%
- );
-}
-
-.root :global(.market-layer-loading-overlay) {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(15, 23, 42, 0.6);
- backdrop-filter: blur(2px);
- z-index: 10;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- border-radius: 8px;
- color: var(--accent-cyan);
- font-size: 13px;
- font-weight: 500;
-}
-
-.root :global(.market-sub-title) {
- color: var(--text-secondary);
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- margin-bottom: 8px;
- font-weight: 600;
-}
-
-.root :global(.market-layer-target) {
- background: rgba(34, 211, 238, 0.04);
- border: 1px solid rgba(34, 211, 238, 0.15);
- border-radius: 8px;
- padding: 12px;
-}
-
-.root :global(.market-target-header) {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 12px;
- font-size: 13px;
- color: var(--text-secondary);
-}
-
-.root :global(.market-target-bucket) {
- font-size: 16px;
- color: var(--text-primary);
- font-weight: 700;
-}
-
-.root :global(.market-edge-box) {
- background: rgba(15, 23, 42, 0.4);
- border-radius: 6px;
- padding: 10px;
-}
-
-.root :global(.market-edge-header) {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 8px;
- font-size: 12px;
- color: var(--text-primary);
- font-weight: 600;
-}
-
-.root :global(.market-edge-val) {
- font-size: 15px;
- font-weight: 700;
-}
-
-.root :global(.market-edge-val.positive) {
- color: var(--accent-green);
-}
-
-.root :global(.market-edge-val.negative) {
- color: var(--text-secondary);
-}
-
-.root :global(.market-edge-compare) {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 8px;
- border-top: 1px solid rgba(255, 255, 255, 0.06);
- padding-top: 8px;
-}
-
-.root :global(.edge-stat) {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.root :global(.edge-label) {
- font-size: 10px;
- color: var(--text-muted);
-}
-
-.root :global(.edge-value) {
- font-size: 13px;
- color: var(--text-secondary);
- font-weight: 600;
-}
-
-.root :global(.market-layer-book),
-.root :global(.market-layer-context) {
- padding: 0 4px;
-}
-
-.root :global(.market-book-row),
-.root :global(.market-context-row) {
- display: flex;
- justify-content: space-between;
- align-items: center;
- font-size: 13px;
- padding: 6px 0;
- border-bottom: 1px dashed rgba(255, 255, 255, 0.05);
-}
-
-.root :global(.market-book-row:last-child),
-.root :global(.market-context-row:last-child) {
- border-bottom: none;
-}
-
-.root :global(.book-label),
-.root :global(.market-context-row span) {
- color: var(--text-secondary);
-}
-
-.root :global(.book-quote strong),
-.root :global(.market-context-row strong) {
- color: var(--text-primary);
- font-weight: 600;
-}
-
-.root :global(.book-quote span) {
- color: var(--text-muted);
- font-size: 11px;
-}
-
-.root :global(.book-spread) {
- color: var(--text-muted);
- font-size: 11px;
-}
-
-.root :global(.mt-3) {
- margin-top: 12px;
-}
-
-.root :global(.future-v2-market-signal) {
- margin-top: 10px;
- padding: 8px 10px;
- border-radius: 10px;
- background: rgba(34, 211, 238, 0.08);
- border: 1px solid rgba(34, 211, 238, 0.22);
- color: var(--accent-cyan);
- font-size: 12px;
- font-weight: 600;
-}
-
-.root :global(.future-v2-main-chart) {
- min-height: 340px;
-}
-
-.root :global(.future-v2-chart-thresholds) {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin-top: 12px;
-}
-
-.root :global(.future-v2-chart-thresholds span) {
- min-height: 26px;
- display: inline-flex;
- align-items: center;
- padding: 5px 8px;
- border: 1px solid rgba(34, 211, 238, 0.16);
- border-radius: 8px;
- background: rgba(8, 47, 73, 0.24);
- color: #cbd5e1;
- font-size: 12px;
- line-height: 1.35;
-}
-
-.root :global(.future-v2-meteorology-grid) {
- display: grid;
- grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.9fr) minmax(0, 0.9fr);
- gap: 12px;
-}
-
-.root :global(.future-v2-evidence-list) {
- display: grid;
- gap: 8px;
-}
-
-.root :global(.future-v2-evidence-row) {
- padding: 10px;
- border: 1px solid rgba(148, 163, 184, 0.14);
- border-left-width: 3px;
- border-radius: 8px;
- background: rgba(15, 23, 42, 0.5);
-}
-
-.root :global(.future-v2-evidence-row.cyan) {
- border-left-color: #22d3ee;
-}
-
-.root :global(.future-v2-evidence-row.amber) {
- border-left-color: #f59e0b;
-}
-
-.root :global(.future-v2-evidence-row.blue) {
- border-left-color: #60a5fa;
-}
-
-.root :global(.future-v2-evidence-head) {
- display: flex;
- justify-content: space-between;
- gap: 10px;
- align-items: flex-start;
-}
-
-.root :global(.future-v2-evidence-head strong) {
- color: #f8fafc;
- font-size: 13px;
- line-height: 1.35;
-}
-
-.root :global(.future-v2-evidence-head span) {
- color: var(--text-muted);
- font-size: 11px;
- line-height: 1.35;
- text-align: right;
-}
-
-.root :global(.future-v2-evidence-row p) {
- margin: 7px 0 0;
- color: var(--text-secondary);
- font-size: 12px;
- line-height: 1.55;
-}
-
-.root :global(.future-v2-rule-list) {
- display: grid;
- gap: 8px;
- margin: 0;
- padding: 0;
- list-style: none;
-}
-
-.root :global(.future-v2-rule-list li) {
- position: relative;
- padding-left: 16px;
- color: var(--text-secondary);
- font-size: 12px;
- line-height: 1.55;
-}
-
-.root :global(.future-v2-rule-list li::before) {
- content: "";
- position: absolute;
- left: 0;
- top: 0.65em;
- width: 6px;
- height: 6px;
- border-radius: 999px;
- background: #22d3ee;
-}
-
-.root
- :global(.future-v2-rule-panel:nth-child(2) .future-v2-rule-list li::before) {
- background: #f59e0b;
-}
-
-.root :global(.future-v2-model-note) {
- margin-top: 12px;
- padding-top: 10px;
- border-top: 1px solid rgba(148, 163, 184, 0.14);
- color: var(--text-muted);
- font-size: 12px;
- line-height: 1.55;
-}
-
-.root :global(.future-modal-section) {
- background: rgba(255, 255, 255, 0.02);
- border: 1px solid var(--border-subtle);
- border-radius: 14px;
- padding: 14px;
-}
-
-.root :global(.future-modal-section h3) {
- margin: 0 0 14px;
- font-size: 14px;
- font-weight: 700;
- color: var(--text-primary);
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.root :global(.section-inline-icon) {
- display: inline-flex;
- width: 16px;
- height: 16px;
- align-items: center;
- justify-content: center;
- color: var(--accent-cyan);
- flex: 0 0 16px;
-}
-
-.root :global(.section-inline-icon svg) {
- width: 16px;
- height: 16px;
-}
-
-.root :global(.future-modal-grid) {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 14px;
-}
-
-.root :global(.future-chart-wrapper) {
- height: 220px;
-}
-
-.root :global(.future-prob-bars .prob-bar-fill) {
- min-width: 56px;
-}
-
-.root :global(.future-trend-grid) {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 12px;
-}
-
-.root :global(.future-trend-card) {
- border: 1px solid var(--border-subtle);
- border-radius: 12px;
- padding: 12px;
- background: rgba(255, 255, 255, 0.025);
-}
-
-.root :global(.future-trend-label) {
- display: block;
- color: var(--text-muted);
- font-size: 11px;
- margin-bottom: 6px;
-}
-
-.root :global(.future-trend-value) {
- display: block;
- color: var(--text-primary);
- font-size: 17px;
- font-weight: 700;
-}
-
-.root :global(.future-trend-meter) {
- position: relative;
- height: 8px;
- margin-top: 10px;
- border-radius: 999px;
- overflow: hidden;
- background: rgba(255, 255, 255, 0.06);
-}
-
-.root :global(.future-trend-meter.center) {
- background: linear-gradient(
- 90deg,
- rgba(56, 189, 248, 0.1) 0%,
- rgba(255, 255, 255, 0.05) 48%,
- rgba(255, 255, 255, 0.05) 52%,
- rgba(245, 158, 11, 0.1) 100%
- );
-}
-
-.root :global(.future-trend-meter-midline) {
- position: absolute;
- top: 0;
- bottom: 0;
- left: 50%;
- width: 1px;
- background: rgba(255, 255, 255, 0.18);
- transform: translateX(-50%);
- z-index: 1;
-}
-
-.root :global(.future-trend-meter-fill) {
- position: absolute;
- top: 0;
- left: 0;
- bottom: 0;
- border-radius: inherit;
- background: linear-gradient(90deg, #334155 0%, #9FB2C7 100%);
-}
-
-.root :global(.future-trend-meter-fill.warm) {
- background: linear-gradient(90deg, #b45309 0%, #f59e0b 100%);
-}
-
-.root :global(.future-trend-meter-fill.cold) {
- background: linear-gradient(90deg, #0f766e 0%, #22d3ee 100%);
-}
-
-.root :global(.future-trend-note) {
- margin-top: 6px;
- color: var(--text-secondary);
- font-size: 12px;
- line-height: 1.45;
-}
-
-.root :global(.future-trend-summary-muted) {
- color: var(--text-muted);
- border: 1px dashed rgba(148, 163, 184, 0.18);
- background: rgba(255, 255, 255, 0.018);
-}
-
-.root :global(.future-trend-card-empty) {
- background: linear-gradient(
- 180deg,
- rgba(148, 163, 184, 0.05) 0%,
- rgba(255, 255, 255, 0.02) 100%
- );
-}
-
-.root :global(.future-subsection-title) {
- color: var(--text-primary);
- font-size: 0.95rem;
- font-weight: 700;
- margin-top: 18px;
-}
-
-.root :global(.future-trend-value.warm) {
- color: #22C55E;
-}
-
-.root :global(.future-trend-value.cold) {
- color: #f59e0b;
-}
-
-.root :global(.future-text-block) {
- color: var(--text-secondary);
- font-size: 13px;
- line-height: 1.7;
-}
-
-.root :global(.future-text-block strong) {
- color: var(--text-primary);
-}
-
-.root :global(.future-front-score) {
- display: grid;
- gap: 12px;
-}
-
-.root :global(.future-front-bar) {
- position: relative;
- height: 10px;
- border-radius: 999px;
- background: linear-gradient(
- 90deg,
- rgba(56, 189, 248, 0.4) 0%,
- rgba(56, 189, 248, 0.4) 30%,
- rgba(255, 255, 255, 0.06) 45%,
- rgba(255, 255, 255, 0.06) 55%,
- rgba(245, 158, 11, 0.4) 70%,
- rgba(245, 158, 11, 0.4) 100%
- );
- overflow: hidden;
-}
-
-.root :global(.future-front-bar::after) {
- content: "";
- position: absolute;
- top: 50%;
- left: var(--score-position, 50%);
- width: 14px;
- height: 14px;
- border-radius: 999px;
- background: var(--text-primary);
- transform: translate(-50%, -50%);
- box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
- z-index: 2;
- transition: left 0.5s ease;
-}
-
-.root :global(.future-front-meta) {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
- font-size: 12px;
- color: var(--text-secondary);
-}
-
-.root :global(.future-front-pill) {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 6px 10px;
- border-radius: 999px;
- border: 1px solid var(--border-subtle);
- background: rgba(255, 255, 255, 0.03);
-}
-
-@media (max-width: 960px) {
- .root :global(.future-v2-meteorology-brief),
- .root :global(.future-v2-meteorology-grid) {
- grid-template-columns: 1fr;
- }
-
- .root :global(.future-v2-meteorology-paths) {
- grid-template-columns: repeat(3, minmax(0, 1fr));
- }
-
- .root :global(.future-v2-decision-rail) {
- grid-template-columns: 1fr;
- }
-
- .root :global(.future-v2-decision-grid) {
- grid-template-columns: repeat(4, minmax(0, 1fr));
- }
-
- .root :global(.future-v2-layout) {
- grid-template-columns: 1fr;
- }
-
- .root :global(.future-v2-mini-item strong) {
- font-size: 18px;
- }
-
- .root :global(.future-v2-daylight-head) {
- flex-direction: column;
- align-items: flex-start;
- }
-
- .root :global(.future-modal-grid),
- .root :global(.future-trend-grid) {
- grid-template-columns: 1fr;
- }
-}
-
-@media (max-width: 640px) {
- .root :global(.future-modal-body) {
- gap: 12px;
- padding-right: 0;
- }
-
- .root :global(.future-v2-meteorology-brief) {
- padding: 12px;
- }
-
- .root :global(.future-v2-meteorology-paths) {
- grid-template-columns: 1fr;
- }
-
- .root :global(.future-v2-anchor-row) {
- align-items: flex-start;
- flex-direction: column;
- }
-
- .root :global(.future-v2-decision-grid) {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-
- .root :global(.future-v2-meteorology-copy h3) {
- font-size: 16px;
- }
-
- .root :global(.future-v2-card),
- .root :global(.future-modal-section) {
- border-radius: 12px;
- padding: 12px;
- }
-
- .root :global(.future-v2-hero-main) {
- align-items: flex-start;
- gap: 12px;
- }
-
- .root :global(.future-v2-hero-temp) {
- font-size: 44px;
- }
-
- .root :global(.future-v2-hero-divider) {
- height: 44px;
- }
-
- .root :global(.future-v2-hero-obs) {
- font-size: 18px;
- }
-
- .root :global(.future-v2-mini-grid) {
- grid-template-columns: 1fr;
- }
-
- .root :global(.future-v2-mini-item strong) {
- font-size: 20px;
- }
-
- .root :global(.future-v2-signal-head) {
- flex-direction: column;
- align-items: flex-start;
- }
-
- .root :global(.future-v2-main-chart) {
- min-height: 280px;
- }
-
- .root :global(.future-chart-wrapper) {
- height: 190px;
- }
-
- .root :global(.future-trend-card) {
- padding: 10px;
- }
-
- .root :global(.future-v2-subpanel) {
- padding: 10px;
- }
-
- .root :global(.intraday-scene-frame) {
- height: 174px;
- }
-
- .root :global(.intraday-scene-legend) {
- grid-template-columns: 1fr;
- }
-
- .root :global(.future-trend-value) {
- font-size: 16px;
- }
-
- .root :global(.future-trend-note),
- .root :global(.future-text-block) {
- font-size: 12px;
- line-height: 1.6;
- }
-
- .root :global(.future-subsection-title) {
- font-size: 0.9rem;
- margin-top: 14px;
- }
-
- .root :global(.future-front-meta) {
- gap: 8px;
- }
-
- .root :global(.future-front-pill) {
- width: 100%;
- justify-content: center;
- }
-
- .root :global(.detail-grid) {
- grid-template-columns: 1fr;
- }
-
- .root :global(.detail-mini-chart) {
- height: 170px;
- }
-
- .root :global(.detail-source-link) {
- padding: 10px 12px;
- align-items: flex-start;
- flex-direction: column;
- }
-
- .root :global(.detail-source-label) {
- text-align: left;
- }
-}
-
-@media (max-width: 1200px) {
- .root :global(.future-trend-grid) {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
-}
-
-.root :global(.guide-grid) {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
- gap: 16px;
- padding: 4px;
-}
-
-.root :global(.guide-card) {
- background: rgba(255, 255, 255, 0.03);
- border: 1px solid var(--border-subtle);
- border-radius: 12px;
- padding: 16px;
- transition: var(--transition);
-}
-.root :global(.guide-card:hover) {
- background: rgba(255, 255, 255, 0.05);
- border-color: var(--border-glass);
-}
-.root :global(.guide-card h3) {
- font-size: 15px;
- font-weight: 700;
- color: var(--accent-cyan);
- margin-bottom: 10px;
- display: flex;
- align-items: center;
- gap: 8px;
-}
-.root :global(.guide-card p) {
- font-size: 13px;
- line-height: 1.6;
- color: var(--text-secondary);
- margin-bottom: 0;
-}
-.root :global(.guide-card b) {
- color: var(--text-primary);
-}
-
-.root :global(.guide-footer) {
- margin-top: 24px;
- padding-top: 16px;
- border-top: 1px solid var(--border-subtle);
- text-align: center;
- font-size: 11px;
- color: var(--text-muted);
-}
-
-.root :global(.detail-grid) {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 10px;
-}
-
-.root :global(.detail-card) {
- background: rgba(255, 255, 255, 0.03);
- border: 1px solid var(--border-subtle);
- border-radius: 12px;
- padding: 12px;
-}
-
-.root :global(.detail-source-list) {
- display: grid;
- grid-template-columns: 1fr;
- gap: 10px;
-}
-
-.root :global(.detail-source-note) {
- margin: 8px 0 12px;
- color: var(--text-muted);
- font-size: 12px;
- line-height: 1.6;
-}
-
-.root :global(.detail-source-link) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 12px 14px;
- border-radius: 12px;
- border: 1px solid var(--border-subtle);
- background: rgba(255, 255, 255, 0.03);
- color: var(--text-main);
- text-decoration: none;
- transition:
- background 0.18s ease,
- border-color 0.18s ease,
- transform 0.18s ease;
-}
-
-.root :global(.detail-source-link:hover) {
- background: rgba(34, 211, 238, 0.08);
- border-color: rgba(34, 211, 238, 0.28);
- transform: translateY(-1px);
-}
-
-.root :global(.detail-source-kind) {
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.12em;
- color: var(--accent-cyan);
- text-transform: uppercase;
- flex-shrink: 0;
-}
-
-.root :global(.detail-source-label) {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-main);
- text-align: right;
-}
-
-.root :global(.detail-label) {
- display: block;
- color: var(--text-muted);
- font-size: 11px;
- margin-bottom: 6px;
-}
-
-.root :global(.detail-value) {
- display: block;
- color: var(--text-primary);
- font-size: 14px;
- font-weight: 700;
- line-height: 1.5;
- word-break: break-word;
-}
-
-.root :global(.profile-lead) {
- color: var(--text-secondary);
- font-size: 13px;
- line-height: 1.7;
-}
-
-.root :global(.detail-mini-chart-wrap) {
- display: grid;
- gap: 10px;
-}
-
-.root :global(.detail-mini-chart) {
- position: relative;
- 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;
- line-height: 1.55;
-}
-
-.root :global(.insight-list) {
- display: grid;
- gap: 10px;
-}
-
-.root :global(.insight-item) {
- background: rgba(255, 255, 255, 0.03);
- border: 1px solid var(--border-subtle);
- border-radius: 12px;
- padding: 12px;
-}
-
-.root :global(.insight-title) {
- color: var(--accent-cyan);
- font-size: 12px;
- font-weight: 700;
- margin-bottom: 6px;
-}
-
-.root :global(.insight-text) {
- color: var(--text-secondary);
- font-size: 13px;
- line-height: 1.65;
-}
-
-.root :global(.detail-scenery-card) {
- position: relative;
- min-height: 210px;
- border-radius: 16px;
- overflow: hidden;
- border: 1px solid var(--border-subtle);
- background:
- linear-gradient(180deg, rgba(15, 23, 42, 0.18), rgba(15, 23, 42, 0.78)),
- rgba(255, 255, 255, 0.03);
-}
-
-.root :global(.detail-scenery-image) {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 100%;
- object-fit: cover;
-}
-
-.root :global(.detail-scenery-overlay) {
- position: relative;
- z-index: 1;
- min-height: 210px;
- padding: 18px;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- background: linear-gradient(
- 180deg,
- rgba(2, 6, 23, 0.08) 0%,
- rgba(2, 6, 23, 0.86) 100%
- );
-}
-
-.root :global(.detail-scenery-copy),
-.root :global(.detail-scenery-fallback) {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.root :global(.detail-scenery-fallback) {
- min-height: 210px;
- padding: 18px;
- justify-content: flex-end;
- background:
- radial-gradient(
- circle at top left,
- rgba(34, 211, 238, 0.12),
- transparent 34%
- ),
- linear-gradient(180deg, rgba(15, 23, 42, 0.92), rgba(2, 6, 23, 0.96));
-}
-
-.root :global(.detail-scenery-kicker) {
- color: rgba(226, 232, 240, 0.78);
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.16em;
- text-transform: uppercase;
-}
-
-.root :global(.detail-scenery-title) {
- color: #fff;
- font-size: 22px;
- font-weight: 800;
- letter-spacing: -0.03em;
- text-shadow: 0 6px 22px rgba(2, 6, 23, 0.42);
-}
-
-.root :global(.detail-scenery-subtitle) {
- max-width: 320px;
- color: rgba(226, 232, 240, 0.9);
- font-size: 13px;
- line-height: 1.6;
- text-shadow: 0 2px 10px rgba(2, 6, 23, 0.45);
-}
-
-.root :global(.detail-scenery-credit) {
- align-self: flex-start;
- color: rgba(226, 232, 240, 0.9);
- font-size: 11px;
- text-decoration: none;
- padding: 6px 10px;
- border-radius: 999px;
- border: 1px solid rgba(255, 255, 255, 0.14);
- background: rgba(2, 6, 23, 0.35);
- backdrop-filter: blur(10px);
-}
-
-.root :global(.detail-scenery-credit:hover) {
- border-color: rgba(34, 211, 238, 0.4);
- color: #fff;
-}
-
-.root :global(.home-ai-assistant) {
- position: fixed;
- right: 24px;
- bottom: 24px;
- z-index: 910;
- width: min(340px, calc(100vw - 48px));
-}
-
-.root :global(.home-ai-assistant.collapsed) {
- width: 56px;
-}
-
-.root :global(.home-ai-assistant.dragging) {
- user-select: none;
-}
-
-.root :global(.home-ai-launcher),
-.root :global(.home-ai-panel) {
- width: 100%;
- border: 1px solid rgba(34, 211, 238, 0.18);
- border-radius: 20px;
- background:
- radial-gradient(
- circle at 100% 0%,
- rgba(30, 64, 175, 0.18),
- transparent 38%
- ),
- linear-gradient(180deg, rgba(5, 14, 28, 0.96), rgba(6, 16, 30, 0.92));
- box-shadow:
- 0 24px 48px rgba(2, 6, 23, 0.32),
- inset 0 1px 0 rgba(255, 255, 255, 0.04);
- backdrop-filter: blur(18px);
-}
-
-.root :global(.home-ai-launcher) {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 56px;
- height: 56px;
- padding: 0;
- cursor: grab;
- touch-action: none;
- user-select: none;
- transition:
- transform 160ms ease,
- border-color 160ms ease,
- background 160ms ease;
-}
-
-.root :global(.home-ai-launcher-icon) {
- width: 22px;
- height: 22px;
- display: block;
- object-fit: contain;
-}
-
-.root :global(.home-ai-launcher:hover) {
- transform: translateY(-1px);
- border-color: rgba(34, 211, 238, 0.32);
-}
-
-.root :global(.home-ai-launcher-copy) {
- min-width: 0;
-}
-
-.root :global(.home-ai-launcher-badge) {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 38px;
- height: 38px;
- border-radius: 12px;
- background: linear-gradient(
- 135deg,
- rgba(34, 211, 238, 0.9),
- rgba(59, 130, 246, 0.86)
- );
- color: #04111d;
- font-size: 13px;
- font-weight: 900;
- letter-spacing: 0.08em;
-}
-
-.root :global(.home-ai-launcher strong),
-.root :global(.home-ai-header strong) {
- display: block;
- color: #f8fafc;
- font-size: 14px;
- font-weight: 850;
-}
-
-.root :global(.home-ai-launcher span),
-.root :global(.home-ai-header span),
-.root :global(.home-ai-disclaimer),
-.root :global(.home-ai-error) {
- color: rgba(148, 163, 184, 0.88);
- font-size: 11px;
- line-height: 1.5;
-}
-
-.root :global(.home-ai-panel) {
- display: grid;
- gap: 12px;
- padding: 14px;
-}
-
-.root :global(.home-ai-header) {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 12px;
-}
-
-.root :global(.home-ai-header-actions) {
- display: inline-flex;
- align-items: center;
- gap: 8px;
-}
-
-.root :global(.home-ai-drag-handle) {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 30px;
- height: 30px;
- border-radius: 999px;
- border: 1px solid rgba(148, 163, 184, 0.16);
- background: rgba(15, 23, 42, 0.42);
- color: rgba(191, 219, 254, 0.84);
- cursor: grab;
- flex-shrink: 0;
- font-size: 15px;
- font-weight: 900;
- line-height: 1;
- letter-spacing: -0.2em;
- touch-action: none;
- user-select: none;
-}
-
-.root :global(.home-ai-assistant.dragging .home-ai-drag-handle) {
- cursor: grabbing;
-}
-
-.root :global(.home-ai-assistant.dragging .home-ai-launcher) {
- cursor: grabbing;
- transform: none;
-}
-
-.root :global(.home-ai-close) {
- border: 1px solid rgba(148, 163, 184, 0.14);
- border-radius: 999px;
- width: 28px;
- height: 28px;
- background: rgba(15, 23, 42, 0.4);
- color: rgba(226, 232, 240, 0.9);
- cursor: pointer;
-}
-
-.root :global(.home-ai-disclaimer) {
- padding: 10px 12px;
- border: 1px solid rgba(34, 211, 238, 0.12);
- border-radius: 14px;
- background: rgba(6, 13, 24, 0.58);
-}
-
-.root :global(.home-ai-messages) {
- display: grid;
- gap: 10px;
- max-height: 248px;
- overflow-y: auto;
- padding-right: 4px;
-}
-
-.root :global(.home-ai-messages::-webkit-scrollbar) {
- width: 4px;
-}
-
-.root :global(.home-ai-messages::-webkit-scrollbar-thumb) {
- background: rgba(34, 211, 238, 0.22);
- border-radius: 999px;
-}
-
-.root :global(.home-ai-message) {
- max-width: calc(100% - 18px);
- padding: 11px 12px;
- border-radius: 16px;
- border: 1px solid rgba(148, 163, 184, 0.12);
- background: rgba(8, 15, 28, 0.8);
-}
-
-.root :global(.home-ai-message.user) {
- margin-left: auto;
- border-color: rgba(34, 211, 238, 0.22);
- background: rgba(6, 18, 34, 0.92);
-}
-
-.root :global(.home-ai-message.assistant) {
- margin-right: auto;
-}
-
-.root :global(.home-ai-message.loading) {
- opacity: 0.8;
-}
-
-.root :global(.home-ai-message p) {
- color: #e2e8f0;
- font-size: 12px;
- line-height: 1.7;
- white-space: pre-wrap;
-}
-
-.root :global(.home-ai-message small) {
- display: inline-block;
- margin-top: 6px;
- color: #6FB7FF;
- font-size: 10px;
- font-weight: 800;
-}
-
-.root :global(.home-ai-starters) {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.root :global(.home-ai-starter) {
- border: 1px solid rgba(148, 163, 184, 0.14);
- border-radius: 999px;
- padding: 7px 10px;
- background: rgba(9, 16, 30, 0.7);
- color: #cbd5e1;
- cursor: pointer;
- font-size: 11px;
- font-weight: 700;
-}
-
-.root :global(.home-ai-starter:hover) {
- border-color: rgba(34, 211, 238, 0.28);
- color: #f8fafc;
-}
-
-.root :global(.home-ai-composer) {
- display: grid;
- gap: 8px;
-}
-
-.root :global(.home-ai-input) {
- resize: none;
- width: 100%;
- min-height: 84px;
- border: 1px solid rgba(148, 163, 184, 0.14);
- border-radius: 14px;
- padding: 10px 12px;
- background: rgba(4, 10, 20, 0.84);
- color: #f8fafc;
- font: inherit;
- font-size: 12px;
- line-height: 1.6;
-}
-
-.root :global(.home-ai-input:focus) {
- outline: none;
- border-color: rgba(34, 211, 238, 0.42);
- box-shadow: 0 0 0 1px rgba(34, 211, 238, 0.16);
-}
-
-.root :global(.home-ai-composer-actions) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
-}
-
-.root :global(.home-ai-send) {
- border: 1px solid rgba(34, 211, 238, 0.22);
- border-radius: 12px;
- padding: 9px 14px;
- background: linear-gradient(
- 135deg,
- rgba(8, 145, 178, 0.24),
- rgba(37, 99, 235, 0.24)
- );
- color: #e0f2fe;
- cursor: pointer;
- font-size: 12px;
- font-weight: 800;
-}
-
-.root :global(.home-ai-send:disabled) {
- cursor: not-allowed;
- opacity: 0.52;
-}
-
-.root :global(.home-ai-paywall-backdrop) {
- position: fixed;
- inset: 0;
- z-index: 1200;
- display: flex;
- align-items: center;
- justify-content: center;
- background: rgba(2, 6, 23, 0.68);
- backdrop-filter: blur(10px);
-}
-
-.root :global(.home-ai-paywall-shell) {
- width: min(820px, calc(100vw - 32px));
-}
-
-:global(html.light) .root :global(.home-ai-launcher),
-:global(html.light) .root :global(.home-ai-panel) {
- background: linear-gradient(
- 180deg,
- rgba(255, 255, 255, 0.96),
- rgba(240, 248, 255, 0.94)
- );
- border-color: rgba(48, 77, 112, 0.16);
-}
-
-:global(html.light) .root :global(.home-ai-message),
-:global(html.light) .root :global(.home-ai-disclaimer),
-:global(html.light) .root :global(.home-ai-input),
-:global(html.light) .root :global(.home-ai-starter) {
- background: rgba(255, 255, 255, 0.88);
- border-color: rgba(48, 77, 112, 0.14);
-}
-
-:global(html.light) .root :global(.home-ai-message p),
-:global(html.light) .root :global(.home-ai-launcher strong),
-:global(html.light) .root :global(.home-ai-header strong) {
- color: #0b1726;
-}
-
-@media (max-width: 1400px) {
- .root :global(.home-ai-assistant) {
- right: 24px;
- bottom: 24px;
- width: min(340px, calc(100vw - 48px));
- }
-}
-
-@media (max-width: 960px) {
- .root :global(.home-ai-assistant) {
- left: auto;
- right: 16px;
- width: min(340px, calc(100vw - 32px));
- bottom: 16px;
- }
-
- .root :global(.home-ai-assistant.collapsed) {
- width: 56px;
- }
-}
diff --git a/frontend/components/dashboard/DashboardHomeIntelligence.module.css b/frontend/components/dashboard/DashboardHomeIntelligence.module.css
new file mode 100644
index 00000000..93c8323b
--- /dev/null
+++ b/frontend/components/dashboard/DashboardHomeIntelligence.module.css
@@ -0,0 +1,2248 @@
+/* ── Homepage Intelligence Layer ── */
+.root :global(.home-intelligence-panel) {
+ position: fixed;
+ top: calc(var(--header-height) + 16px);
+ right: 18px;
+ bottom: 20px;
+ width: min(360px, calc(100vw - var(--sidebar-width) - 56px));
+ z-index: 890;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 18px;
+ overflow: hidden;
+ border: 1px solid rgba(34, 211, 238, 0.18);
+ border-radius: 24px;
+ background:
+ radial-gradient(
+ circle at 18% 0%,
+ rgba(34, 211, 238, 0.16),
+ transparent 32%
+ ),
+ radial-gradient(
+ circle at 100% 20%,
+ rgba(59, 130, 246, 0.18),
+ transparent 34%
+ ),
+ linear-gradient(155deg, rgba(5, 12, 25, 0.86), rgba(12, 19, 35, 0.78));
+ backdrop-filter: blur(24px) saturate(130%);
+ box-shadow:
+ 0 28px 80px rgba(0, 0, 0, 0.42),
+ inset 0 1px 0 rgba(255, 255, 255, 0.06);
+ animation: home-panel-enter 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+
+.root :global(.home-panel-glow) {
+ position: absolute;
+ inset: -28% -30% auto auto;
+ width: 260px;
+ height: 260px;
+ border-radius: 999px;
+ background: radial-gradient(
+ circle,
+ rgba(34, 211, 238, 0.18),
+ transparent 62%
+ );
+ pointer-events: none;
+}
+
+.root :global(.home-panel-kicker),
+.root :global(.home-panel-airport),
+.root :global(.home-signal-label),
+.root :global(.home-pro-card span),
+.root :global(.opportunity-strip-heading span) {
+ color: rgba(148, 163, 184, 0.92);
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+.root :global(.home-panel-kicker) {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.root :global(.home-panel-pulse) {
+ width: 8px;
+ height: 8px;
+ border-radius: 999px;
+ background: var(--accent-cyan);
+ box-shadow: 0 0 18px rgba(34, 211, 238, 0.7);
+ animation: pulse 2s ease-in-out infinite;
+}
+
+.root :global(.home-panel-city) {
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.root :global(.home-panel-city h2) {
+ margin-top: 6px;
+ color: #f8fafc;
+ font-size: clamp(28px, 4vw, 42px);
+ font-weight: 900;
+ line-height: 0.92;
+ letter-spacing: -0.07em;
+}
+
+.root :global(.home-risk-badge) {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 26px;
+ border-radius: 999px;
+ padding: 5px 10px 5px 8px;
+ border: 1px solid rgba(148, 163, 184, 0.2);
+ background: rgba(9, 18, 36, 0.58);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
+ color: rgba(226, 232, 240, 0.88);
+ font-size: 10px;
+ font-weight: 900;
+ letter-spacing: 0.01em;
+ white-space: nowrap;
+}
+
+.root :global(.home-risk-badge::before) {
+ content: "";
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background: currentColor;
+ opacity: 0.88;
+ box-shadow: 0 0 8px color-mix(in srgb, currentColor 24%, transparent);
+}
+
+.root :global(.home-risk-badge.high) {
+ border-color: rgba(248, 113, 113, 0.34);
+ color: #fda4af;
+ background: rgba(69, 10, 10, 0.32);
+}
+
+.root :global(.home-risk-badge.medium) {
+ border-color: rgba(251, 191, 36, 0.3);
+ color: #fcd34d;
+ background: rgba(67, 36, 6, 0.28);
+}
+
+.root :global(.home-risk-badge.low) {
+ border-color: rgba(52, 211, 153, 0.28);
+ color: #86efac;
+ background: rgba(7, 47, 35, 0.28);
+}
+
+.root :global(.home-panel-subtitle) {
+ max-width: 310px;
+ color: rgba(203, 213, 225, 0.78);
+ font-size: 13px;
+ line-height: 1.6;
+}
+
+.root :global(.home-metric-grid) {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.root :global(.home-metric-card) {
+ min-height: 86px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 13px;
+ border: 1px solid rgba(148, 163, 184, 0.13);
+ border-radius: 18px;
+ background: rgba(2, 6, 23, 0.36);
+}
+
+.root :global(.home-metric-card.primary) {
+ background:
+ linear-gradient(135deg, rgba(34, 211, 238, 0.2), rgba(59, 130, 246, 0.08)),
+ rgba(2, 6, 23, 0.42);
+ border-color: rgba(34, 211, 238, 0.24);
+}
+
+.root :global(.home-metric-card span) {
+ color: rgba(148, 163, 184, 0.86);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.root :global(.home-metric-card strong) {
+ color: #f8fafc;
+ font-size: 24px;
+ font-weight: 900;
+ letter-spacing: -0.06em;
+}
+
+.root :global(.home-signal-card) {
+ display: grid;
+ grid-template-columns: 48px 1fr;
+ gap: 14px;
+ padding: 14px;
+ border: 1px solid rgba(34, 211, 238, 0.16);
+ border-radius: 20px;
+ background: rgba(8, 13, 27, 0.48);
+}
+
+.root :global(.home-signal-line) {
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ gap: 5px;
+ height: 68px;
+}
+
+.root :global(.home-signal-line span) {
+ width: 7px;
+ border-radius: 999px;
+ background: linear-gradient(180deg, #22d3ee, #3b82f6);
+ opacity: 0.88;
+ animation: home-signal-wave 1.8s ease-in-out infinite;
+}
+
+.root :global(.home-signal-line span:nth-child(1)) {
+ height: 32px;
+}
+
+.root :global(.home-signal-line span:nth-child(2)) {
+ height: 56px;
+ animation-delay: 180ms;
+}
+
+.root :global(.home-signal-line span:nth-child(3)) {
+ height: 42px;
+ animation-delay: 360ms;
+}
+
+.root :global(.home-signal-card strong) {
+ display: block;
+ margin: 5px 0 4px;
+ color: #e2e8f0;
+ font-size: 15px;
+}
+
+.root :global(.home-signal-card p) {
+ color: rgba(148, 163, 184, 0.9);
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.root :global(.home-pro-card) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 14px;
+ border: 1px solid rgba(251, 191, 36, 0.26);
+ border-radius: 20px;
+ background:
+ linear-gradient(135deg, rgba(251, 191, 36, 0.1), transparent 60%),
+ rgba(15, 23, 42, 0.52);
+}
+
+.root :global(.home-pro-card.active) {
+ border-color: rgba(34, 211, 238, 0.28);
+ background:
+ linear-gradient(135deg, rgba(34, 211, 238, 0.12), transparent 60%),
+ rgba(15, 23, 42, 0.52);
+}
+
+.root :global(.home-pro-card strong) {
+ display: block;
+ margin-top: 5px;
+ color: #f8fafc;
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.root :global(.home-pro-card a),
+.root :global(.home-pro-card button) {
+ flex-shrink: 0;
+ border: 1px solid rgba(34, 211, 238, 0.32);
+ border-radius: 999px;
+ background: rgba(34, 211, 238, 0.1);
+ color: #a5f3fc;
+ cursor: pointer;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 800;
+ padding: 8px 12px;
+ text-decoration: none;
+ transition:
+ transform 160ms ease,
+ border-color 160ms ease,
+ background 160ms ease;
+}
+
+.root :global(.home-pro-card a:hover),
+.root :global(.home-pro-card button:hover) {
+ transform: translateY(-1px);
+ border-color: rgba(34, 211, 238, 0.62);
+ background: rgba(34, 211, 238, 0.16);
+}
+
+.root :global(.home-intelligence-panel.full) {
+ bottom: 18px;
+ width: min(372px, calc(100vw - var(--sidebar-width) - 56px));
+ gap: 14px;
+ overflow-y: auto;
+ border-color: rgba(34, 211, 238, 0.22);
+ background:
+ radial-gradient(
+ circle at 100% 0%,
+ rgba(37, 99, 235, 0.18),
+ transparent 38%
+ ),
+ radial-gradient(
+ circle at 0% 100%,
+ rgba(34, 211, 238, 0.06),
+ transparent 32%
+ ),
+ linear-gradient(165deg, rgba(3, 10, 22, 0.96), rgba(5, 18, 38, 0.94));
+ box-shadow:
+ inset 1px 0 0 rgba(255, 255, 255, 0.04),
+ -10px 0 34px rgba(0, 0, 0, 0.18);
+}
+
+.root :global(.home-intelligence-panel.full::-webkit-scrollbar) {
+ width: 4px;
+}
+
+.root :global(.home-intelligence-panel.full::-webkit-scrollbar-thumb) {
+ background: rgba(34, 211, 238, 0.28);
+ border-radius: 999px;
+}
+
+/* Close button removed — panel always visible */
+
+.root :global(.home-panel-header-left) {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.root :global(.home-panel-live-indicator) {
+ width: 7px;
+ height: 7px;
+ border-radius: 999px;
+ background: #22c55e;
+ box-shadow: 0 0 10px rgba(34, 197, 94, 0.6);
+ animation: pulse 2s ease-in-out infinite;
+}
+
+.root :global(.home-panel-header) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid rgba(115, 137, 161, 0.12);
+}
+
+.root :global(.home-focus-title) {
+ color: rgba(226, 232, 240, 0.92);
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.root :global(.home-why-link) {
+ border: 1px solid rgba(96, 165, 250, 0.22);
+ border-radius: 999px;
+ padding: 4px 10px;
+ background: rgba(96, 165, 250, 0.06);
+ color: #93c5fd;
+ cursor: pointer;
+ font: inherit;
+ font-size: 10px;
+ font-weight: 700;
+ transition: all 180ms ease;
+}
+
+.root :global(.home-why-link:hover) {
+ border-color: rgba(96, 165, 250, 0.42);
+ background: rgba(96, 165, 250, 0.12);
+ color: #bfdbfe;
+}
+
+.root :global(.home-top-opportunity-label) {
+ display: inline-flex;
+ align-self: flex-start;
+ align-items: center;
+ gap: 7px;
+ padding: 5px 8px;
+ border: 1px solid rgba(34, 211, 238, 0.18);
+ border-radius: 999px;
+ background: rgba(8, 145, 178, 0.1);
+ color: #6FB7FF;
+ font-size: 10px;
+ font-weight: 900;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+}
+
+.root :global(.home-top-opportunity-label::before) {
+ content: "";
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background: #22c55e;
+ box-shadow: 0 0 12px rgba(34, 197, 94, 0.72);
+}
+
+.root :global(.home-card-titlebar) {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+}
+
+.root :global(.home-card-titlebar h2) {
+ color: #f8fafc;
+ font-size: 22px;
+ font-weight: 900;
+ line-height: 1;
+ letter-spacing: -0.045em;
+}
+
+.root :global(.home-card-titlebar p),
+.root :global(.home-card-meta-row) {
+ color: rgba(148, 163, 184, 0.88);
+ font-size: 12px;
+ font-weight: 650;
+}
+
+.root :global(.home-card-titlebar p) {
+ margin-top: 7px;
+}
+
+.root :global(.home-card-meta-row) {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.root :global(.home-card-live-dot) {
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background: #22c55e;
+ box-shadow: 0 0 12px rgba(34, 197, 94, 0.72);
+}
+
+.root :global(.home-weather-hero) {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 130px;
+ gap: 14px;
+ align-items: center;
+ padding: 16px;
+ border: 1px solid rgba(34, 211, 238, 0.14);
+ border-radius: 18px;
+ background:
+ radial-gradient(
+ circle at 88% 24%,
+ rgba(245, 158, 11, 0.12),
+ transparent 24%
+ ),
+ linear-gradient(180deg, rgba(8, 16, 30, 0.62), rgba(4, 10, 22, 0.36));
+}
+
+.root :global(.home-weather-main strong) {
+ display: block;
+ color: #f8fafc;
+ font-size: 62px;
+ font-weight: 900;
+ line-height: 0.96;
+ letter-spacing: -0.08em;
+}
+
+.root :global(.home-weather-label),
+.root :global(.home-weather-sub),
+.root :global(.home-weather-stat span),
+.root :global(.home-weather-stat small) {
+ display: block;
+ color: rgba(148, 163, 184, 0.88);
+ font-size: 12px;
+}
+
+.root :global(.home-weather-label) {
+ margin-bottom: 8px;
+ color: #6FB7FF;
+ font-size: 10px;
+ font-weight: 900;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.root :global(.home-weather-sub) {
+ margin-top: 10px;
+}
+
+.root :global(.home-weather-side) {
+ display: grid;
+ justify-items: end;
+ gap: 10px;
+}
+
+.root :global(.home-weather-icon) {
+ position: relative;
+ width: 88px;
+ height: 76px;
+}
+
+.root :global(.home-weather-stat) {
+ min-width: 118px;
+ padding: 9px 10px;
+ border: 1px solid rgba(148, 163, 184, 0.12);
+ border-radius: 12px;
+ background: rgba(2, 8, 18, 0.35);
+ text-align: right;
+}
+
+.root :global(.home-weather-stat strong) {
+ display: block;
+ margin-top: 3px;
+ color: #f8fafc;
+ font-size: 18px;
+ font-weight: 900;
+}
+
+.root :global(.home-weather-stat small) {
+ margin-top: 2px;
+ font-size: 10px;
+}
+
+.root :global(.home-weather-icon .sun),
+.root :global(.home-weather-icon .mist),
+.root :global(.home-weather-icon .wind),
+.root :global(.home-weather-icon .bolt) {
+ position: absolute;
+}
+
+.root :global(.home-weather-icon .sun) {
+ top: 8px;
+ left: 6px;
+ width: 26px;
+ height: 26px;
+ border-radius: 999px;
+ background: radial-gradient(circle, #fde68a 0%, #f59e0b 70%, #f97316 100%);
+ box-shadow:
+ 0 0 0 7px rgba(251, 191, 36, 0.12),
+ 0 0 22px rgba(245, 158, 11, 0.26);
+}
+
+.root :global(.home-weather-icon .cloud) {
+ position: absolute;
+ border-radius: 999px;
+ background: linear-gradient(180deg, #b8c4d2, #6B7A90);
+ filter: drop-shadow(0 10px 18px rgba(0, 0, 0, 0.28));
+}
+
+.root :global(.home-weather-icon .cloud-a) {
+ left: 10px;
+ top: 24px;
+ width: 70px;
+ height: 30px;
+}
+
+.root :global(.home-weather-icon .cloud-b) {
+ left: 31px;
+ top: 10px;
+ width: 37px;
+ height: 37px;
+}
+
+.root :global(.home-weather-icon .rain) {
+ position: absolute;
+ top: 58px;
+ width: 4px;
+ height: 13px;
+ border-radius: 999px;
+ background: #4DA3FF;
+ transform: rotate(18deg);
+}
+
+.root :global(.home-weather-icon .rain-a) {
+ left: 29px;
+}
+
+.root :global(.home-weather-icon .rain-b) {
+ left: 48px;
+}
+
+.root :global(.home-weather-icon .rain-c) {
+ left: 67px;
+}
+
+.root :global(.home-weather-icon .mist) {
+ left: 20px;
+ width: 46px;
+ height: 2px;
+ border-radius: 999px;
+ background: rgba(191, 219, 254, 0.85);
+ box-shadow: 0 0 10px rgba(148, 163, 184, 0.18);
+}
+
+.root :global(.home-weather-icon .mist-a) {
+ top: 58px;
+}
+
+.root :global(.home-weather-icon .mist-b) {
+ top: 64px;
+ left: 28px;
+ width: 38px;
+}
+
+.root :global(.home-weather-icon .wind) {
+ left: 20px;
+ width: 44px;
+ height: 2px;
+ border-radius: 999px;
+ background: linear-gradient(
+ 90deg,
+ rgba(125, 211, 252, 0),
+ rgba(125, 211, 252, 0.92)
+ );
+}
+
+.root :global(.home-weather-icon .wind::after) {
+ content: "";
+ position: absolute;
+ right: -3px;
+ top: -1px;
+ width: 9px;
+ height: 4px;
+ border-top: 2px solid rgba(125, 211, 252, 0.92);
+ border-right: 2px solid rgba(125, 211, 252, 0.92);
+ border-radius: 0 8px 0 0;
+}
+
+.root :global(.home-weather-icon .wind-a) {
+ top: 28px;
+}
+
+.root :global(.home-weather-icon .wind-b) {
+ top: 42px;
+ left: 30px;
+ width: 34px;
+}
+
+.root :global(.home-weather-icon .bolt) {
+ top: 44px;
+ left: 20px;
+ width: 0;
+ height: 0;
+ border-left: 6px solid transparent;
+ border-right: 2px solid transparent;
+ border-top: 18px solid #fbbf24;
+ transform: skewX(-16deg);
+ filter: drop-shadow(0 0 12px rgba(251, 191, 36, 0.32));
+}
+
+.root :global(.home-weather-icon .bolt::after) {
+ content: "";
+ position: absolute;
+ left: -5px;
+ top: -4px;
+ width: 0;
+ height: 0;
+ border-left: 5px solid transparent;
+ border-right: 2px solid transparent;
+ border-top: 12px solid #fde047;
+ transform: translateX(5px) translateY(8px);
+}
+
+.root :global(.home-weather-icon.weather-clear .cloud),
+.root :global(.home-weather-icon.weather-clear .rain),
+.root :global(.home-weather-icon.weather-clear .mist),
+.root :global(.home-weather-icon.weather-clear .wind),
+.root :global(.home-weather-icon.weather-clear .bolt) {
+ display: none;
+}
+
+.root :global(.home-weather-icon.weather-partly .rain),
+.root :global(.home-weather-icon.weather-partly .mist),
+.root :global(.home-weather-icon.weather-partly .wind),
+.root :global(.home-weather-icon.weather-partly .bolt) {
+ display: none;
+}
+
+.root :global(.home-weather-icon.weather-cloudy .sun),
+.root :global(.home-weather-icon.weather-cloudy .rain),
+.root :global(.home-weather-icon.weather-cloudy .mist),
+.root :global(.home-weather-icon.weather-cloudy .wind),
+.root :global(.home-weather-icon.weather-cloudy .bolt) {
+ display: none;
+}
+
+.root :global(.home-weather-icon.weather-rain .sun),
+.root :global(.home-weather-icon.weather-rain .mist),
+.root :global(.home-weather-icon.weather-rain .wind),
+.root :global(.home-weather-icon.weather-rain .bolt) {
+ display: none;
+}
+
+.root :global(.home-weather-icon.weather-storm .sun),
+.root :global(.home-weather-icon.weather-storm .mist),
+.root :global(.home-weather-icon.weather-storm .wind) {
+ display: none;
+}
+
+.root :global(.home-weather-icon.weather-mist .sun),
+.root :global(.home-weather-icon.weather-mist .rain),
+.root :global(.home-weather-icon.weather-mist .wind),
+.root :global(.home-weather-icon.weather-mist .bolt) {
+ display: none;
+}
+
+.root :global(.home-weather-icon.weather-wind .sun),
+.root :global(.home-weather-icon.weather-wind .rain),
+.root :global(.home-weather-icon.weather-wind .mist),
+.root :global(.home-weather-icon.weather-wind .bolt) {
+ display: none;
+}
+
+.root :global(.home-max-so-far) {
+ grid-column: 2;
+ color: rgba(203, 213, 225, 0.86);
+ font-size: 12px;
+ text-align: right;
+}
+
+.root :global(.home-max-so-far strong) {
+ display: block;
+ margin-top: 4px;
+ font-size: 16px;
+ letter-spacing: -0.03em;
+}
+
+.root :global(.home-max-so-far small) {
+ color: rgba(148, 163, 184, 0.9);
+ font-size: 11px;
+}
+
+.root :global(.home-deb-card),
+.root :global(.home-card-section) {
+ border-radius: 14px;
+ border: 1px solid rgba(115, 137, 161, 0.12);
+ background: linear-gradient(
+ 180deg,
+ rgba(8, 16, 30, 0.88),
+ rgba(6, 12, 24, 0.74)
+ );
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.04),
+ 0 2px 8px rgba(0, 0, 0, 0.18);
+ transition: border-color 200ms ease;
+}
+
+.root :global(.home-deb-card:hover),
+.root :global(.home-card-section:hover) {
+ border-color: rgba(34, 211, 238, 0.18);
+}
+
+.root :global(.home-deb-card) {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 12px 14px;
+}
+
+.root :global(.home-deb-card span),
+.root :global(.home-card-section h3) {
+ color: #6FB7FF;
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.root :global(.home-deb-card small),
+.root :global(.home-card-section h3 small) {
+ color: rgba(148, 163, 184, 0.82);
+ font-weight: 700;
+}
+
+.root :global(.home-deb-card strong) {
+ display: inline-block;
+ margin-top: 9px;
+ color: #f8fafc;
+ font-size: 26px;
+ font-weight: 900;
+ letter-spacing: -0.055em;
+}
+
+.root :global(.home-deb-card em) {
+ margin-left: 10px;
+ color: #f87171;
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 850;
+}
+
+.root :global(.home-market-metrics svg) {
+ width: 100%;
+ height: 42px;
+}
+
+.root :global(.home-market-metrics polyline) {
+ fill: none;
+ stroke: #18e6d4;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 2.2;
+}
+
+.root :global(.home-card-section) {
+ padding: 12px 14px;
+}
+
+.root :global(.home-card-section h3) {
+ margin-bottom: 10px;
+ color: rgba(226, 232, 240, 0.94);
+}
+
+.root :global(.home-card-section.intraday) {
+ border-color: rgba(34, 211, 238, 0.2);
+ background:
+ radial-gradient(
+ circle at 0% 35%,
+ rgba(34, 211, 238, 0.12),
+ transparent 44%
+ ),
+ linear-gradient(180deg, rgba(7, 14, 26, 0.94), rgba(9, 17, 30, 0.82));
+}
+
+.root :global(.home-intraday-chart) {
+ position: relative;
+ padding: 6px 0 18px 34px;
+}
+
+.root :global(.home-intraday-chart svg) {
+ width: calc(100% - 34px);
+ margin-left: 34px;
+ height: 78px;
+}
+
+.root :global(.home-intraday-y-axis) {
+ position: absolute;
+ inset: 0 auto 18px 0;
+ width: 30px;
+ pointer-events: none;
+}
+
+.root :global(.home-intraday-y-label) {
+ position: absolute;
+ left: 0;
+ transform: translateY(-50%);
+ color: rgba(148, 163, 184, 0.72);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.root :global(.home-intraday-x-axis) {
+ position: absolute;
+ left: 34px;
+ right: 10px;
+ bottom: 0;
+ height: 14px;
+ pointer-events: none;
+}
+
+.root :global(.home-intraday-x-label) {
+ position: absolute;
+ bottom: 0;
+ transform: translateX(-50%);
+ color: rgba(148, 163, 184, 0.72);
+ font-size: 10px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.root :global(.home-intraday-chart line) {
+ stroke: rgba(115, 137, 161, 0.14);
+ stroke-width: 0.9;
+}
+
+.root :global(.home-intraday-chart polyline) {
+ fill: none;
+ stroke: #22d3ee;
+ stroke-width: 1.8;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ filter: drop-shadow(0 0 8px rgba(34, 211, 238, 0.22));
+}
+
+.root :global(.home-intraday-chart circle) {
+ 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));
+}
+
+.root :global(.home-intraday-reports) {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 7px;
+ margin-top: 8px;
+}
+
+.root :global(.home-intraday-reports span) {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 6px;
+ padding: 7px 8px;
+ border: 1px solid rgba(34, 211, 238, 0.12);
+ border-radius: 10px;
+ background: rgba(2, 8, 18, 0.55);
+}
+
+.root :global(.home-intraday-reports b) {
+ color: rgba(148, 163, 184, 0.9);
+ font-size: 10px;
+ font-weight: 800;
+}
+
+.root :global(.home-intraday-reports strong) {
+ color: #e0f2fe;
+ font-size: 11px;
+ font-weight: 900;
+}
+
+.root :global(.home-card-section.forecast) {
+ border-color: rgba(99, 102, 241, 0.24);
+}
+
+.root :global(.home-forecast-grid) {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.root :global(.home-forecast-item) {
+ min-width: 0;
+ padding: 10px 8px;
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ border-radius: 12px;
+ background: rgba(15, 23, 42, 0.5);
+ text-align: center;
+}
+
+.root :global(.home-forecast-item span) {
+ display: block;
+ color: rgba(148, 163, 184, 0.84);
+ font-size: 10px;
+ font-weight: 800;
+}
+
+.root :global(.home-forecast-item strong) {
+ display: block;
+ margin-top: 6px;
+ color: #f8fafc;
+ font-size: 16px;
+ font-weight: 900;
+ letter-spacing: -0.04em;
+}
+
+.root :global(.home-model-stack) {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+}
+
+.root :global(.home-model-stack span) {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 6px 8px;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: 6px;
+ background: rgba(15, 23, 42, 0.72);
+ color: rgba(226, 232, 240, 0.92);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.root :global(.home-model-stack i),
+.root :global(.key-signals i.active) {
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background: #22c55e;
+ box-shadow: 0 0 10px rgba(34, 197, 94, 0.62);
+}
+
+.root :global(.home-card-section.probability) {
+ border-color: rgba(251, 113, 133, 0.18);
+ background:
+ radial-gradient(
+ circle at 100% 0%,
+ rgba(251, 113, 133, 0.08),
+ transparent 42%
+ ),
+ linear-gradient(180deg, rgba(7, 14, 26, 0.94), rgba(9, 17, 30, 0.82));
+}
+
+.root :global(.home-probability-list),
+.root :global(.home-probability-ladder) {
+ display: grid;
+ gap: 8px;
+}
+
+.root :global(.home-probability-row),
+.root :global(.home-probability-ladder-row) {
+ display: grid;
+ grid-template-columns: 58px minmax(0, 1fr) 42px;
+ align-items: center;
+ gap: 9px;
+ color: rgba(226, 232, 240, 0.95);
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.root :global(.home-probability-row div),
+.root :global(.home-probability-track) {
+ position: relative;
+ height: 14px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: rgba(115, 137, 161, 0.12);
+}
+
+.root :global(.home-probability-row i),
+.root :global(.home-probability-track i) {
+ position: absolute;
+ inset: 0 auto 0 0;
+ border-radius: inherit;
+ background: linear-gradient(90deg, #3b82f6, #18e6d4);
+ box-shadow: 0 0 10px rgba(34, 211, 238, 0.14);
+}
+
+.root :global(.home-probability-row strong),
+.root :global(.home-probability-ladder-row strong) {
+ position: relative;
+ z-index: 1;
+ display: block;
+ color: #f8fafc;
+ font-size: 12px;
+ line-height: 1;
+ text-align: right;
+}
+
+.root :global(.home-probability-threshold) {
+ color: rgba(226, 232, 240, 0.95);
+ font-size: 12px;
+ font-weight: 900;
+}
+
+.root :global(.home-card-empty) {
+ color: rgba(148, 163, 184, 0.9);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.root :global(.home-card-section.market) {
+ position: relative;
+ border-color: rgba(34, 211, 238, 0.16);
+}
+
+.root :global(.home-card-section.market.locked) {
+ border-color: rgba(251, 191, 36, 0.28);
+}
+
+.root :global(.home-market-header),
+.root :global(.home-market-ticket),
+.root :global(.home-market-prices),
+.root :global(.home-market-metrics) {
+ display: flex;
+ align-items: center;
+}
+
+.root :global(.home-market-header),
+.root :global(.home-market-ticket) {
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.root :global(.home-market-header span) {
+ color: rgba(148, 163, 184, 0.88);
+ font-size: 11px;
+ font-weight: 750;
+}
+
+.root :global(.home-market-ticket) {
+ margin-top: 8px;
+}
+
+.root :global(.home-market-question strong) {
+ display: block;
+ color: #f8fafc;
+ font-size: 15px;
+}
+
+.root :global(.home-market-question span) {
+ display: block;
+ margin-top: 3px;
+ color: rgba(148, 163, 184, 0.8);
+ font-size: 11px;
+}
+
+.root :global(.home-market-prices) {
+ gap: 6px;
+}
+
+.root :global(.home-market-prices span) {
+ border-radius: 7px;
+ padding: 7px 9px;
+ font-size: 12px;
+ font-weight: 900;
+}
+
+.root :global(.home-market-prices .yes) {
+ color: #6FB7FF;
+ background: rgba(8, 145, 178, 0.2);
+}
+
+.root :global(.home-market-prices .no) {
+ color: #fca5a5;
+ background: rgba(127, 29, 29, 0.24);
+}
+
+.root :global(.home-market-metrics) {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr)) 94px;
+ gap: 8px;
+ margin-top: 10px;
+}
+
+.root :global(.home-market-metrics span) {
+ color: rgba(148, 163, 184, 0.84);
+ font-size: 11px;
+}
+
+.root :global(.home-market-metrics strong) {
+ display: block;
+ margin-top: 3px;
+ color: #22c55e;
+ font-size: 13px;
+}
+
+.root :global(.home-market-lock) {
+ display: inline-flex;
+ margin-top: 10px;
+ color: #fde68a;
+ font-size: 12px;
+ font-weight: 850;
+ text-decoration: none;
+}
+
+.root :global(.key-signals) {
+ border-color: transparent;
+ background: transparent;
+ padding: 0 4px;
+}
+
+.root :global(.key-signals ul) {
+ display: grid;
+ gap: 7px;
+ list-style: none;
+}
+
+.root :global(.key-signals li) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ color: rgba(203, 213, 225, 0.88);
+ font-size: 12px;
+}
+
+.root :global(.key-signals i) {
+ width: 8px;
+ height: 8px;
+ border-radius: 999px;
+ background: rgba(148, 163, 184, 0.42);
+}
+
+.root :global(.key-signals i.amber.active) {
+ background: #f59e0b;
+ box-shadow: 0 0 10px rgba(245, 158, 11, 0.62);
+}
+
+.root :global(.key-signals p) {
+ margin-top: 9px;
+ color: rgba(100, 116, 139, 0.9);
+ font-size: 11px;
+}
+
+.root :global(.home-intelligence-panel.full .home-pro-card) {
+ margin-top: 0;
+}
+
+.root :global(.home-opportunity-strip) {
+ position: fixed;
+ left: calc(var(--sidebar-width) + 22px);
+ right: 392px;
+ bottom: 18px;
+ z-index: 880;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 16px;
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ border-radius: 24px;
+ background:
+ radial-gradient(
+ circle at 82% -10%,
+ rgba(59, 130, 246, 0.14),
+ transparent 34%
+ ),
+ radial-gradient(
+ circle at 8% 50%,
+ rgba(34, 211, 238, 0.12),
+ transparent 32%
+ ),
+ rgba(7, 13, 25, 0.76);
+ backdrop-filter: blur(20px) saturate(130%);
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.34);
+ animation: opportunity-strip-enter 580ms cubic-bezier(0.16, 1, 0.3, 1) 120ms
+ both;
+}
+
+.root :global(.opportunity-strip-topline) {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 18px;
+}
+
+.root :global(.opportunity-strip-copy) {
+ display: flex;
+ flex-direction: column;
+ gap: 7px;
+ min-width: 0;
+ flex: 1;
+}
+
+.root :global(.opportunity-strip-kicker) {
+ color: rgba(148, 163, 184, 0.8);
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.root :global(.opportunity-strip-copy strong) {
+ color: #f8fafc;
+ font-size: 19px;
+ font-weight: 860;
+ line-height: 1.25;
+ letter-spacing: -0.02em;
+}
+
+.root :global(.opportunity-strip-copy p) {
+ max-width: 620px;
+ color: rgba(191, 219, 254, 0.74);
+ font-size: 12px;
+ line-height: 1.6;
+}
+
+.root :global(.opportunity-strip-tape) {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ justify-content: flex-end;
+ max-width: 460px;
+}
+
+.root :global(.opportunity-tape-pill) {
+ display: flex;
+ min-width: 92px;
+ flex-direction: column;
+ gap: 3px;
+ padding: 10px 12px;
+ border-radius: 14px;
+ border: 1px solid rgba(148, 163, 184, 0.16);
+ background: rgba(9, 16, 30, 0.7);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
+}
+
+.root :global(.opportunity-tape-pill span) {
+ color: rgba(148, 163, 184, 0.82);
+ font-size: 10px;
+ font-weight: 760;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.root :global(.opportunity-tape-pill strong) {
+ color: #f8fafc;
+ font-size: 16px;
+ font-weight: 860;
+ line-height: 1;
+}
+
+.root :global(.opportunity-tape-pill.accent-cyan strong) {
+ color: #6FB7FF;
+}
+
+.root :global(.opportunity-tape-pill.accent-green strong) {
+ color: #4ade80;
+}
+
+.root :global(.opportunity-tape-pill.accent-slate strong) {
+ color: rgba(226, 232, 240, 0.78);
+}
+
+.root :global(.opportunity-tape-pill.accent-outline) {
+ min-width: auto;
+ justify-content: center;
+ padding: 9px 11px;
+ background: rgba(6, 11, 23, 0.62);
+}
+
+.root :global(.opportunity-tape-pill.accent-outline span) {
+ color: rgba(191, 219, 254, 0.92);
+ font-size: 11px;
+ letter-spacing: 0;
+ text-transform: none;
+}
+
+.root :global(.opportunity-strip-main) {
+ display: grid;
+ grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.92fr);
+ gap: 14px;
+}
+
+.root :global(.opportunity-hero-card) {
+ position: relative;
+ overflow: hidden;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-height: 244px;
+ padding: 18px;
+ border: 1px solid rgba(56, 189, 248, 0.18);
+ border-radius: 22px;
+ background:
+ radial-gradient(
+ circle at 100% 0%,
+ rgba(14, 165, 233, 0.22),
+ transparent 28%
+ ),
+ radial-gradient(
+ circle at 12% 88%,
+ rgba(16, 185, 129, 0.1),
+ transparent 28%
+ ),
+ linear-gradient(135deg, rgba(7, 14, 28, 0.96), rgba(12, 27, 48, 0.92));
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+ text-align: left;
+ box-shadow:
+ 0 18px 42px rgba(2, 6, 23, 0.28),
+ inset 0 1px 0 rgba(255, 255, 255, 0.04);
+ transition:
+ transform 160ms ease,
+ border-color 160ms ease,
+ box-shadow 160ms ease;
+}
+
+.root :global(.opportunity-hero-card:hover) {
+ transform: translateY(-2px);
+ border-color: rgba(34, 211, 238, 0.36);
+ box-shadow:
+ 0 24px 52px rgba(2, 6, 23, 0.36),
+ 0 0 0 1px rgba(34, 211, 238, 0.08);
+}
+
+.root :global(.opportunity-hero-header) {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 18px;
+}
+
+.root :global(.opportunity-hero-copy) {
+ display: flex;
+ min-width: 0;
+ flex: 1;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.root :global(.opportunity-hero-kicker) {
+ color: #6FB7FF;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.root :global(.opportunity-hero-title-row) {
+ display: flex;
+ align-items: baseline;
+ gap: 10px;
+ min-width: 0;
+}
+
+.root :global(.opportunity-hero-title-row strong) {
+ overflow: hidden;
+ color: #f8fafc;
+ font-size: 28px;
+ font-weight: 880;
+ line-height: 1;
+ letter-spacing: -0.04em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.root :global(.opportunity-hero-date) {
+ color: rgba(186, 230, 253, 0.76);
+ font-size: 12px;
+ font-weight: 720;
+}
+
+.root :global(.opportunity-hero-copy p) {
+ max-width: 520px;
+ color: rgba(226, 232, 240, 0.82);
+ font-size: 13px;
+ line-height: 1.55;
+}
+
+.root :global(.opportunity-hero-tags) {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.root :global(.opportunity-hero-tag) {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 30px;
+ padding: 0 11px;
+ border-radius: 999px;
+ border: 1px solid rgba(148, 163, 184, 0.16);
+ background: rgba(7, 13, 25, 0.58);
+ color: rgba(226, 232, 240, 0.92);
+ font-size: 11px;
+ font-weight: 820;
+ white-space: nowrap;
+}
+
+.root :global(.opportunity-hero-tag.engine) {
+ border-color: rgba(34, 211, 238, 0.24);
+ color: #6FB7FF;
+}
+
+.root :global(.opportunity-hero-tag.signal-yes) {
+ border-color: rgba(74, 222, 128, 0.26);
+ color: #86efac;
+}
+
+.root :global(.opportunity-hero-tag.signal-no) {
+ border-color: rgba(251, 113, 133, 0.24);
+ color: #fda4af;
+}
+
+.root :global(.opportunity-hero-tag.signal-neutral) {
+ color: rgba(191, 219, 254, 0.88);
+}
+
+.root :global(.opportunity-hero-tag.risk.high) {
+ border-color: rgba(251, 113, 133, 0.24);
+ color: #fda4af;
+}
+
+.root :global(.opportunity-hero-tag.risk.medium) {
+ border-color: rgba(251, 191, 36, 0.24);
+ color: #fcd34d;
+}
+
+.root :global(.opportunity-hero-tag.risk.low) {
+ border-color: rgba(74, 222, 128, 0.24);
+ color: #86efac;
+}
+
+.root :global(.opportunity-hero-body) {
+ display: grid;
+ gap: 14px;
+}
+
+.root :global(.opportunity-hero-edgeblock) {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 14px 16px;
+ border-radius: 18px;
+ border: 1px solid rgba(34, 211, 238, 0.18);
+ background: linear-gradient(
+ 135deg,
+ rgba(8, 15, 28, 0.84),
+ rgba(13, 30, 52, 0.82)
+ );
+}
+
+.root :global(.opportunity-hero-edgeblock span) {
+ color: rgba(148, 163, 184, 0.8);
+ font-size: 11px;
+ font-weight: 760;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.root :global(.opportunity-hero-edgeblock strong) {
+ color: #4ade80;
+ font-size: 44px;
+ font-weight: 900;
+ line-height: 0.95;
+ letter-spacing: -0.05em;
+}
+
+.root :global(.opportunity-hero-edgeblock em) {
+ color: rgba(191, 219, 254, 0.84);
+ font-size: 12px;
+ font-style: normal;
+ line-height: 1.5;
+}
+
+.root :global(.opportunity-hero-metrics) {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.root :global(.opportunity-hero-metric) {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 5px;
+ padding: 12px 13px;
+ border-radius: 14px;
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ background: rgba(9, 16, 30, 0.6);
+}
+
+.root :global(.opportunity-hero-metric span) {
+ color: rgba(148, 163, 184, 0.8);
+ font-size: 10px;
+ font-weight: 760;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.root :global(.opportunity-hero-metric strong) {
+ overflow: hidden;
+ color: #f8fafc;
+ font-size: 20px;
+ font-weight: 860;
+ line-height: 1;
+ letter-spacing: -0.03em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.root :global(.opportunity-hero-book) {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(110px, 132px)) minmax(0, 1fr);
+ gap: 12px;
+ align-items: stretch;
+}
+
+.root :global(.opportunity-book-side) {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 6px;
+ padding: 12px 14px;
+ border-radius: 16px;
+ border: 1px solid rgba(148, 163, 184, 0.16);
+ background: rgba(6, 11, 23, 0.62);
+}
+
+.root :global(.opportunity-book-side span) {
+ font-size: 11px;
+ font-weight: 760;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.root :global(.opportunity-book-side strong) {
+ font-size: 26px;
+ font-weight: 880;
+ line-height: 1;
+ letter-spacing: -0.03em;
+}
+
+.root :global(.opportunity-book-side.yes) {
+ border-color: rgba(34, 211, 238, 0.22);
+}
+
+.root :global(.opportunity-book-side.yes span),
+.root :global(.opportunity-book-side.yes strong) {
+ color: #22d3ee;
+}
+
+.root :global(.opportunity-book-side.no) {
+ border-color: rgba(251, 113, 133, 0.22);
+}
+
+.root :global(.opportunity-book-side.no span),
+.root :global(.opportunity-book-side.no strong) {
+ color: #fda4af;
+}
+
+.root :global(.opportunity-hero-sparkline-wrap) {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px 14px;
+ border-radius: 16px;
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ background:
+ radial-gradient(
+ circle at 100% 0%,
+ rgba(59, 130, 246, 0.14),
+ transparent 32%
+ ),
+ rgba(8, 15, 28, 0.66);
+}
+
+.root :global(.opportunity-hero-sparkline-wrap span) {
+ color: rgba(148, 163, 184, 0.8);
+ font-size: 10px;
+ font-weight: 760;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.root :global(.opportunity-hero-sparkline) {
+ width: 100%;
+ height: 44px;
+}
+
+.root :global(.opportunity-hero-sparkline polyline) {
+ fill: none;
+ stroke: #18e6d4;
+ stroke-width: 2.4;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ filter: drop-shadow(0 0 10px rgba(34, 211, 238, 0.26));
+}
+
+.root :global(.opportunity-hero-footer) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ color: rgba(191, 219, 254, 0.82);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.root :global(.opportunity-side-grid) {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.root :global(.opportunity-side-tile) {
+ display: flex;
+ min-height: 116px;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px;
+ border-radius: 18px;
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ background:
+ radial-gradient(
+ circle at 100% 0%,
+ rgba(30, 64, 175, 0.12),
+ transparent 30%
+ ),
+ rgba(9, 16, 30, 0.68);
+}
+
+.root :global(.opportunity-side-tile-head span) {
+ color: rgba(241, 245, 249, 0.92);
+ font-size: 13px;
+ font-weight: 790;
+}
+
+.root :global(.opportunity-side-tile-body) {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.root :global(.opportunity-side-stat) {
+ display: flex;
+ min-width: 0;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.root :global(.opportunity-side-stat b) {
+ overflow: hidden;
+ color: #f8fafc;
+ font-size: 24px;
+ font-weight: 880;
+ line-height: 1;
+ letter-spacing: -0.04em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.root :global(.opportunity-side-stat span) {
+ color: rgba(148, 163, 184, 0.82);
+ font-size: 10px;
+ font-weight: 680;
+}
+
+.root :global(.opportunity-side-stat b.accent-red) {
+ color: #fb7185;
+}
+
+.root :global(.opportunity-side-stat b.accent-amber) {
+ color: #fbbf24;
+}
+
+.root :global(.opportunity-side-stat b.accent-green) {
+ color: #4ade80;
+}
+
+.root :global(.opportunity-side-stat b.accent-cyan) {
+ color: #22d3ee;
+}
+
+.root :global(.opportunity-mini-grid) {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.root :global(.opportunity-empty-state) {
+ display: flex;
+ min-height: 168px;
+ align-items: center;
+ justify-content: center;
+ padding: 0 18px;
+ border: 1px dashed rgba(34, 211, 238, 0.2);
+ border-radius: 16px;
+ background:
+ radial-gradient(
+ circle at 50% 0%,
+ rgba(34, 211, 238, 0.08),
+ transparent 44%
+ ),
+ rgba(7, 12, 22, 0.72);
+}
+
+.root :global(.opportunity-empty-copy) {
+ display: grid;
+ gap: 6px;
+ justify-items: center;
+ text-align: center;
+}
+
+.root :global(.opportunity-empty-copy strong) {
+ color: rgba(241, 245, 249, 0.94);
+ font-size: 15px;
+ font-weight: 840;
+}
+
+.root :global(.opportunity-empty-copy span) {
+ color: rgba(148, 163, 184, 0.88);
+ font-size: 12px;
+ line-height: 1.6;
+}
+
+.root :global(.opportunity-mini-card) {
+ position: relative;
+ overflow: hidden;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-height: 152px;
+ padding: 14px;
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ border-radius: 18px;
+ background:
+ radial-gradient(
+ circle at 100% 0%,
+ rgba(30, 64, 175, 0.12),
+ transparent 32%
+ ),
+ rgba(10, 16, 28, 0.84);
+ color: inherit;
+ cursor: pointer;
+ font: inherit;
+ text-align: left;
+ transition:
+ transform 160ms ease,
+ border-color 160ms ease,
+ background 160ms ease;
+}
+
+.root :global(.opportunity-mini-card:hover) {
+ transform: translateY(-2px);
+ border-color: rgba(34, 211, 238, 0.32);
+ background:
+ radial-gradient(
+ circle at 100% 0%,
+ rgba(30, 64, 175, 0.18),
+ transparent 32%
+ ),
+ rgba(15, 23, 42, 0.92);
+}
+
+.root :global(.opportunity-mini-head) {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.root :global(.opportunity-mini-rank) {
+ width: 30px;
+ height: 30px;
+ border-radius: 12px;
+ border: 1px solid rgba(148, 163, 184, 0.2);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: #f8fafc;
+ font-size: 13px;
+ font-weight: 840;
+ flex-shrink: 0;
+ background: rgba(6, 11, 23, 0.46);
+}
+
+.root :global(.opportunity-mini-copy) {
+ display: flex;
+ min-width: 0;
+ flex: 1;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.root :global(.opportunity-pill) {
+ margin-left: auto;
+ padding: 4px 8px;
+ border-radius: 999px;
+ font-size: 10px;
+ font-weight: 800;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ color: rgba(226, 232, 240, 0.86);
+}
+
+.root :global(.opportunity-pill.high) {
+ color: #fda4af;
+ border-color: rgba(251, 113, 133, 0.26);
+}
+
+.root :global(.opportunity-pill.medium) {
+ color: #fbbf24;
+ border-color: rgba(251, 191, 36, 0.24);
+}
+
+.root :global(.opportunity-pill.low) {
+ color: #4ade80;
+ border-color: rgba(74, 222, 128, 0.24);
+}
+
+.root :global(.opportunity-mini-copy strong) {
+ overflow: hidden;
+ color: #f8fafc;
+ font-size: 15px;
+ font-weight: 860;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.root :global(.opportunity-mini-copy p) {
+ display: -webkit-box;
+ overflow: hidden;
+ color: rgba(203, 213, 225, 0.88);
+ font-size: 11px;
+ line-height: 1.45;
+ min-height: 32px;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+}
+
+.root :global(.opportunity-mini-metrics) {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+}
+
+.root :global(.opportunity-yes) {
+ color: #22d3ee;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.root :global(.opportunity-no) {
+ color: #fda4af;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.root :global(.opportunity-edge) {
+ color: rgba(34, 211, 238, 0.9);
+ font-size: 18px;
+ font-weight: 800;
+}
+
+.root :global(.opportunity-mini-sparkline) {
+ width: 100%;
+ height: 28px;
+ margin-top: auto;
+}
+
+.root :global(.opportunity-mini-sparkline polyline) {
+ fill: none;
+ stroke: #18e6d4;
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ filter: drop-shadow(0 0 8px rgba(34, 211, 238, 0.22));
+}
+
+/* ── Light Theme Dashboard Overrides ── */
+:global(html.light) .root {
+ background:
+ radial-gradient(
+ circle at 22% 12%,
+ rgba(14, 165, 233, 0.16),
+ transparent 28%
+ ),
+ radial-gradient(
+ circle at 78% 0%,
+ rgba(59, 130, 246, 0.12),
+ transparent 24%
+ ),
+ linear-gradient(180deg, #f8fbff 0%, #e7f1fb 100%);
+}
+
+:global(html.light) .root :global(.map) {
+ background: #dfeefa;
+ border-color: rgba(36, 68, 104, 0.18);
+ filter: none;
+ box-shadow:
+ 0 28px 80px rgba(31, 64, 104, 0.18),
+ inset 0 1px 0 rgba(255, 255, 255, 0.86);
+}
+
+:global(html.light) .root :global(.map .leaflet-tile) {
+ filter: saturate(1.08) contrast(0.98) brightness(1.02);
+}
+
+:global(html.light) .weatherAura {
+ opacity: 0.22;
+ mix-blend-mode: multiply;
+}
+
+:global(html.light) .weatherAuraScrim {
+ background:
+ linear-gradient(
+ 180deg,
+ rgba(248, 252, 255, 0.2) 0%,
+ rgba(248, 252, 255, 0.08) 42%,
+ rgba(226, 239, 250, 0.28) 100%
+ ),
+ radial-gradient(
+ circle at 50% 58%,
+ rgba(14, 165, 233, 0.08) 0%,
+ rgba(14, 165, 233, 0) 52%
+ );
+}
+
+:global(html.light) .root :global(.home-panel-city h2),
+:global(html.light) .root :global(.home-weather-main strong),
+:global(html.light) .root :global(.home-weather-stat strong),
+:global(html.light) .root :global(.home-deb-card strong),
+:global(html.light) .root :global(.home-card-section h3),
+:global(html.light) .root :global(.opportunity-strip-copy strong),
+:global(html.light) .root :global(.opportunity-hero-title-row strong),
+:global(html.light) .root :global(.opportunity-hero-metric strong),
+:global(html.light) .root :global(.opportunity-hero-edgeblock strong),
+:global(html.light) .root :global(.opportunity-book-side strong),
+:global(html.light) .root :global(.opportunity-side-stat b),
+:global(html.light) .root :global(.opportunity-mini-copy strong),
+:global(html.light) .root :global(.city-item .city-name-text) {
+ color: #0b1726;
+}
+
+:global(html.light) .root :global(.home-panel-subtitle),
+:global(html.light) .root :global(.home-weather-sub),
+:global(html.light) .root :global(.home-weather-label),
+:global(html.light) .root :global(.home-weather-stat span),
+:global(html.light) .root :global(.home-weather-stat small),
+:global(html.light) .root :global(.home-deb-card small),
+:global(html.light) .root :global(.home-card-section h3 small),
+:global(html.light) .root :global(.opportunity-strip-kicker),
+:global(html.light) .root :global(.opportunity-strip-copy p),
+:global(html.light) .root :global(.opportunity-tape-pill span),
+:global(html.light) .root :global(.opportunity-hero-kicker),
+:global(html.light) .root :global(.opportunity-hero-date),
+:global(html.light) .root :global(.opportunity-hero-copy p),
+:global(html.light) .root :global(.opportunity-hero-metric span),
+:global(html.light) .root :global(.opportunity-hero-edgeblock span),
+:global(html.light) .root :global(.opportunity-hero-edgeblock em),
+:global(html.light) .root :global(.opportunity-hero-footer),
+:global(html.light) .root :global(.opportunity-side-tile-head span),
+:global(html.light) .root :global(.opportunity-side-stat span),
+:global(html.light) .root :global(.opportunity-mini-copy p),
+:global(html.light) .root :global(.city-group-title),
+:global(html.light) .root :global(.home-panel-kicker),
+:global(html.light) .root :global(.home-panel-airport) {
+ color: rgba(66, 84, 108, 0.86);
+}
+
+:global(html.light) .root :global(.header) {
+ background: rgba(248, 252, 255, 0.9);
+ border-bottom-color: rgba(36, 68, 104, 0.14);
+ box-shadow: 0 12px 34px rgba(31, 64, 104, 0.08);
+}
+
+:global(html.light) .root :global(.brand-mark) {
+ background: rgba(255, 255, 255, 0.82);
+ border-color: rgba(14, 165, 233, 0.24);
+ box-shadow:
+ inset 0 0 0 1px rgba(255, 255, 255, 0.72),
+ 0 10px 24px rgba(31, 64, 104, 0.12);
+}
+
+:global(html.light) .root :global(.locale-switch) {
+ background: rgba(255, 255, 255, 0.82);
+ border-color: rgba(36, 68, 104, 0.16);
+ color: rgba(66, 84, 108, 0.78);
+}
+
+:global(html.light) .root :global(.locale-switch span.active) {
+ color: #075985;
+ background: rgba(14, 165, 233, 0.14);
+ box-shadow: inset 0 0 0 1px rgba(14, 165, 233, 0.22);
+}
+
+:global(html.light) .root :global(.city-list),
+:global(html.light) .root :global(.home-intelligence-panel),
+:global(html.light) .root :global(.home-opportunity-strip) {
+ background:
+ radial-gradient(
+ circle at 12% 0%,
+ rgba(14, 165, 233, 0.08),
+ transparent 34%
+ ),
+ rgba(248, 252, 255, 0.9);
+ border-color: rgba(36, 68, 104, 0.16);
+ box-shadow: 0 28px 70px rgba(31, 64, 104, 0.14);
+ color: #0b1726;
+}
+
+:global(html.light) .root :global(.home-summary-card),
+:global(html.light) .root :global(.opportunity-hero-card),
+:global(html.light) .root :global(.opportunity-side-tile),
+:global(html.light) .root :global(.opportunity-mini-card),
+:global(html.light) .root :global(.opportunity-tape-pill),
+:global(html.light) .root :global(.opportunity-hero-metric),
+:global(html.light) .root :global(.opportunity-book-side),
+:global(html.light) .root :global(.opportunity-hero-sparkline-wrap),
+:global(html.light) .root :global(.home-deb-card),
+:global(html.light) .root :global(.home-card-section),
+:global(html.light) .root :global(.home-metric-card),
+:global(html.light) .root :global(.home-forecast-item),
+:global(html.light) .root :global(.home-intraday-reports span) {
+ background:
+ radial-gradient(
+ circle at 100% 0%,
+ rgba(14, 165, 233, 0.08),
+ transparent 36%
+ ),
+ rgba(255, 255, 255, 0.92);
+ border-color: rgba(36, 68, 104, 0.14);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
+ color: #0b1726;
+}
+
+:global(html.light) .root :global(.home-weather-hero) {
+ background: linear-gradient(
+ 135deg,
+ rgba(238, 246, 255, 0.98),
+ rgba(216, 233, 248, 0.92)
+ );
+ border-color: rgba(36, 68, 104, 0.14);
+}
+
+:global(html.light) .root :global(.home-card-section.intraday),
+:global(html.light) .root :global(.home-card-section.probability) {
+ background:
+ radial-gradient(circle at 0% 25%, rgba(14, 165, 233, 0.1), transparent 42%),
+ rgba(255, 255, 255, 0.92);
+}
+
+:global(html.light) .root :global(.home-intraday-chart line) {
+ stroke: rgba(66, 84, 108, 0.16);
+}
+
+:global(html.light) .root :global(.home-intraday-reports strong),
+:global(html.light) .root :global(.home-probability-threshold),
+:global(html.light) .root :global(.home-market-question strong),
+:global(html.light) .root :global(.home-market-metrics strong),
+:global(html.light) .root :global(.home-forecast-item strong) {
+ color: #0b1726;
+}
+
+:global(html.light) .root :global(.home-probability-row div),
+:global(html.light) .root :global(.home-probability-track) {
+ background: rgba(37, 57, 82, 0.12);
+}
+
+:global(html.light) .root :global(.city-group) {
+ background: rgba(255, 255, 255, 0.54);
+ border-color: rgba(36, 68, 104, 0.12);
+}
+
+:global(html.light) .root :global(.city-group-header:hover),
+:global(html.light) .root :global(.city-item:hover) {
+ background: rgba(221, 235, 250, 0.86);
+}
+
+:global(html.light) .root :global(.city-item.active) {
+ background:
+ linear-gradient(90deg, rgba(14, 165, 233, 0.18), rgba(14, 165, 233, 0.06)),
+ rgba(255, 255, 255, 0.88);
+ border-color: rgba(14, 165, 233, 0.42);
+}
+
+:global(html.light) .root :global(.leaflet-control-attribution),
+:global(html.light) .root :global(.leaflet-control-zoom a) {
+ background: rgba(248, 252, 255, 0.86) !important;
+ border-color: rgba(36, 68, 104, 0.16) !important;
+ color: #0b1726 !important;
+}
+
+@keyframes home-panel-enter {
+ from {
+ opacity: 0;
+ transform: translate3d(24px, 0, 0) scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: translate3d(0, 0, 0) scale(1);
+ }
+}
+
+@keyframes opportunity-strip-enter {
+ from {
+ opacity: 0;
+ transform: translate3d(0, 22px, 0);
+ }
+ to {
+ opacity: 1;
+ transform: translate3d(0, 0, 0);
+ }
+}
+
+@keyframes home-signal-wave {
+ 0%,
+ 100% {
+ transform: scaleY(0.72);
+ opacity: 0.68;
+ }
+ 50% {
+ transform: scaleY(1);
+ opacity: 1;
+ }
+}
+
+@media (max-width: 1360px) {
+ .root :global(.map) {
+ right: 18px;
+ }
+
+ .root :global(.home-intelligence-panel) {
+ display: none;
+ }
+
+ .root :global(.home-opportunity-strip) {
+ right: 18px;
+ }
+}
+
+@media (max-width: 1240px) {
+ .root :global(.opportunity-strip-topline) {
+ flex-direction: column;
+ }
+
+ .root :global(.opportunity-strip-tape) {
+ max-width: none;
+ justify-content: flex-start;
+ }
+
+ .root :global(.opportunity-hero-metrics) {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .root :global(.opportunity-hero-book) {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .root :global(.opportunity-hero-sparkline-wrap) {
+ grid-column: 1 / -1;
+ }
+
+ .root :global(.opportunity-mini-grid) {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 1020px) {
+ .root :global(.map) {
+ left: 18px;
+ bottom: 188px;
+ }
+
+ .root :global(.home-opportunity-strip) {
+ left: 18px;
+ }
+
+ .root :global(.opportunity-strip-main) {
+ grid-template-columns: 1fr;
+ }
+
+ .root :global(.opportunity-side-grid) {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 720px) {
+ .root :global(.map) {
+ inset: var(--header-height) 0 0;
+ border-radius: 0;
+ border-left: 0;
+ border-right: 0;
+ }
+
+ .root :global(.home-opportunity-strip) {
+ display: none;
+ }
+}
diff --git a/frontend/components/dashboard/DashboardMap.module.css b/frontend/components/dashboard/DashboardMap.module.css
new file mode 100644
index 00000000..b88e83a3
--- /dev/null
+++ b/frontend/components/dashboard/DashboardMap.module.css
@@ -0,0 +1,846 @@
+/* ── Map ── */
+.root :global(.map) {
+ position: absolute;
+ top: calc(var(--header-height) + 16px);
+ right: 392px;
+ bottom: 164px;
+ left: calc(var(--sidebar-width) + 22px);
+ z-index: 1;
+ overflow: hidden;
+ border: 1px solid rgba(115, 137, 161, 0.16);
+ border-radius: 24px;
+ background: #040912;
+ box-shadow:
+ 0 32px 100px rgba(0, 0, 0, 0.5),
+ inset 0 1px 0 rgba(255, 255, 255, 0.04),
+ inset 0 0 0 1px rgba(0, 224, 164, 0.04);
+}
+
+.weatherAura {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ pointer-events: none;
+ opacity: 0.96;
+ mix-blend-mode: screen;
+ overflow: hidden;
+}
+
+.weatherAura :global(canvas) {
+ width: 100%;
+ height: 100%;
+ display: block;
+ opacity: 0.94;
+ filter: saturate(1.05) blur(0.3px);
+}
+
+.weatherAuraScrim {
+ position: absolute;
+ inset: 0;
+ background:
+ linear-gradient(
+ 180deg,
+ rgba(3, 8, 19, 0.64) 0%,
+ rgba(5, 10, 20, 0.16) 24%,
+ rgba(4, 8, 18, 0.1) 54%,
+ rgba(3, 6, 14, 0.42) 100%
+ ),
+ radial-gradient(
+ circle at 50% 60%,
+ rgba(0, 224, 164, 0.08) 0%,
+ rgba(123, 97, 255, 0) 48%
+ );
+}
+
+.weatherAura[data-reduced-motion="true"] :global(canvas) {
+ display: none;
+}
+
+.root :global(.map .leaflet-tile),
+.root :global(.map .leaflet-marker-icon),
+.root :global(.map .leaflet-marker-shadow),
+.root :global(.map .leaflet-container img),
+.root :global(.map .leaflet-container svg) {
+ max-width: none !important;
+ max-height: none !important;
+}
+
+.root :global(.map .leaflet-tile) {
+ width: 256px !important;
+ height: 256px !important;
+}
+
+/* Remove Leaflet default styling for cleaner look */
+.root :global(.leaflet-control-attribution) {
+ background: var(--bg-glass) !important;
+ color: var(--text-muted) !important;
+ backdrop-filter: blur(8px);
+ border: 1px solid var(--border-subtle) !important;
+ font-size: 10px !important;
+ border-radius: 6px !important;
+ padding: 2px 8px !important;
+}
+.root :global(.leaflet-control-attribution a) {
+ color: var(--text-secondary) !important;
+}
+.root :global(.leaflet-control-zoom) {
+ border: none !important;
+ box-shadow: var(--shadow-lg) !important;
+}
+.root :global(.leaflet-control-zoom a) {
+ background: var(--bg-glass) !important;
+ color: var(--text-primary) !important;
+ backdrop-filter: blur(12px) !important;
+ border: 1px solid var(--border-glass) !important;
+ width: 36px !important;
+ height: 36px !important;
+ line-height: 36px !important;
+ font-size: 16px !important;
+ border-radius: 8px !important;
+ transition: var(--transition);
+}
+.root :global(.leaflet-control-zoom a:hover) {
+ background: rgba(99, 102, 241, 0.2) !important;
+ border-color: var(--accent-blue) !important;
+}
+
+/* ── Custom Map Markers ── */
+.root :global(.city-marker) {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ cursor: pointer;
+ /* Remove transition: transform to avoid fighting with Leaflet's own positioning */
+}
+.root :global(.city-marker:hover) {
+ transform: scale(1.15);
+ z-index: 1000 !important;
+}
+
+.root :global(.marker-bubble) {
+ min-width: 44px;
+ padding: 4px 10px;
+ border-radius: 14px;
+ font-family: "Inter", sans-serif;
+ font-size: 13px;
+ font-weight: 700;
+ text-align: center;
+ color: white;
+ white-space: nowrap;
+ position: relative;
+ box-shadow:
+ 0 6px 18px rgba(0, 0, 0, 0.42),
+ 0 0 0 1px rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.14);
+}
+
+.root :global(.marker-bubble::after) {
+ content: "";
+ position: absolute;
+ bottom: -6px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 0;
+ height: 0;
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-top: 6px solid;
+ border-top-color: inherit;
+}
+
+.root :global(.marker-bubble.risk-high) {
+ background: linear-gradient(135deg, #991b1b, #fb7185);
+ border-color: rgba(251, 113, 133, 0.42);
+ box-shadow:
+ 0 6px 18px rgba(0, 0, 0, 0.42),
+ 0 0 22px rgba(251, 113, 133, 0.18);
+}
+.root :global(.marker-bubble.risk-high::after) {
+ border-top-color: #ef4444;
+}
+
+.root :global(.marker-bubble.risk-medium) {
+ background: linear-gradient(135deg, #9a3412, #f59e0b);
+ border-color: rgba(245, 158, 11, 0.42);
+ box-shadow:
+ 0 6px 18px rgba(0, 0, 0, 0.42),
+ 0 0 22px rgba(245, 158, 11, 0.16);
+}
+.root :global(.marker-bubble.risk-medium::after) {
+ border-top-color: #f59e0b;
+}
+
+.root :global(.marker-bubble.risk-low) {
+ background: linear-gradient(135deg, #065f46, #22c55e);
+ border-color: rgba(74, 222, 128, 0.42);
+ box-shadow:
+ 0 6px 18px rgba(0, 0, 0, 0.42),
+ 0 0 22px rgba(74, 222, 128, 0.14);
+}
+.root :global(.marker-bubble.risk-low::after) {
+ border-top-color: #22C55E;
+}
+
+.root :global(.marker-name) {
+ font-size: 10px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.85);
+ text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
+ margin-top: 2px;
+ white-space: nowrap;
+}
+
+/* Marker glow animation for selected city */
+.root :global(.city-marker.selected .marker-bubble) {
+ animation: markerGlow 2s ease-in-out infinite;
+}
+@keyframes markerGlow {
+ 0%,
+ 100% {
+ box-shadow:
+ 0 6px 18px rgba(0, 0, 0, 0.42),
+ 0 0 24px rgba(34, 211, 238, 0.12);
+ }
+ 50% {
+ box-shadow:
+ 0 6px 26px rgba(0, 0, 0, 0.46),
+ 0 0 20px rgba(34, 211, 238, 0.24),
+ 0 0 46px rgba(34, 211, 238, 0.14);
+ }
+}
+
+/* ── Loading Overlay ── */
+.root :global(.loading-overlay) {
+ position: fixed;
+ inset: 0;
+ z-index: 2000;
+ background: radial-gradient(
+ circle at 50% 50%,
+ rgba(8, 14, 32, 0.76) 0%,
+ rgba(4, 8, 22, 0.9) 44%,
+ rgba(2, 6, 20, 0.96) 100%
+ );
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ backdrop-filter: blur(12px);
+}
+
+.root :global(.loading-card) {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 22px;
+ min-width: 240px;
+ padding: 28px 32px;
+ border-radius: 28px;
+ background: linear-gradient(
+ 180deg,
+ rgba(10, 18, 36, 0.78) 0%,
+ rgba(8, 14, 30, 0.62) 100%
+ );
+ border: 1px solid rgba(92, 142, 255, 0.16);
+ box-shadow:
+ 0 24px 90px rgba(0, 0, 0, 0.42),
+ inset 0 1px 0 rgba(255, 255, 255, 0.04);
+}
+
+.root :global(.loading-clouds) {
+ position: relative;
+ width: 220px;
+ height: 26px;
+ overflow: hidden;
+}
+
+.root :global(.loading-cloud) {
+ position: absolute;
+ top: 0;
+ height: 18px;
+ border-radius: 999px;
+ background:
+ radial-gradient(
+ circle at 30% 50%,
+ rgba(186, 230, 253, 0.22),
+ transparent 54%
+ ),
+ linear-gradient(
+ 180deg,
+ rgba(148, 163, 184, 0.28) 0%,
+ rgba(71, 85, 105, 0.1) 100%
+ );
+ filter: blur(0.3px);
+ box-shadow:
+ 0 6px 24px rgba(56, 189, 248, 0.06),
+ inset 0 1px 0 rgba(255, 255, 255, 0.08);
+ animation: cloud-drift 6.8s ease-in-out infinite;
+}
+
+.root :global(.loading-cloud-1) {
+ left: 12px;
+ width: 76px;
+}
+
+.root :global(.loading-cloud-2) {
+ right: 8px;
+ top: 7px;
+ width: 98px;
+ opacity: 0.75;
+ animation-delay: -2.6s;
+}
+
+.root :global(.loading-windfield) {
+ position: relative;
+ width: 220px;
+ height: 30px;
+ overflow: hidden;
+ opacity: 0.9;
+}
+
+.root :global(.loading-windline) {
+ position: absolute;
+ left: -28%;
+ height: 1px;
+ border-radius: 999px;
+ background: linear-gradient(
+ 90deg,
+ rgba(34, 211, 238, 0) 0%,
+ rgba(103, 232, 249, 0.5) 22%,
+ rgba(125, 211, 252, 0.9) 55%,
+ rgba(34, 211, 238, 0) 100%
+ );
+ filter: blur(0.2px);
+ animation: wind-shift 2.8s linear infinite;
+}
+
+.root :global(.loading-windline-1) {
+ top: 4px;
+ width: 124px;
+}
+
+.root :global(.loading-windline-2) {
+ top: 13px;
+ width: 176px;
+ animation-delay: -0.9s;
+ opacity: 0.82;
+}
+
+.root :global(.loading-windline-3) {
+ top: 22px;
+ width: 142px;
+ animation-delay: -1.6s;
+ opacity: 0.66;
+}
+
+.root :global(.loading-copy) {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ text-align: center;
+}
+
+.root :global(.loading-copy strong) {
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.22em;
+ text-transform: uppercase;
+ color: rgba(103, 232, 249, 0.92);
+}
+
+.root :global(.loading-copy span) {
+ max-width: 240px;
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.55;
+ color: rgba(203, 213, 225, 0.82);
+}
+
+.root :global(.loading-radar) {
+ position: relative;
+ width: 108px;
+ height: 108px;
+ border-radius: 50%;
+ background: radial-gradient(
+ circle,
+ rgba(22, 78, 99, 0.16) 0%,
+ rgba(7, 18, 36, 0.08) 48%,
+ rgba(5, 10, 22, 0.02) 76%,
+ transparent 100%
+ );
+ box-shadow:
+ inset 0 0 0 1px rgba(56, 189, 248, 0.12),
+ 0 0 40px rgba(34, 211, 238, 0.08);
+}
+
+.root :global(.loading-radar-core) {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: radial-gradient(circle, #6FB7FF 0%, #0891b2 100%);
+ transform: translate(-50%, -50%);
+ box-shadow:
+ 0 0 12px rgba(34, 211, 238, 0.65),
+ 0 0 28px rgba(59, 130, 246, 0.25);
+}
+
+.root :global(.loading-radar-ring) {
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ border: 1px solid rgba(56, 189, 248, 0.14);
+}
+
+.root :global(.loading-radar-ring-1) {
+ transform: scale(0.62);
+}
+
+.root :global(.loading-radar-ring-2) {
+ transform: scale(0.88);
+}
+
+.root :global(.loading-radar-sweep) {
+ position: absolute;
+ inset: 6px;
+ border-radius: 50%;
+ background: conic-gradient(
+ from 90deg,
+ rgba(34, 211, 238, 0) 0deg,
+ rgba(34, 211, 238, 0) 260deg,
+ rgba(34, 211, 238, 0.3) 312deg,
+ rgba(96, 165, 250, 0.06) 360deg
+ );
+ mask: radial-gradient(circle, transparent 0 22px, #000 23px);
+ animation: radar-sweep 2.4s linear infinite;
+}
+
+.root :global(.loading-radar-blip) {
+ position: absolute;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: rgba(253, 224, 71, 0.92);
+ box-shadow: 0 0 12px rgba(253, 224, 71, 0.45);
+ animation: radar-blip 1.8s ease-in-out infinite;
+}
+
+.root :global(.loading-radar-blip-1) {
+ top: 26px;
+ right: 24px;
+ animation-delay: 0.15s;
+}
+
+.root :global(.loading-radar-blip-2) {
+ bottom: 25px;
+ left: 20px;
+ background: rgba(74, 222, 128, 0.9);
+ box-shadow: 0 0 12px rgba(74, 222, 128, 0.4);
+ animation-delay: 0.9s;
+}
+
+.root :global(.loading-thermals) {
+ display: flex;
+ align-items: end;
+ gap: 8px;
+ height: 28px;
+}
+
+.root :global(.loading-thermal) {
+ width: 10px;
+ border-radius: 999px;
+ background: linear-gradient(
+ 180deg,
+ rgba(251, 191, 36, 0.15) 0%,
+ rgba(251, 146, 60, 0.8) 55%,
+ rgba(239, 68, 68, 0.9) 100%
+ );
+ box-shadow: 0 0 18px rgba(251, 146, 60, 0.18);
+ animation: thermal-rise 1.5s ease-in-out infinite;
+ transform-origin: center bottom;
+}
+
+.root :global(.loading-thermal-1) {
+ height: 11px;
+ animation-delay: 0s;
+}
+
+.root :global(.loading-thermal-2) {
+ height: 22px;
+ animation-delay: 0.2s;
+}
+
+.root :global(.loading-thermal-3) {
+ height: 16px;
+ animation-delay: 0.42s;
+}
+
+.root :global(.loading-thermal-4) {
+ height: 25px;
+ animation-delay: 0.68s;
+}
+
+.root :global(.loading-drizzle) {
+ position: relative;
+ width: 220px;
+ height: 26px;
+ overflow: hidden;
+ opacity: 0.88;
+}
+
+.root :global(.loading-drizzle-drop) {
+ position: absolute;
+ top: -2px;
+ width: 2px;
+ height: 16px;
+ border-radius: 999px;
+ background: linear-gradient(
+ 180deg,
+ rgba(125, 211, 252, 0) 0%,
+ rgba(125, 211, 252, 0.9) 100%
+ );
+ box-shadow: 0 0 8px rgba(56, 189, 248, 0.12);
+ animation: drizzle-fall 1.35s linear infinite;
+}
+
+.root :global(.loading-drizzle-drop-1) {
+ left: 38px;
+ animation-delay: -0.1s;
+}
+
+.root :global(.loading-drizzle-drop-2) {
+ left: 76px;
+ height: 18px;
+ animation-delay: -0.52s;
+}
+
+.root :global(.loading-drizzle-drop-3) {
+ left: 112px;
+ height: 14px;
+ animation-delay: -0.92s;
+}
+
+.root :global(.loading-drizzle-drop-4) {
+ left: 156px;
+ height: 17px;
+ animation-delay: -0.38s;
+}
+
+.root :global(.loading-drizzle-drop-5) {
+ left: 192px;
+ height: 13px;
+ animation-delay: -0.74s;
+}
+
+.root :global(.loading-overlay.hidden) {
+ display: none;
+}
+
+.root :global(.loading-spinner) {
+ width: 48px;
+ height: 48px;
+ border: 2px solid rgba(34, 211, 238, 0.1);
+ border-top-color: var(--accent-cyan);
+ border-radius: 50%;
+ animation: loading-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
+ box-shadow: 0 0 15px rgba(34, 211, 238, 0.1);
+}
+
+@keyframes loading-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes city-loading-pulse {
+ 0% {
+ transform: scale(0.92);
+ box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.44);
+ }
+ 70% {
+ transform: scale(1);
+ box-shadow: 0 0 0 10px rgba(34, 211, 238, 0);
+ }
+ 100% {
+ transform: scale(0.92);
+ box-shadow: 0 0 0 0 rgba(34, 211, 238, 0);
+ }
+}
+
+@keyframes radar-sweep {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes radar-blip {
+ 0%,
+ 100% {
+ opacity: 0.2;
+ transform: scale(0.72);
+ }
+ 40% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 60% {
+ opacity: 0.9;
+ transform: scale(1.18);
+ }
+}
+
+@keyframes wind-shift {
+ from {
+ transform: translateX(0);
+ opacity: 0;
+ }
+ 18% {
+ opacity: 0.9;
+ }
+ 82% {
+ opacity: 0.78;
+ }
+ to {
+ transform: translateX(145%);
+ opacity: 0;
+ }
+}
+
+@keyframes thermal-rise {
+ 0%,
+ 100% {
+ transform: scaleY(0.78);
+ opacity: 0.72;
+ }
+ 50% {
+ transform: scaleY(1.14);
+ opacity: 1;
+ }
+}
+
+@keyframes cloud-drift {
+ 0%,
+ 100% {
+ transform: translateX(0);
+ opacity: 0.7;
+ }
+ 50% {
+ transform: translateX(12px);
+ opacity: 0.95;
+ }
+}
+
+@keyframes drizzle-fall {
+ 0% {
+ transform: translateY(-3px);
+ opacity: 0;
+ }
+ 20% {
+ opacity: 0.9;
+ }
+ 100% {
+ transform: translateY(18px);
+ opacity: 0;
+ }
+}
+
+/* ── Nearby Stations (Premium Glassmorphism) ── */
+.root :global(.nearby-marker-premium) {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ background: rgba(10, 14, 26, 0.75);
+ backdrop-filter: blur(12px);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 999px; /* Pill shape */
+ padding: 5px 14px 5px 10px;
+ box-shadow:
+ 0 8px 32px rgba(0, 0, 0, 0.4),
+ inset 0 1px 1px rgba(255, 255, 255, 0.05);
+ pointer-events: none;
+ white-space: nowrap;
+}
+
+.root :global(.nearby-marker-shell) {
+ display: inline-block;
+ will-change: transform;
+}
+
+@keyframes nearby-fade-in {
+ from {
+ opacity: 0;
+ transform: scale(0.9) translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+ }
+}
+
+.root :global(.nearby-pulse) {
+ position: relative;
+ width: 12px;
+ height: 12px;
+ flex-shrink: 0;
+}
+
+.root :global(.pulse-core) {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 6px;
+ height: 6px;
+ background: var(--accent-cyan);
+ border-radius: 50%;
+ transform: translate(-50%, -50%);
+ box-shadow: 0 0 8px var(--accent-cyan);
+}
+
+.root :global(.pulse-ring) {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 100%;
+ height: 100%;
+ border: 1.5px solid var(--accent-cyan);
+ border-radius: 50%;
+ transform: translate(-50%, -50%);
+ opacity: 0.3;
+ box-shadow: 0 0 10px rgba(34, 211, 238, 0.18);
+}
+
+.root :global(.nearby-content) {
+ display: flex;
+ flex-direction: column;
+ line-height: 1.1;
+}
+
+.root :global(.nearby-label) {
+ font-size: 9px;
+ font-weight: 700;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ letter-spacing: 0.05em;
+}
+
+.root :global(.nearby-stats) {
+ display: flex;
+ align-items: baseline;
+ gap: 2px;
+}
+
+.root :global(.nearby-temp-val) {
+ font-size: 13px;
+ font-weight: 800;
+ color: var(--text-primary);
+ text-shadow: 0 0 12px rgba(255, 255, 255, 0.1);
+}
+
+.root :global(.nearby-temp-unit) {
+ font-size: 10px;
+ color: var(--text-muted);
+}
+
+.root :global(.nearby-time) {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-top: 3px;
+ font-size: 11px;
+ font-weight: 700;
+ color: rgba(203, 213, 225, 0.95);
+ white-space: nowrap;
+}
+
+.root :global(.nearby-time span + span)::before {
+ content: "·";
+ margin-right: 4px;
+ color: rgba(148, 163, 184, 0.55);
+}
+
+.root :global(.nearby-time.is-stale) {
+ color: #fbbf24;
+}
+
+.root :global(.nearby-wind) {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding-left: 10px;
+ margin-left: 2px;
+ border-left: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.root :global(.nearby-wind .wind-arrow) {
+ font-size: 12px;
+ color: var(--accent-cyan);
+ text-shadow: 0 0 8px rgba(34, 211, 238, 0.4);
+}
+
+.root :global(.nearby-wind .wind-val) {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+/* ── Trend badge ── */
+.root :global(.trend-badge) {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 10px;
+ border-radius: 6px;
+ font-size: 11px;
+ font-weight: 600;
+}
+.root :global(.trend-badge.rising) {
+ background: rgba(34, 197, 94, 0.12);
+ color: var(--accent-green);
+ border: 1px solid rgba(34, 197, 94, 0.25);
+}
+.root :global(.trend-badge.falling) {
+ background: rgba(248, 113, 113, 0.12);
+ color: var(--accent-red);
+ border: 1px solid rgba(248, 113, 113, 0.25);
+}
+.root :global(.trend-badge.stagnant) {
+ background: rgba(251, 191, 36, 0.12);
+ color: var(--accent-yellow);
+ border: 1px solid rgba(251, 191, 36, 0.25);
+}
+.root :global(.trend-badge.mixed) {
+ background: rgba(167, 139, 250, 0.12);
+ color: var(--accent-purple);
+ border: 1px solid rgba(167, 139, 250, 0.25);
+}
+
+.root :global(.dead-market) {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px 10px;
+ border-radius: 6px;
+ font-size: 11px;
+ font-weight: 700;
+ background: rgba(248, 113, 113, 0.15);
+ color: var(--accent-red);
+ border: 1px solid rgba(248, 113, 113, 0.3);
+ animation: deadPulse 2s ease-in-out infinite;
+}
+@keyframes deadPulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.6;
+ }
+}
diff --git a/frontend/components/dashboard/DashboardModalGuide.module.css b/frontend/components/dashboard/DashboardModalGuide.module.css
new file mode 100644
index 00000000..40709d22
--- /dev/null
+++ b/frontend/components/dashboard/DashboardModalGuide.module.css
@@ -0,0 +1,303 @@
+.root :global(.scan-select) {
+ border: none;
+ border-radius: 8px;
+ background: linear-gradient(135deg, #4DA3FF, #00b383);
+ color: #000;
+ font-weight: 700;
+ cursor: pointer;
+ transition: all 0.2s;
+ box-shadow: 0 4px 12px rgba(0, 224, 164, 0.2);
+}
+
+.root :global(.scan-cta-button:hover:not(:disabled)) {
+ transform: translateY(-1px);
+ box-shadow: 0 6px 20px rgba(0, 224, 164, 0.4);
+ filter: brightness(1.1);
+}
+
+.root :global(.scan-cta-button:active:not(:disabled)) {
+ transform: translateY(1px);
+}
+
+.root :global(.modal-overlay) {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(2, 6, 23, 0.75);
+ backdrop-filter: blur(12px);
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+}
+
+.root :global(.modal-content) {
+ background: #16213A;
+ border: 1px solid var(--border-subtle);
+ border-radius: 16px;
+ width: 100%;
+ max-width: 700px;
+ max-height: calc(100vh - 48px);
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.root :global(.modal-header) {
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border-subtle);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.root :global(.modal-header h2) {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin: 0;
+ min-width: 0;
+}
+.root :global(.future-modal-title-with-actions) {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.root :global(.future-refresh-btn) {
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ color: var(--text-muted);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px;
+ border-radius: 6px;
+ transition: all 0.2s ease;
+}
+
+.root :global(.future-refresh-btn:hover) {
+ color: var(--accent-cyan);
+ background: rgba(34, 211, 238, 0.1);
+}
+
+.root :global(.future-refresh-btn.spinning svg) {
+ animation: spin 1s linear infinite;
+ color: var(--accent-cyan);
+}
+
+@keyframes spin {
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.root :global(.modal-close) {
+ background: none;
+ border: none;
+ font-size: 20px;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: color 0.2s;
+}
+.root :global(.modal-close:hover) {
+ color: var(--accent-red);
+}
+
+.root :global(.modal-body) {
+ padding: 20px;
+ overflow-y: auto;
+}
+
+/* Keep scroll behavior but hide visual scrollbar in today's/future analysis modal */
+.root :global(.modal-content.large.future-modal .modal-body) {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+.root
+ :global(.modal-content.large.future-modal .modal-body::-webkit-scrollbar) {
+ width: 0;
+ height: 0;
+}
+
+@media (max-width: 640px) {
+ .root :global(.modal-content) {
+ border-radius: 14px;
+ max-height: calc(100vh - 16px);
+ }
+
+ .root :global(.modal-header) {
+ padding: 14px 14px 12px;
+ align-items: flex-start;
+ gap: 12px;
+ }
+
+ .root :global(.modal-header h2) {
+ font-size: 15px;
+ line-height: 1.35;
+ }
+
+ .root :global(.modal-body) {
+ padding: 14px;
+ }
+
+}
+
+/* ── Info Button ── */
+.root :global(.account-btn) {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 6px 12px;
+ border-radius: 8px;
+ border: 1px solid rgba(34, 211, 238, 0.34);
+ background: rgba(34, 211, 238, 0.08);
+ color: var(--accent-cyan);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ text-decoration: none;
+ transition: var(--transition);
+}
+.root :global(.account-btn:hover) {
+ background: rgba(34, 211, 238, 0.16);
+ border-color: rgba(34, 211, 238, 0.62);
+ color: #f8fbff;
+ transform: translateY(-1px);
+}
+
+.root :global(.account-renew-badge) {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px 10px;
+ border-radius: 999px;
+ border: 1px solid rgba(245, 158, 11, 0.34);
+ background: rgba(245, 158, 11, 0.1);
+ color: #fbbf24;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ text-decoration: none;
+ transition: var(--transition);
+}
+
+.root :global(.account-renew-badge:hover) {
+ background: rgba(245, 158, 11, 0.18);
+ border-color: rgba(245, 158, 11, 0.6);
+ color: #fff6d5;
+ transform: translateY(-1px);
+}
+
+.root :global(.account-renew-badge.expired) {
+ border-color: rgba(239, 68, 68, 0.34);
+ background: rgba(239, 68, 68, 0.12);
+ color: #fca5a5;
+}
+
+.root :global(.trial-promo-badge) {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 6px 12px;
+ border-radius: 999px;
+ border: 1px solid rgba(34, 211, 238, 0.28);
+ background: rgba(34, 211, 238, 0.1);
+ color: #a5f3fc;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ text-decoration: none;
+ transition: var(--transition);
+}
+
+.root :global(.trial-promo-badge:hover) {
+ background: rgba(34, 211, 238, 0.16);
+ border-color: rgba(34, 211, 238, 0.45);
+ color: #ecfeff;
+ transform: translateY(-1px);
+}
+
+.root :global(.info-btn) {
+ background: rgba(99, 102, 241, 0.1);
+ border: 1px solid rgba(99, 102, 241, 0.3);
+ color: var(--accent-blue);
+ padding: 6px 14px;
+ border-radius: 8px;
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: var(--transition);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.root :global(.info-btn:hover) {
+ background: rgba(99, 102, 241, 0.2);
+ border-color: var(--accent-blue);
+ transform: translateY(-1px);
+}
+
+.root :global(.info-btn.active) {
+ background: rgba(34, 211, 238, 0.14);
+ border-color: rgba(34, 211, 238, 0.42);
+ color: #e0fbff;
+}
+
+/* ── Guide Modal Customizations ── */
+.root :global(.modal-content.large) {
+ max-width: 900px;
+}
+
+
+.root :global(.guide-grid) {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 16px;
+ padding: 4px;
+}
+
+.root :global(.guide-card) {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border-subtle);
+ border-radius: 12px;
+ padding: 16px;
+ transition: var(--transition);
+}
+.root :global(.guide-card:hover) {
+ background: rgba(255, 255, 255, 0.05);
+ border-color: var(--border-glass);
+}
+.root :global(.guide-card h3) {
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--accent-cyan);
+ margin-bottom: 10px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.root :global(.guide-card p) {
+ font-size: 13px;
+ line-height: 1.6;
+ color: var(--text-secondary);
+ margin-bottom: 0;
+}
+.root :global(.guide-card b) {
+ color: var(--text-primary);
+}
+
+.root :global(.guide-footer) {
+ margin-top: 24px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border-subtle);
+ text-align: center;
+ font-size: 11px;
+ color: var(--text-muted);
+}
diff --git a/frontend/components/dashboard/DashboardShell.module.css b/frontend/components/dashboard/DashboardShell.module.css
new file mode 100644
index 00000000..a8ce753c
--- /dev/null
+++ b/frontend/components/dashboard/DashboardShell.module.css
@@ -0,0 +1,827 @@
+/* ── Header ── */
+.root :global(.header) {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: var(--header-height);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 0 16px;
+ background: rgba(7, 11, 18, 0.94);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+ border-bottom: 1px solid var(--border-glass);
+}
+
+.root :global(.brand) {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-width: 0;
+}
+
+.root :global(.brand-mark) {
+ width: 30px;
+ height: 30px;
+ flex-shrink: 0;
+ border-radius: 10px;
+ border: 1px solid rgba(0, 224, 164, 0.22);
+ background: rgba(8, 15, 28, 0.78);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ box-shadow:
+ inset 0 0 0 1px rgba(148, 163, 184, 0.08),
+ 0 0 20px rgba(0, 224, 164, 0.12);
+}
+
+.root :global(.brand-mark img) {
+ width: 24px;
+ height: 24px;
+ display: block;
+ object-fit: contain;
+}
+
+.root :global(.brand h1) {
+ font-size: 18px;
+ font-weight: 800;
+ letter-spacing: -0.03em;
+ background: linear-gradient(
+ 135deg,
+ var(--accent-cyan) 0%,
+ var(--accent-blue) 100%
+ );
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.root :global(.subtitle) {
+ font-size: 11px;
+ font-weight: 500;
+ color: var(--text-muted);
+ letter-spacing: 0.04em;
+ opacity: 0.8;
+}
+
+.root :global(.header-nav) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.root :global(.header-nav-link) {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 12px 18px;
+ border-radius: 10px;
+ color: rgba(203, 213, 225, 0.82);
+ text-decoration: none;
+ font-size: 14px;
+ font-weight: 550;
+ transition: var(--transition);
+ position: relative;
+}
+
+.root :global(.header-nav-link:hover) {
+ color: #f8fafc;
+ background: rgba(30, 41, 59, 0.44);
+}
+
+.root :global(.header-nav-link.active) {
+ color: #f8fafc;
+ background: transparent;
+ box-shadow: none;
+}
+
+.root :global(.header-nav-link.active::after) {
+ content: "";
+ position: absolute;
+ left: 18px;
+ right: 18px;
+ bottom: 4px;
+ height: 2px;
+ border-radius: 999px;
+ background: var(--accent-cyan);
+}
+
+.root :global(.header-right) {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+}
+
+.root :global(.lang-switch) {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px;
+ border-radius: 10px;
+ border: 1px solid var(--border-glass);
+ background: var(--bg-glass);
+}
+
+.root :global(.lang-btn) {
+ border: none;
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1;
+ padding: 6px 8px;
+ border-radius: 7px;
+ cursor: pointer;
+ transition: var(--transition);
+}
+
+.root :global(.lang-btn:hover) {
+ color: var(--text-primary);
+ background: rgba(99, 102, 241, 0.12);
+}
+
+.root :global(.lang-btn.active) {
+ color: var(--text-primary);
+ background: rgba(0, 224, 164, 0.12);
+ box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.2);
+}
+
+.root :global(.live-badge) {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 10px;
+ border-radius: 9999px;
+ background: rgba(0, 224, 164, 0.08);
+ border: 1px solid rgba(0, 224, 164, 0.22);
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--accent-green);
+ letter-spacing: 1.2px;
+ text-transform: uppercase;
+}
+
+.root :global(.pulse-dot) {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--accent-green);
+ animation: pulse 2s ease-in-out infinite;
+}
+
+@keyframes pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ box-shadow: 0 0 0 0 rgba(0, 224, 164, 0.4);
+ }
+ 50% {
+ opacity: 0.7;
+ box-shadow: 0 0 0 6px rgba(0, 224, 164, 0);
+ }
+}
+
+.root :global(.refresh-btn) {
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ border: 1px solid var(--border-glass);
+ background: rgba(13, 19, 33, 0.6);
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all 150ms ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.root :global(.refresh-btn:hover) {
+ background: rgba(0, 224, 164, 0.1);
+ border-color: rgba(0, 224, 164, 0.3);
+ color: var(--accent-cyan);
+}
+.root :global(.refresh-btn.spinning) {
+ animation: spin 1s linear infinite;
+}
+
+.root :global(.locale-switch) {
+ height: 32px;
+ padding: 3px;
+ border-radius: 10px;
+ border: 1px solid rgba(0, 224, 164, 0.2);
+ background: rgba(8, 15, 27, 0.82);
+ color: rgba(148, 163, 184, 0.86);
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ font-size: 11px;
+ font-weight: 750;
+ cursor: pointer;
+ transition: var(--transition);
+}
+
+.root :global(.locale-switch span) {
+ min-width: 32px;
+ height: 24px;
+ border-radius: 7px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.root :global(.locale-switch span.active) {
+ color: #f8fafc;
+ background: rgba(0, 224, 164, 0.16);
+ box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.24);
+}
+
+.root :global(.locale-switch:hover) {
+ border-color: rgba(0, 224, 164, 0.34);
+ background: rgba(11, 22, 40, 0.94);
+}
+
+.root :global(.header-utility-btn) {
+ min-width: 32px;
+ height: 32px;
+ padding: 0 12px;
+ border-radius: 8px;
+ border: 1px solid rgba(115, 137, 161, 0.18);
+ background: rgba(8, 15, 27, 0.82);
+ color: rgba(226, 232, 240, 0.8);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ text-decoration: none;
+ font-size: 12px;
+ font-weight: 650;
+ transition: var(--transition);
+}
+
+.root :global(.header-utility-btn:hover) {
+ color: #f8fafc;
+ border-color: rgba(0, 224, 164, 0.34);
+ background: rgba(11, 22, 40, 0.94);
+}
+
+.root :global(.header-utility-btn.active) {
+ color: #f8fafc;
+ border-color: rgba(123, 97, 255, 0.34);
+}
+
+.root :global(.header-utility-btn.more) {
+ padding: 0;
+ width: 32px;
+}
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+/* ── City List Sidebar ── */
+.root :global(.city-list) {
+ position: fixed;
+ top: calc(var(--header-height) + 12px);
+ left: 12px;
+ width: var(--sidebar-width);
+ max-height: calc(100vh - var(--header-height) - 24px);
+ z-index: 900;
+ background: var(--bg-glass);
+ backdrop-filter: blur(var(--glass-blur));
+ border: 1px solid var(--border-glass);
+ border-radius: 18px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ box-shadow: var(--shadow-lg);
+}
+
+.root :global(.city-list-header) {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 14px 16px 10px;
+ border-bottom: 1px solid var(--border-subtle);
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--text-primary);
+ letter-spacing: -0.01em;
+}
+
+.root :global(.city-search) {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0 14px 12px;
+ padding: 10px 12px;
+ border-radius: 12px;
+ border: 1px solid rgba(115, 137, 161, 0.14);
+ background: rgba(6, 12, 22, 0.82);
+ color: rgba(148, 163, 184, 0.86);
+}
+
+.root :global(.city-search input) {
+ width: 100%;
+ border: none;
+ outline: none;
+ background: transparent;
+ color: #f8fafc;
+ font: inherit;
+ font-size: 13px;
+}
+
+.root :global(.city-search input::placeholder) {
+ color: rgba(148, 163, 184, 0.72);
+}
+
+.root :global(.city-count) {
+ background: rgba(123, 97, 255, 0.2);
+ color: var(--accent-blue);
+ font-size: 11px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ padding: 2px 8px;
+ border-radius: 9999px;
+ border: 1px solid rgba(123, 97, 255, 0.15);
+}
+
+.root :global(.city-list-items) {
+ overflow-y: auto;
+ flex: 1;
+ padding: 0 6px 8px;
+}
+.root :global(.city-list-items::-webkit-scrollbar) {
+ width: 4px;
+}
+.root :global(.city-list-items::-webkit-scrollbar-track) {
+ background: transparent;
+}
+.root :global(.city-list-items::-webkit-scrollbar-thumb) {
+ background: var(--border-glass);
+ border-radius: 2px;
+}
+
+.root :global(.sidebar-footer) {
+ border-top: 1px solid var(--border-subtle);
+ padding: 10px 12px;
+ color: rgba(148, 163, 184, 0.72);
+ font-size: 10px;
+ line-height: 1.55;
+}
+
+.root :global(.city-group) {
+ border: 1px solid rgba(115, 137, 161, 0.12);
+ border-radius: 14px;
+ background: rgba(10, 17, 30, 0.48);
+ margin-bottom: 8px;
+ overflow: hidden;
+}
+
+.root :global(.city-group:last-child) {
+ margin-bottom: 0;
+}
+
+.root :global(.city-group-header) {
+ width: 100%;
+ border: none;
+ background: rgba(15, 28, 47, 0.56);
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 7px 10px;
+ cursor: pointer;
+ font-family: inherit;
+ transition: background 150ms ease;
+}
+
+.root :global(.city-group-header:hover) {
+ background: rgba(18, 34, 55, 0.82);
+}
+
+.root :global(.city-group-title) {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.4px;
+ text-transform: uppercase;
+}
+
+.root :global(.city-group-indicator) {
+ width: 3px;
+ height: 14px;
+ border-radius: 2px;
+ flex-shrink: 0;
+}
+.root :global(.city-group-indicator.high) {
+ background: var(--risk-high);
+ box-shadow: 0 0 6px rgba(239, 68, 68, 0.3);
+}
+.root :global(.city-group-indicator.medium) {
+ background: var(--risk-medium);
+ box-shadow: 0 0 6px rgba(245, 158, 11, 0.3);
+}
+.root :global(.city-group-indicator.low) {
+ background: var(--risk-low);
+ box-shadow: 0 0 6px rgba(34, 197, 94, 0.3);
+}
+.root :global(.city-group-indicator.other) {
+ background: var(--text-muted);
+ opacity: 0.5;
+}
+
+.root :global(.city-group-meta) {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.root :global(.city-group-count) {
+ min-width: 18px;
+ height: 18px;
+ border-radius: 9px;
+ padding: 0 6px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(123, 97, 255, 0.26);
+ color: var(--text-primary);
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.root :global(.city-group-arrow) {
+ font-size: 11px;
+ color: var(--text-muted);
+ transform: rotate(-90deg);
+ transition: transform 0.2s ease;
+}
+
+.root :global(.city-group-arrow.expanded) {
+ transform: rotate(0deg);
+}
+
+.root :global(.city-group-items) {
+ padding: 4px;
+}
+
+.root :global(.city-group.collapsed .city-group-items) {
+ display: none;
+}
+
+.root :global(.city-item) {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 8px 10px;
+ border-radius: 10px;
+ cursor: pointer;
+ transition: var(--transition);
+ border: 1px solid transparent;
+ background: transparent;
+ color: inherit;
+ font-family: inherit;
+ text-align: left;
+}
+.root :global(.city-item:hover) {
+ background: rgba(10, 26, 44, 0.84);
+ border-color: rgba(0, 224, 164, 0.18);
+ box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.03);
+}
+.root :global(.city-item.active) {
+ background:
+ linear-gradient(135deg, rgba(0, 224, 164, 0.12), rgba(123, 97, 255, 0.1)),
+ rgba(9, 18, 32, 0.92);
+ border-color: rgba(0, 224, 164, 0.24);
+ box-shadow:
+ 0 0 20px rgba(0, 224, 164, 0.08),
+ inset 0 0 0 1px rgba(0, 224, 164, 0.08);
+}
+
+.root :global(.city-item-main) {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+}
+
+.root :global(.city-item .city-name-text) {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.root :global(.city-item-info) {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-left: 20px; /* Align with name text, after the dot */
+ font-size: 10px;
+ color: var(--text-muted);
+}
+
+.root :global(.city-item .city-max-info) {
+ color: var(--accent-blue);
+ font-weight: 500;
+}
+
+.root :global(.city-item .city-deviation-info) {
+ font-weight: 600;
+}
+
+.root :global(.city-item .city-deviation-cold) {
+ color: #4DA3FF;
+}
+
+.root :global(.city-item .city-deviation-hot) {
+ color: #f59e0b;
+}
+
+.root :global(.city-item .city-deviation-normal) {
+ color: #22d3ee;
+}
+
+.root :global(.city-item .city-deviation-info.strong) {
+ text-shadow: 0 0 10px rgba(56, 189, 248, 0.18);
+}
+
+.root :global(.city-item .city-deviation-hot.strong) {
+ text-shadow: 0 0 10px rgba(245, 158, 11, 0.24);
+}
+
+.root :global(.city-item .risk-dot) {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+.root :global(.city-item .risk-dot.high) {
+ background: var(--risk-high);
+ box-shadow: 0 0 6px var(--risk-high);
+}
+.root :global(.city-item .risk-dot.medium) {
+ background: var(--risk-medium);
+ box-shadow: 0 0 6px var(--risk-medium);
+}
+.root :global(.city-item .risk-dot.low) {
+ background: var(--risk-low);
+ box-shadow: 0 0 6px var(--risk-low);
+}
+
+.root :global(.city-clock-icon) {
+ display: inline-block;
+ vertical-align: -1px;
+ margin-right: 3px;
+ opacity: 0.6;
+}
+
+.root :global(.city-item .city-temp) {
+ margin-left: auto;
+ font-size: 13px;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ color: var(--accent-cyan);
+ opacity: 0;
+ transition: opacity 200ms ease;
+}
+.root :global(.city-item .city-temp.loaded) {
+ opacity: 1;
+}
+
+/* ── Responsive ── */
+@media (max-width: 1100px) {
+ .root {
+ --panel-width: 460px;
+ }
+ .root :global(.hero-value) {
+ font-size: 52px;
+ }
+}
+
+@media (max-width: 768px) {
+ .root :global(.header) {
+ height: auto;
+ min-height: var(--header-height);
+ align-items: flex-start;
+ gap: 10px;
+ padding: 10px 12px;
+ }
+
+ .root :global(.brand) {
+ align-items: flex-start;
+ gap: 8px;
+ min-width: 0;
+ }
+
+ .root :global(.header-right) {
+ flex: 1 1 auto;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ gap: 8px;
+ min-width: 0;
+ }
+
+ .root :global(.lang-switch) {
+ order: 1;
+ }
+
+ .root :global(.account-btn),
+ .root :global(.info-btn) {
+ padding: 6px 10px;
+ font-size: 12px;
+ }
+
+ .root :global(.live-badge) {
+ order: 4;
+ padding: 4px 10px;
+ white-space: nowrap;
+ }
+
+ .root :global(.refresh-btn) {
+ order: 5;
+ }
+
+ .root :global(.city-list) {
+ display: flex;
+ top: auto;
+ left: 8px;
+ right: 8px;
+ bottom: 8px;
+ width: auto;
+ max-height: min(36vh, 320px);
+ border-radius: 14px;
+ z-index: 920;
+ }
+ .root :global(.detail-panel) {
+ width: 100%;
+ }
+ .root {
+ --panel-width: 100%;
+ }
+
+ .root :global(.city-list-header) {
+ padding: 12px 14px;
+ font-size: 14px;
+ }
+
+ .root :global(.city-list-items) {
+ padding: 6px;
+ }
+
+ .root :global(.city-group-header) {
+ padding: 8px 9px;
+ }
+
+ .root :global(.city-item) {
+ padding: 8px 9px;
+ }
+
+ .root :global(.city-item .city-name-text),
+ .root :global(.city-item .city-temp) {
+ font-size: 12px;
+ }
+
+ .root :global(.city-item-info) {
+ padding-left: 18px;
+ flex-wrap: wrap;
+ row-gap: 4px;
+ }
+
+ .root :global(.panel-header) {
+ padding: 16px 16px 14px;
+ }
+}
+
+@media (max-width: 600px) {
+ .root :global(.header) {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 8px;
+ padding: 8px 10px;
+ }
+
+ .root :global(.brand) {
+ justify-content: space-between;
+ width: 100%;
+ }
+
+ .root :global(.subtitle) {
+ display: none;
+ }
+ .root :global(.brand h1) {
+ font-size: 16px;
+ }
+
+ .root :global(.header-right) {
+ width: 100%;
+ justify-content: flex-start;
+ gap: 6px;
+ }
+
+ .root :global(.lang-switch) {
+ margin-right: auto;
+ }
+
+ .root :global(.account-btn),
+ .root :global(.info-btn) {
+ min-height: 34px;
+ padding: 6px 9px;
+ font-size: 11px;
+ }
+
+ .root :global(.account-btn span),
+ .root :global(.info-btn span) {
+ max-width: 88px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .root :global(.live-badge) {
+ padding: 4px 8px;
+ font-size: 10px;
+ letter-spacing: 0.6px;
+ }
+
+ .root :global(.refresh-btn) {
+ width: 34px;
+ height: 34px;
+ font-size: 16px;
+ }
+
+ .root :global(.hero-value) {
+ font-size: 42px;
+ }
+ .root :global(.hero-details) {
+ grid-template-columns: repeat(3, 1fr);
+ gap: 4px;
+ }
+ .root :global(.hero-item .value) {
+ font-size: 14px;
+ }
+
+ .root :global(.city-list) {
+ left: 6px;
+ right: 6px;
+ bottom: 6px;
+ max-height: min(40vh, 300px);
+ }
+
+ .root :global(.city-list-header) {
+ padding: 10px 12px;
+ }
+
+ .root :global(.city-group-title) {
+ font-size: 11px;
+ }
+
+ .root :global(.city-item-main) {
+ gap: 6px;
+ }
+
+ .root :global(.city-item-info) {
+ font-size: 9px;
+ }
+
+ .root :global(.panel-header) {
+ padding: 14px 14px 12px;
+ }
+
+ .root :global(.city-marker:hover) {
+ transform: none;
+ }
+
+ .root :global(.marker-bubble) {
+ min-width: 48px;
+ min-height: 32px;
+ padding: 6px 10px;
+ }
+
+ .root :global(.marker-name) {
+ font-size: 11px;
+ }
+
+ .root :global(.prob-price-grid) {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .root :global(.prob-price-head) {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+}
diff --git a/frontend/components/dashboard/DetailMiniTemperatureChart.tsx b/frontend/components/dashboard/DetailMiniTemperatureChart.tsx
new file mode 100644
index 00000000..22de58ec
--- /dev/null
+++ b/frontend/components/dashboard/DetailMiniTemperatureChart.tsx
@@ -0,0 +1,135 @@
+"use client";
+
+import type { ChartConfiguration } from "chart.js";
+import { useMemo } from "react";
+import { useChart } from "@/hooks/useChart";
+import { useI18n } from "@/hooks/useI18n";
+import { getTemperatureChartData } from "@/lib/chart-utils";
+import type { CityDetail } from "@/lib/dashboard-types";
+
+export function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
+ const { locale, t } = 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 Observation" : "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: chartData.datasets.hasMgmHourly
+ ? "rgba(250, 204, 21, 0.92)"
+ : "rgba(52, 211, 153, 0.86)",
+ borderWidth: 1.8,
+ data: forecastPoints,
+ fill: false,
+ label: forecastLabel,
+ pointRadius: 0,
+ spanGaps: true,
+ tension: 0.28,
+ },
+ {
+ backgroundColor: "#4DA3FF",
+ borderColor: "#4DA3FF",
+ borderWidth: 0,
+ data: chartData.datasets.metarPoints,
+ fill: false,
+ label: observationLabel,
+ pointHoverRadius: 5,
+ pointRadius: 3.2,
+ showLine: false,
+ },
+ ],
+ labels: chartData.times,
+ },
+ 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(77, 163, 255, 0.28)",
+ borderWidth: 1,
+ },
+ },
+ responsive: true,
+ scales: {
+ x: {
+ grid: { color: "rgba(255,255,255,0.03)" },
+ 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(255,255,255,0.03)" },
+ 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 (
+
+
+
+
+ {chartData ? (
+
+
+
+ {forecastLabel}
+
+
+
+ {observationLabel}
+
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/components/dashboard/DetailPanel.tsx b/frontend/components/dashboard/DetailPanel.tsx
index 18c2c46c..3e51f2df 100644
--- a/frontend/components/dashboard/DetailPanel.tsx
+++ b/frontend/components/dashboard/DetailPanel.tsx
@@ -1,148 +1,29 @@
"use client";
-import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
+import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { ForecastTable } from "@/components/dashboard/PanelSections";
-import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
-import { CityDetail } from "@/lib/dashboard-types";
import { trackAppEvent } from "@/lib/app-analytics";
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
-import { getTemperatureChartData } from "@/lib/chart-utils";
import { getCityProfileStats } from "@/lib/dashboard-utils";
import { normalizeObservationSourceLabel } from "@/lib/source-labels";
import { getRiskBadgeLabel } from "@/lib/weather-summary-utils";
-function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
- const { locale, t } = 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 Observation" : "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: chartData.datasets.hasMgmHourly
- ? "rgba(250, 204, 21, 0.92)"
- : "rgba(52, 211, 153, 0.86)",
- borderWidth: 1.8,
- data: forecastPoints,
- fill: false,
- label: forecastLabel,
- pointRadius: 0,
- spanGaps: true,
- tension: 0.28,
- },
- {
- backgroundColor: "#4DA3FF",
- borderColor: "#4DA3FF",
- borderWidth: 0,
- data: chartData.datasets.metarPoints,
- fill: false,
- label: observationLabel,
- pointHoverRadius: 5,
- pointRadius: 3.2,
- showLine: false,
- },
- ],
- labels: chartData.times,
- },
- 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(77, 163, 255, 0.28)",
- borderWidth: 1,
- },
- },
- responsive: true,
- scales: {
- x: {
- grid: { color: "rgba(255,255,255,0.03)" },
- 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(255,255,255,0.03)" },
- 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 (
-
-
-
-
- {chartData ? (
-
-
-
- {forecastLabel}
-
-
-
- {observationLabel}
-
-
- ) : null}
-
- );
-}
+const DetailMiniTemperatureChart = dynamic(
+ () =>
+ import("@/components/dashboard/DetailMiniTemperatureChart").then(
+ (module) => module.DetailMiniTemperatureChart,
+ ),
+ {
+ loading: () =>
,
+ ssr: false,
+ },
+);
export function DetailPanel({
variant = "overlay",
diff --git a/frontend/components/dashboard/DetailPanelChrome.module.css b/frontend/components/dashboard/DetailPanelChrome.module.css
index 7e3fefd9..018dcd3b 100644
--- a/frontend/components/dashboard/DetailPanelChrome.module.css
+++ b/frontend/components/dashboard/DetailPanelChrome.module.css
@@ -210,4 +210,3 @@
font-size: 13px;
line-height: 1.7;
}
-
diff --git a/frontend/components/dashboard/DetailPanelContent.module.css b/frontend/components/dashboard/DetailPanelContent.module.css
new file mode 100644
index 00000000..cffafff9
--- /dev/null
+++ b/frontend/components/dashboard/DetailPanelContent.module.css
@@ -0,0 +1,290 @@
+/* Detail panel content styles. */
+
+.root :global(.detail-grid) {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.root :global(.detail-card) {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border-subtle);
+ border-radius: 12px;
+ padding: 12px;
+}
+
+.root :global(.detail-source-list) {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 10px;
+}
+
+.root :global(.detail-source-note) {
+ margin: 8px 0 12px;
+ color: var(--text-muted);
+ font-size: 12px;
+ line-height: 1.6;
+}
+
+.root :global(.detail-source-link) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 14px;
+ border-radius: 12px;
+ border: 1px solid var(--border-subtle);
+ background: rgba(255, 255, 255, 0.03);
+ color: var(--text-main);
+ text-decoration: none;
+ transition:
+ background 0.18s ease,
+ border-color 0.18s ease,
+ transform 0.18s ease;
+}
+
+.root :global(.detail-source-link:hover) {
+ background: rgba(34, 211, 238, 0.08);
+ border-color: rgba(34, 211, 238, 0.28);
+ transform: translateY(-1px);
+}
+
+.root :global(.detail-source-kind) {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ color: var(--accent-cyan);
+ text-transform: uppercase;
+ flex-shrink: 0;
+}
+
+.root :global(.detail-source-label) {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-main);
+ text-align: right;
+}
+
+.root :global(.detail-label) {
+ display: block;
+ color: var(--text-muted);
+ font-size: 11px;
+ margin-bottom: 6px;
+}
+
+.root :global(.detail-value) {
+ display: block;
+ color: var(--text-primary);
+ font-size: 14px;
+ font-weight: 700;
+ line-height: 1.5;
+ word-break: break-word;
+}
+
+.root :global(.profile-lead) {
+ color: var(--text-secondary);
+ font-size: 13px;
+ line-height: 1.7;
+}
+
+.root :global(.detail-mini-chart-wrap) {
+ display: grid;
+ gap: 10px;
+}
+
+.root :global(.detail-mini-chart) {
+ position: relative;
+ 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;
+ line-height: 1.55;
+}
+
+.root :global(.insight-list) {
+ display: grid;
+ gap: 10px;
+}
+
+.root :global(.insight-item) {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border-subtle);
+ border-radius: 12px;
+ padding: 12px;
+}
+
+.root :global(.insight-title) {
+ color: var(--accent-cyan);
+ font-size: 12px;
+ font-weight: 700;
+ margin-bottom: 6px;
+}
+
+.root :global(.insight-text) {
+ color: var(--text-secondary);
+ font-size: 13px;
+ line-height: 1.65;
+}
+
+.root :global(.detail-scenery-card) {
+ position: relative;
+ min-height: 210px;
+ border-radius: 16px;
+ overflow: hidden;
+ border: 1px solid var(--border-subtle);
+ background:
+ linear-gradient(180deg, rgba(15, 23, 42, 0.18), rgba(15, 23, 42, 0.78)),
+ rgba(255, 255, 255, 0.03);
+}
+
+.root :global(.detail-scenery-image) {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.root :global(.detail-scenery-overlay) {
+ position: relative;
+ z-index: 1;
+ min-height: 210px;
+ padding: 18px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ background: linear-gradient(
+ 180deg,
+ rgba(2, 6, 23, 0.08) 0%,
+ rgba(2, 6, 23, 0.86) 100%
+ );
+}
+
+.root :global(.detail-scenery-copy),
+.root :global(.detail-scenery-fallback) {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.root :global(.detail-scenery-fallback) {
+ min-height: 210px;
+ padding: 18px;
+ justify-content: flex-end;
+ background:
+ radial-gradient(
+ circle at top left,
+ rgba(34, 211, 238, 0.12),
+ transparent 34%
+ ),
+ linear-gradient(180deg, rgba(15, 23, 42, 0.92), rgba(2, 6, 23, 0.96));
+}
+
+.root :global(.detail-scenery-kicker) {
+ color: rgba(226, 232, 240, 0.78);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+.root :global(.detail-scenery-title) {
+ color: #fff;
+ font-size: 22px;
+ font-weight: 800;
+ letter-spacing: -0.03em;
+ text-shadow: 0 6px 22px rgba(2, 6, 23, 0.42);
+}
+
+.root :global(.detail-scenery-subtitle) {
+ max-width: 320px;
+ color: rgba(226, 232, 240, 0.9);
+ font-size: 13px;
+ line-height: 1.6;
+ text-shadow: 0 2px 10px rgba(2, 6, 23, 0.45);
+}
+
+.root :global(.detail-scenery-credit) {
+ align-self: flex-start;
+ color: rgba(226, 232, 240, 0.9);
+ font-size: 11px;
+ text-decoration: none;
+ padding: 6px 10px;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ background: rgba(2, 6, 23, 0.35);
+ backdrop-filter: blur(10px);
+}
+
+.root :global(.detail-scenery-credit:hover) {
+ border-color: rgba(34, 211, 238, 0.4);
+ color: #fff;
+}
+
+@media (max-width: 640px) {
+ .root :global(.detail-grid) {
+ grid-template-columns: 1fr;
+ }
+
+ .root :global(.detail-mini-chart) {
+ height: 170px;
+ }
+
+ .root :global(.detail-source-link) {
+ padding: 10px 12px;
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .root :global(.detail-source-label) {
+ text-align: left;
+ }
+}
diff --git a/frontend/components/dashboard/DetailPanelSections.module.css b/frontend/components/dashboard/DetailPanelSections.module.css
new file mode 100644
index 00000000..fc18b962
--- /dev/null
+++ b/frontend/components/dashboard/DetailPanelSections.module.css
@@ -0,0 +1,1065 @@
+/* ── Detail Panel ── */
+.root :global(.detail-panel) {
+ position: fixed;
+ top: 0;
+ right: 0;
+ width: var(--panel-width);
+ height: 100vh;
+ z-index: 950;
+ background: linear-gradient(
+ 180deg,
+ rgba(10, 14, 26, 0.92) 0%,
+ rgba(15, 23, 42, 0.95) 100%
+ );
+ backdrop-filter: blur(24px);
+ border-left: 1px solid var(--border-glass);
+ box-shadow: -10px 0 60px rgba(0, 0, 0, 0.5);
+ transform: translateX(100%);
+ transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+.root :global(.detail-panel.visible) {
+ transform: translateX(0);
+}
+
+.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
+ position: sticky;
+ top: 16px;
+ width: auto;
+ min-width: 380px;
+ height: calc(100vh - 32px);
+ min-height: 0;
+ max-height: calc(100vh - 32px);
+ z-index: auto;
+ transform: none;
+ transition: none;
+ border: 1px solid rgba(82, 114, 161, 0.18);
+ border-left: 1px solid rgba(82, 114, 161, 0.18);
+ border-radius: 22px;
+ background: linear-gradient(180deg, rgba(15, 28, 47, 0.94), rgba(9, 18, 32, 0.94));
+ box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
+ backdrop-filter: blur(14px);
+ align-self: start;
+ overflow: hidden;
+}
+
+.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail.visible) {
+ transform: none;
+}
+
+.root :global(.scan-city-detail-rail .panel-header) {
+ padding: 20px 16px 16px;
+}
+
+.root :global(.panel-header) {
+ padding: 20px 20px 16px;
+ border-bottom: 1px solid var(--border-subtle);
+ position: relative;
+ flex-shrink: 0;
+}
+
+.root :global(.panel-close) {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ border: 1px solid var(--border-glass);
+ background: transparent;
+ color: var(--text-muted);
+ font-size: 14px;
+ cursor: pointer;
+ transition: var(--transition);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.root :global(.panel-close:hover) {
+ background: rgba(248, 113, 113, 0.15);
+ border-color: var(--accent-red);
+ color: var(--accent-red);
+}
+
+.root :global(.panel-title-area h2) {
+ font-size: 22px;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ margin-bottom: 6px;
+}
+
+.root :global(.panel-loading-hint) {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 10px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ border: 1px solid rgba(34, 211, 238, 0.18);
+ background: rgba(12, 24, 42, 0.78);
+ color: var(--accent-cyan);
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.2px;
+}
+
+.root :global(.panel-loading-spinner) {
+ width: 10px;
+ height: 10px;
+ border-radius: 999px;
+ border: 2px solid rgba(34, 211, 238, 0.22);
+ border-top-color: rgba(34, 211, 238, 0.92);
+ animation: loading-spin 0.8s linear infinite;
+}
+
+.root :global(.panel-meta) {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.root :global(.city-loading-toast) {
+ position: fixed;
+ top: 78px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 1200;
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ border-radius: 999px;
+ border: 1px solid rgba(34, 211, 238, 0.18);
+ background: linear-gradient(
+ 180deg,
+ rgba(10, 18, 34, 0.94),
+ rgba(10, 18, 34, 0.82)
+ );
+ box-shadow:
+ 0 18px 40px rgba(2, 6, 23, 0.38),
+ 0 0 0 1px rgba(34, 211, 238, 0.04) inset;
+ backdrop-filter: blur(18px);
+ pointer-events: none;
+}
+
+.root :global(.city-loading-dot) {
+ width: 10px;
+ height: 10px;
+ border-radius: 999px;
+ background: var(--accent-cyan);
+ box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.5);
+ animation: city-loading-pulse 1.35s ease-out infinite;
+}
+
+.root :global(.city-loading-copy) {
+ color: var(--text-primary);
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.25px;
+}
+
+.root :global(.pro-locked) {
+ filter: grayscale(0.8) opacity(0.7);
+ position: relative;
+}
+
+.root :global(.pro-locked::after) {
+ content: "PRO";
+ position: absolute;
+ top: -4px;
+ right: -4px;
+ background: var(--accent-blue);
+ color: white;
+ font-size: 7px;
+ padding: 1px 3px;
+ border-radius: 3px;
+ font-weight: 900;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
+}
+
+.root :global(.risk-badge) {
+ font-size: 11px;
+ font-weight: 600;
+ padding: 3px 10px;
+ border-radius: 6px;
+ letter-spacing: 0.5px;
+}
+.root :global(.risk-badge.high) {
+ background: rgba(239, 68, 68, 0.15);
+ color: var(--risk-high);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+}
+.root :global(.risk-badge.medium) {
+ background: rgba(245, 158, 11, 0.15);
+ color: var(--risk-medium);
+ border: 1px solid rgba(245, 158, 11, 0.3);
+}
+.root :global(.risk-badge.low) {
+ background: rgba(34, 197, 94, 0.15);
+ color: var(--risk-low);
+ border: 1px solid rgba(34, 197, 94, 0.3);
+}
+
+.root :global(.local-time) {
+ font-size: 12px;
+ color: var(--text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.root :global(.panel-body) {
+ overflow-y: auto;
+ flex: 1;
+ position: relative;
+ padding: 0 20px 24px;
+}
+
+.root :global(.panel-sync-blocker) {
+ position: sticky;
+ top: 0;
+ z-index: 6;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 0 0 12px;
+ padding: 12px 14px;
+ border: 1px solid rgba(34, 211, 238, 0.22);
+ border-radius: 8px;
+ background: rgba(8, 16, 30, 0.96);
+ color: var(--text-primary);
+ box-shadow: 0 14px 34px rgba(2, 6, 23, 0.38);
+ backdrop-filter: blur(18px);
+ font-size: 12px;
+ font-weight: 650;
+ line-height: 1.45;
+}
+
+.root :global(.panel-content-stale) {
+ opacity: 0.28;
+ pointer-events: none;
+ user-select: none;
+}
+
+.root :global(.panel-content-stale canvas) {
+ visibility: hidden;
+}
+.root :global(.panel-body::-webkit-scrollbar) {
+ width: 4px;
+}
+.root :global(.panel-body::-webkit-scrollbar-thumb) {
+ background: var(--border-glass);
+ border-radius: 2px;
+}
+
+.root :global(.panel-body section) {
+ padding: 18px 0;
+ border-bottom: 1px solid var(--border-subtle);
+}
+.root :global(.panel-body section:last-child) {
+ border-bottom: none;
+}
+
+.root :global(.panel-body section.detail-scenery-card) {
+ padding: 0;
+ margin: 18px 0;
+}
+
+.root :global(.panel-body h3) {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 12px;
+ letter-spacing: 0.3px;
+}
+
+/* ── Hero Section ── */
+.root :global(.hero-section) {
+ text-align: center;
+ padding-top: 12px !important;
+}
+
+.root :global(.hero-weather) {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--accent-cyan);
+ margin-bottom: -4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+}
+
+.root :global(.hero-temp) {
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 2px;
+ margin-bottom: 2px;
+}
+
+.root :global(.hero-max-time) {
+ font-size: 10px;
+ font-weight: 500;
+ color: var(--text-muted);
+ margin-bottom: 16px;
+ min-height: 12px;
+}
+.root :global(.hero-value) {
+ font-size: 56px;
+ font-weight: 800;
+ letter-spacing: -0.04em;
+ line-height: 1;
+ background: linear-gradient(135deg, #fff 30%, var(--accent-cyan));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+.root :global(.hero-unit) {
+ font-size: 20px;
+ font-weight: 400;
+ color: var(--text-muted);
+ margin-top: 8px;
+}
+
+.root :global(.hero-details) {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ margin-bottom: 12px;
+}
+.root :global(.hero-item) {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border-subtle);
+ border-radius: 10px;
+ padding: 10px 8px;
+ text-align: center;
+}
+.root :global(.hero-item .label) {
+ display: block;
+ font-size: 10px;
+ color: var(--text-muted);
+ margin-bottom: 4px;
+}
+.root :global(.hero-item .value) {
+ display: block;
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--text-primary);
+ font-variant-numeric: tabular-nums;
+}
+.root :global(.hero-item .value.highlight) {
+ color: var(--accent-cyan);
+ text-shadow: 0 0 12px rgba(34, 211, 238, 0.3);
+}
+
+.root :global(.hero-sub) {
+ font-size: 12px;
+ color: var(--text-muted);
+ display: flex;
+ justify-content: center;
+ gap: 16px;
+ flex-wrap: wrap;
+}
+.root :global(.hero-sub span) {
+ white-space: nowrap;
+}
+
+/* ── Chart Section ── */
+.root :global(.chart-wrapper) {
+ height: 180px;
+ position: relative;
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: 12px;
+ border: 1px solid var(--border-subtle);
+ padding: 12px;
+}
+
+.root :global(.chart-legend) {
+ display: flex;
+ justify-content: center;
+ gap: 16px;
+ margin-top: 8px;
+ font-size: 11px;
+ color: var(--text-muted);
+}
+
+/* ── Probability Bars ── */
+.root :global(.prob-bars) {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.root :global(.prob-calibration-head) {
+ display: grid;
+ gap: 6px;
+ margin-bottom: 4px;
+ padding: 10px;
+ border: 1px solid rgba(34, 211, 238, 0.16);
+ border-radius: 8px;
+ background: rgba(15, 23, 42, 0.26);
+}
+
+.root :global(.prob-calibration-head > div) {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.root :global(.prob-calibration-head strong) {
+ color: var(--text-primary);
+ font-size: 13px;
+ font-weight: 800;
+}
+
+.root :global(.prob-calibration-head p) {
+ margin: 0;
+ color: var(--text-muted);
+ font-size: 11px;
+ line-height: 1.45;
+}
+
+.root :global(.prob-source-chip) {
+ display: inline-flex;
+ align-items: center;
+ min-height: 22px;
+ padding: 3px 8px;
+ border: 1px solid rgba(34, 211, 238, 0.28);
+ border-radius: 8px;
+ color: #6FB7FF;
+ background: rgba(34, 211, 238, 0.08);
+ font-size: 11px;
+ font-weight: 900;
+}
+
+.root :global(.prob-row) {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.root :global(.prob-label) {
+ width: 80px;
+ font-size: 13px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+ flex-shrink: 0;
+}
+
+.root :global(.prob-bar-track) {
+ flex: 1;
+ height: 28px;
+ background: rgba(255, 255, 255, 0.04);
+ border-radius: 8px;
+ overflow: hidden;
+ position: relative;
+}
+
+.root :global(.prob-bar-fill) {
+ height: 100%;
+ border-radius: 8px;
+ transition: width 0.8s cubic-bezier(0.16, 1, 0.3, 1);
+ display: flex;
+ align-items: center;
+ padding-left: 10px;
+ font-size: 12px;
+ font-weight: 600;
+ color: white;
+ min-width: 40px;
+}
+
+.root :global(.prob-bar-fill.rank-0) {
+ background: linear-gradient(90deg, var(--accent-blue), var(--accent-cyan));
+ box-shadow: 0 0 12px rgba(99, 102, 241, 0.3);
+}
+.root :global(.prob-bar-fill.rank-1) {
+ background: linear-gradient(
+ 90deg,
+ rgba(99, 102, 241, 0.6),
+ rgba(34, 211, 238, 0.5)
+ );
+}
+.root :global(.prob-bar-fill.rank-2) {
+ background: rgba(99, 102, 241, 0.3);
+}
+.root :global(.prob-bar-fill.rank-3) {
+ background: rgba(99, 102, 241, 0.15);
+}
+
+.root :global(.prob-market-inline) {
+ min-width: 120px;
+ text-align: right;
+ font-size: 12px;
+ font-weight: 700;
+ border-radius: 999px;
+ padding: 4px 10px;
+ letter-spacing: 0.02em;
+ font-variant-numeric: tabular-nums;
+}
+
+.root :global(.prob-market-inline.yes) {
+ color: #4ade80;
+ background: rgba(74, 222, 128, 0.12);
+ border: 1px solid rgba(74, 222, 128, 0.3);
+}
+
+.root :global(.prob-market-inline.no) {
+ color: #fb7185;
+ background: rgba(251, 113, 133, 0.12);
+ border: 1px solid rgba(251, 113, 133, 0.26);
+}
+
+.root :global(.prob-distribution-panel) {
+ display: grid;
+ gap: 8px;
+ padding: 10px;
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ border-radius: 8px;
+ background: rgba(15, 23, 42, 0.24);
+}
+
+.root :global(.prob-distribution-head) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 2px;
+}
+
+.root :global(.prob-distribution-head span) {
+ color: var(--cyan);
+ font-size: 11px;
+ font-weight: 900;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.root :global(.prob-distribution-head em) {
+ color: var(--text-muted);
+ font-size: 11px;
+ font-style: normal;
+}
+
+.root :global(.prob-price-card) {
+ display: grid;
+ gap: 8px;
+ padding: 10px;
+ border: 1px solid rgba(34, 211, 238, 0.16);
+ border-radius: 8px;
+ background: rgba(8, 20, 32, 0.52);
+}
+
+.root :global(.prob-price-head) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.root :global(.prob-price-head span) {
+ color: var(--cyan);
+ font-size: 11px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.root :global(.prob-price-head strong) {
+ color: var(--text-primary);
+ font-size: 13px;
+ font-weight: 800;
+}
+
+.root :global(.prob-price-grid) {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.root :global(.prob-price-grid > div) {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+ padding: 8px;
+ border: 1px solid rgba(148, 163, 184, 0.12);
+ border-radius: 8px;
+ background: rgba(15, 23, 42, 0.45);
+}
+
+.root :global(.prob-price-grid span),
+.root :global(.prob-price-grid em),
+.root :global(.prob-price-card p) {
+ color: var(--text-muted);
+ font-size: 11px;
+ font-style: normal;
+ line-height: 1.35;
+}
+
+.root :global(.prob-price-grid strong) {
+ color: var(--text-primary);
+ font-size: 13px;
+ font-weight: 800;
+ font-variant-numeric: tabular-nums;
+}
+
+.root :global(.prob-price-card p) {
+ margin: 0;
+}
+
+.root :global(.prob-model-hint) {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 10px;
+ padding: 7px 9px;
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ border-radius: 8px;
+ background: rgba(15, 23, 42, 0.22);
+}
+
+.root :global(.prob-model-hint span) {
+ color: var(--cyan);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.root :global(.prob-model-hint strong) {
+ color: var(--text-secondary);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.root :global(.prob-model-hint em) {
+ color: var(--text-muted);
+ font-size: 11px;
+ font-style: normal;
+}
+
+/* ── Model Bars ── */
+.root :global(.model-bars) {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.root :global(.model-row) {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 12px;
+}
+
+.root :global(.model-name) {
+ width: 80px;
+ text-align: right;
+ color: var(--text-muted);
+ font-weight: 500;
+ flex-shrink: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.root :global(.model-row-rich) {
+ align-items: flex-start;
+}
+
+.root :global(.model-row-rich .model-name) {
+ width: 118px;
+ white-space: normal;
+ line-height: 1.25;
+}
+
+.root :global(.model-row-rich .model-name strong) {
+ display: block;
+ color: var(--text-primary);
+ font-size: 12px;
+}
+
+.root :global(.model-row-rich .model-name span) {
+ display: block;
+ margin-top: 2px;
+ color: var(--text-muted);
+ font-size: 10px;
+ font-weight: 500;
+}
+
+.root :global(.model-stack-summary) {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 2px;
+}
+
+.root :global(.model-stack-summary span) {
+ border: 1px solid rgba(148, 163, 184, 0.16);
+ border-radius: 6px;
+ background: rgba(15, 23, 42, 0.5);
+ color: var(--text-secondary);
+ font-size: 11px;
+ padding: 5px 8px;
+}
+
+.root :global(.model-stack-summary strong) {
+ color: var(--text-primary);
+ font-weight: 800;
+}
+
+.root :global(.model-group) {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 8px;
+ border-left: 2px solid rgba(148, 163, 184, 0.26);
+ background: rgba(15, 23, 42, 0.28);
+ border-radius: 6px;
+}
+
+.root :global(.model-group-cyan) {
+ border-left-color: rgba(34, 211, 238, 0.75);
+}
+
+.root :global(.model-group-blue) {
+ border-left-color: rgba(96, 165, 250, 0.75);
+}
+
+.root :global(.model-group-amber) {
+ border-left-color: rgba(245, 158, 11, 0.8);
+}
+
+.root :global(.model-group-heading) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ color: var(--accent-cyan);
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.root :global(.model-group-heading em) {
+ color: var(--text-muted);
+ font-style: normal;
+ letter-spacing: 0;
+}
+
+.root :global(.model-bar-track) {
+ flex: 1;
+ height: 20px;
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 6px;
+ position: relative;
+ overflow: hidden;
+}
+
+.root :global(.model-bar-fill) {
+ height: 100%;
+ border-radius: 6px;
+ background: linear-gradient(
+ 90deg,
+ rgba(14, 165, 233, 0.72),
+ rgba(34, 211, 238, 0.92)
+ );
+ transition: width 0.6s ease-out;
+}
+
+.root :global(.model-bar-fill.deb) {
+ background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green));
+ box-shadow: 0 0 8px rgba(34, 211, 238, 0.3);
+}
+
+.root :global(.model-bar-value) {
+ position: absolute;
+ top: 50%;
+ right: 8px;
+ z-index: 3;
+ transform: translateY(-50%);
+ color: var(--text-primary);
+ font-size: 11px;
+ font-weight: 800;
+ line-height: 1;
+ max-width: calc(100% - 12px);
+ overflow: visible;
+ pointer-events: none;
+ text-align: right;
+ text-shadow: 0 1px 2px rgba(2, 6, 23, 0.9);
+ white-space: nowrap;
+}
+
+.root :global(.model-bar-value.deb) {
+ color: #ecfeff;
+}
+
+.root :global(.model-deb-line) {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ background: var(--accent-cyan);
+ box-shadow: 0 0 6px var(--accent-cyan);
+ z-index: 2;
+}
+
+/* ── Forecast Table ── */
+.root :global(.forecast-table) {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
+ gap: 8px;
+}
+
+.root :global(.forecast-inline-note) {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 10px;
+ padding: 8px 10px;
+ border: 1px solid rgba(34, 211, 238, 0.18);
+ border-radius: 8px;
+ color: var(--text-secondary);
+ background: rgba(34, 211, 238, 0.06);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.root :global(.forecast-inline-note::before) {
+ content: "";
+ width: 8px;
+ height: 8px;
+ flex: 0 0 auto;
+ border-radius: 999px;
+ background: #22d3ee;
+ box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.12);
+ animation: pulseGlow 1.25s ease-in-out infinite;
+}
+
+.root :global(.forecast-day) {
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border-subtle);
+ border-radius: 10px;
+ padding: 10px;
+ text-align: center;
+ transition: var(--transition);
+}
+.root :global(.forecast-day:hover) {
+ border-color: var(--border-glass);
+ background: rgba(255, 255, 255, 0.05);
+}
+.root :global(.forecast-day .f-date) {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-bottom: 4px;
+}
+.root :global(.forecast-day .f-temp) {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+.root :global(.forecast-day.today) {
+ border-color: var(--accent-blue);
+ background: rgba(99, 102, 241, 0.08);
+}
+.root :global(.forecast-day.today .f-date) {
+ color: var(--accent-cyan);
+}
+
+.root :global(.forecast-day.selected) {
+ border-color: var(--accent-cyan);
+ background: rgba(34, 211, 238, 0.1);
+ box-shadow: 0 0 12px rgba(34, 211, 238, 0.2);
+}
+.root :global(.forecast-day.selected .f-date) {
+ color: var(--accent-cyan);
+}
+
+.root :global(.forecast-day-sync) {
+ cursor: wait;
+ opacity: 0.76;
+ background: linear-gradient(
+ 90deg,
+ rgba(15, 23, 42, 0.86),
+ rgba(30, 41, 59, 0.72),
+ rgba(15, 23, 42, 0.86)
+ );
+ background-size: 220% 100%;
+ animation: panelSkeletonSweep 1.35s ease-in-out infinite;
+}
+
+.root :global(.forecast-day-sync .f-temp) {
+ color: var(--text-muted);
+}
+
+@keyframes panelSkeletonSweep {
+ 0% {
+ background-position: 120% 0;
+ }
+ 100% {
+ background-position: -120% 0;
+ }
+}
+
+@media (max-width: 720px) {
+ .root :global(.forecast-table) {
+ display: flex;
+ gap: 10px;
+ overflow-x: auto;
+ padding-bottom: 4px;
+ scroll-snap-type: x proximity;
+ }
+
+ .root :global(.forecast-table::-webkit-scrollbar) {
+ height: 6px;
+ }
+
+ .root :global(.forecast-table::-webkit-scrollbar-thumb) {
+ background: rgba(148, 163, 184, 0.32);
+ border-radius: 999px;
+ }
+
+ .root :global(.forecast-day) {
+ flex: 0 0 112px;
+ min-width: 112px;
+ scroll-snap-align: start;
+ }
+}
+
+.root :global(.sun-info) {
+ margin-top: 10px;
+ font-size: 12px;
+ color: var(--text-muted);
+ display: flex;
+ gap: 16px;
+ justify-content: center;
+}
+
+/* ── AI Section ── */
+.root :global(.ai-box) {
+ background: rgba(99, 102, 241, 0.06);
+ border: 1px solid rgba(99, 102, 241, 0.15);
+ border-radius: 12px;
+ padding: 16px;
+ font-size: 13px;
+ line-height: 1.7;
+ color: var(--text-secondary);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.root :global(.ai-placeholder) {
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+/* ── Risk Section ── */
+.root :global(.risk-info) {
+ font-size: 12px;
+ color: var(--text-secondary);
+ line-height: 1.8;
+ word-break: break-word; /* 确保数字和英文长句也能折行 */
+ white-space: normal;
+}
+.root :global(.risk-info .risk-row) {
+ display: flex;
+ gap: 8px;
+ align-items: flex-start;
+}
+.root :global(.risk-info .risk-label) {
+ color: var(--text-muted);
+ min-width: 60px;
+ flex-shrink: 0;
+}
+
+/* ── Nearby Markers ── */
+.root :global(.nearby-marker) {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: rgba(13, 17, 28, 0.85);
+ color: var(--text-primary);
+ border: 1px solid rgba(34, 211, 238, 0.25);
+ border-radius: 12px;
+ padding: 5px 12px;
+ font-size: 11px;
+ font-weight: 500;
+ box-shadow:
+ 0 4px 16px rgba(0, 0, 0, 0.6),
+ inset 0 0 10px rgba(34, 211, 238, 0.05);
+ white-space: nowrap;
+ backdrop-filter: blur(12px);
+ pointer-events: none;
+ animation: markerFadeIn 0.35s cubic-bezier(0.16, 1, 0.3, 1);
+ transition: border-color 0.3s ease;
+}
+
+.root :global(.nearby-marker:hover) {
+ border-color: var(--accent-cyan);
+}
+
+.root :global(.nearby-name) {
+ color: rgba(255, 255, 255, 0.6);
+ font-weight: 400;
+ max-width: 120px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.root :global(.nearby-temp) {
+ font-weight: 800;
+ color: var(--accent-cyan);
+ font-size: 13px;
+ text-shadow: 0 0 10px rgba(34, 211, 238, 0.3);
+}
+
+.root :global(.nearby-unit) {
+ font-weight: 600;
+ font-size: 10px;
+ color: var(--accent-cyan);
+ opacity: 0.8;
+ margin-left: 1px;
+}
+
+.root :global(.wind-info) {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ margin-left: 4px;
+ padding-left: 8px;
+ border-left: 1.5px solid rgba(255, 255, 255, 0.15);
+}
+
+.root :global(.wind-arrow) {
+ display: inline-block;
+ font-size: 14px;
+ color: var(--accent-green);
+ text-shadow: 0 0 8px rgba(52, 211, 153, 0.4);
+}
+
+.root :global(.wind-speed) {
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--text-primary);
+ font-variant-numeric: tabular-nums;
+}
+
+@keyframes markerFadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(5px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes loading-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/frontend/components/dashboard/FutureForecastForwardView.tsx b/frontend/components/dashboard/FutureForecastForwardView.tsx
new file mode 100644
index 00000000..dda6f772
--- /dev/null
+++ b/frontend/components/dashboard/FutureForecastForwardView.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import type { useDashboardStore } from "@/hooks/useDashboardStore";
+import type { useI18n } from "@/hooks/useI18n";
+import type { getFutureModalView } from "@/lib/dashboard-utils";
+import {
+ FutureModelForecastPanel,
+ FutureProbabilityPanel,
+ FutureTemperaturePathChart,
+} from "./FutureForecastModalPanels";
+
+type DashboardDetail = NonNullable<
+ ReturnType["selectedDetail"]
+>;
+type FutureModalView = ReturnType;
+type TranslationFn = ReturnType["t"];
+
+export function FutureForecastForwardView({
+ dateStr,
+ detail,
+ t,
+ view,
+}: {
+ dateStr: string;
+ detail: DashboardDetail;
+ t: TranslationFn;
+ view: FutureModalView;
+}) {
+ return (
+ <>
+
+
+ {t("future.targetForecast")}
+
+ {view.forecastEntry?.max_temp ?? "--"}
+ {detail.temp_symbol}
+
+
+
+ {t("future.deb")}
+
+ {view.deb ?? "--"}
+ {detail.temp_symbol}
+
+
+
+ {t("future.mu")}
+
+ {view.mu != null
+ ? `${view.mu.toFixed(1)}${detail.temp_symbol}`
+ : "--"}
+
+
+
+ {t("future.score")}
+
+ {view.front.score > 0 ? "+" : ""}
+ {view.front.score}
+
+
+
+
+
+ {t("future.targetTempTrend")}
+
+
+
+
+
+ {t("future.probability")}
+
+
+
+ {t("future.models")}
+
+
+
+ >
+ );
+}
diff --git a/frontend/components/dashboard/FutureForecastModal.module.css b/frontend/components/dashboard/FutureForecastModal.module.css
new file mode 100644
index 00000000..c60e11a9
--- /dev/null
+++ b/frontend/components/dashboard/FutureForecastModal.module.css
@@ -0,0 +1,1543 @@
+/* Future forecast modal chrome and evidence panels. */
+
+.root :global(.modal-content.large.future-modal) {
+ width: min(96vw, 1720px);
+ max-width: 1720px;
+}
+
+.root :global(.future-modal-body) {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding-right: 14px;
+}
+
+.root :global(.future-modal-body-refreshing > :not(.future-v2-refresh-lock)) {
+ opacity: 0.18;
+ filter: blur(1px);
+ pointer-events: none;
+ user-select: none;
+}
+
+.root :global(.future-v2-refresh-lock) {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 14px 16px;
+ border: 1px solid rgba(34, 211, 238, 0.34);
+ border-radius: 8px;
+ background: rgba(8, 13, 24, 0.96);
+ box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34);
+}
+
+.root :global(.future-v2-refresh-lock strong) {
+ display: block;
+ color: #e0faff;
+ font-size: 14px;
+ font-weight: 900;
+}
+
+.root :global(.future-v2-refresh-lock p) {
+ margin: 5px 0 0;
+ color: var(--text-secondary);
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.root :global(.future-v2-refresh-spinner) {
+ width: 18px;
+ height: 18px;
+ flex: 0 0 auto;
+ margin-top: 1px;
+ border: 2px solid rgba(34, 211, 238, 0.18);
+ border-top-color: #22d3ee;
+ border-radius: 999px;
+ animation: spin 0.8s linear infinite;
+}
+
+.root :global(.future-v2-sync-strip) {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 10px;
+}
+
+.root :global(.future-v2-sync-strip-compact) {
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 8px;
+}
+
+.root :global(.future-v2-sync-strip-compact .future-v2-sync-chip) {
+ padding: 8px 10px;
+ border-radius: 8px;
+ background: rgba(15, 23, 42, 0.62);
+}
+
+.root :global(.future-v2-sync-strip-compact .future-v2-sync-copy span) {
+ display: none;
+}
+
+.root :global(.future-v2-meteorology-brief) {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(320px, 0.56fr);
+ gap: 14px;
+ padding: 16px;
+ border: 1px solid rgba(34, 211, 238, 0.22);
+ border-radius: 8px;
+ background: linear-gradient(
+ 180deg,
+ rgba(8, 47, 73, 0.42),
+ rgba(15, 23, 42, 0.78)
+ );
+}
+
+.root :global(.future-v2-meteorology-copy) {
+ display: grid;
+ gap: 9px;
+ min-width: 0;
+}
+
+.root :global(.future-v2-anchor-row) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ min-width: 0;
+}
+
+.root :global(.future-v2-anchor-source) {
+ display: inline-flex;
+ align-items: center;
+ min-height: 24px;
+ padding: 4px 8px;
+ border: 1px solid rgba(34, 211, 238, 0.28);
+ border-radius: 8px;
+ background: rgba(8, 47, 73, 0.36);
+ color: #6FB7FF;
+ font-size: 11px;
+ font-weight: 800;
+ line-height: 1.25;
+ white-space: nowrap;
+}
+
+.root :global(.future-v2-meteorology-copy h3) {
+ margin: 0;
+ color: #f8fafc;
+ font-size: 18px;
+ font-weight: 800;
+ line-height: 1.35;
+ letter-spacing: 0;
+}
+
+.root :global(.future-v2-anchor-rule) {
+ margin: 0;
+ max-width: 860px;
+ color: var(--text-muted);
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.root :global(.future-v2-meteorology-meta) {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.root :global(.future-v2-meteorology-meta span) {
+ min-height: 26px;
+ display: inline-flex;
+ align-items: center;
+ padding: 5px 8px;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: 8px;
+ background: rgba(15, 23, 42, 0.62);
+ color: #cbd5e1;
+ font-size: 12px;
+ line-height: 1.35;
+}
+
+.root :global(.future-v2-decision-rail) {
+ display: grid;
+ grid-template-columns: minmax(0, 0.88fr) minmax(0, 1.35fr);
+ gap: 10px;
+ min-width: 0;
+}
+
+.root :global(.future-v2-decision-anchor) {
+ min-width: 0;
+ padding: 12px;
+ border: 1px solid rgba(96, 165, 250, 0.2);
+ border-radius: 8px;
+ background: rgba(2, 6, 23, 0.5);
+}
+
+.root :global(.future-v2-decision-anchor span),
+.root :global(.future-v2-decision-grid span) {
+ display: block;
+ color: var(--text-muted);
+ font-size: 11px;
+ line-height: 1.3;
+}
+
+.root :global(.future-v2-decision-anchor strong) {
+ display: block;
+ margin-top: 8px;
+ color: #f8fafc;
+ font-size: 14px;
+ font-weight: 800;
+ line-height: 1.35;
+ letter-spacing: 0;
+}
+
+.root :global(.future-v2-decision-anchor small) {
+ display: block;
+ margin-top: 6px;
+ color: #6FB7FF;
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1.3;
+}
+
+.root :global(.future-v2-decision-grid) {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ min-width: 0;
+}
+
+.root :global(.future-v2-decision-grid div) {
+ min-width: 0;
+ padding: 10px;
+ border: 1px solid rgba(34, 211, 238, 0.18);
+ border-radius: 8px;
+ background: rgba(2, 6, 23, 0.5);
+}
+
+.root :global(.future-v2-decision-grid strong) {
+ display: block;
+ margin-top: 7px;
+ color: #6FB7FF;
+ font-size: 18px;
+ font-weight: 800;
+ line-height: 1;
+ letter-spacing: 0;
+}
+
+.root :global(.future-v2-meteorology-paths) {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.root :global(.future-v2-meteorology-paths div) {
+ min-width: 0;
+ padding: 12px;
+ border: 1px solid rgba(34, 211, 238, 0.18);
+ border-radius: 8px;
+ background: rgba(2, 6, 23, 0.5);
+}
+
+.root :global(.future-v2-meteorology-paths span) {
+ display: block;
+ color: var(--text-muted);
+ font-size: 11px;
+ line-height: 1.3;
+}
+
+.root :global(.future-v2-meteorology-paths strong) {
+ display: block;
+ margin-top: 8px;
+ color: #6FB7FF;
+ font-size: 22px;
+ font-weight: 800;
+ line-height: 1;
+ letter-spacing: 0;
+}
+
+.root :global(.future-v2-sync-chip) {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 11px 12px;
+ border-radius: 12px;
+ border: 1px solid rgba(52, 211, 153, 0.16);
+ background: rgba(16, 185, 129, 0.06);
+}
+
+.root :global(.future-v2-sync-chip.syncing) {
+ border-color: rgba(34, 211, 238, 0.2);
+ background: rgba(34, 211, 238, 0.07);
+}
+
+.root :global(.future-v2-sync-dot) {
+ width: 9px;
+ height: 9px;
+ border-radius: 999px;
+ background: #22C55E;
+ margin-top: 5px;
+ flex: 0 0 auto;
+ box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.12);
+}
+
+.root :global(.future-v2-sync-chip.syncing .future-v2-sync-dot) {
+ background: #22d3ee;
+ box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.12);
+ animation: pulseGlow 1.25s ease-in-out infinite;
+}
+
+.root :global(.future-v2-sync-copy) {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+}
+
+.root :global(.future-v2-sync-copy strong) {
+ color: var(--text-primary);
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 1.3;
+}
+
+.root :global(.future-v2-sync-copy span) {
+ color: var(--text-muted);
+ font-size: 11px;
+ line-height: 1.45;
+}
+
+@keyframes pulseGlow {
+ 0%,
+ 100% {
+ opacity: 0.9;
+ transform: scale(1);
+ }
+
+ 50% {
+ opacity: 1;
+ transform: scale(1.08);
+ }
+}
+
+.root :global(.future-v2-layout) {
+ display: grid;
+ grid-template-columns: 360px minmax(0, 1fr);
+ gap: 14px;
+ align-items: start;
+}
+
+.root :global(.future-v2-left) {
+ display: grid;
+ gap: 12px;
+}
+
+.root :global(.future-v2-right) {
+ display: grid;
+ gap: 14px;
+}
+
+.root :global(.future-v2-card) {
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid var(--border-subtle);
+ border-radius: 14px;
+ padding: 14px;
+}
+
+.root :global(.future-v2-panel-loading) {
+ min-height: 120px;
+ border-radius: 14px;
+ background:
+ linear-gradient(
+ 90deg,
+ rgba(255, 255, 255, 0.03),
+ rgba(77, 163, 255, 0.08),
+ rgba(255, 255, 255, 0.03)
+ );
+ background-size: 220% 100%;
+ animation: future-panel-loading 1.4s ease-in-out infinite;
+}
+
+@keyframes future-panel-loading {
+ 0% {
+ background-position: 100% 0;
+ }
+
+ 100% {
+ background-position: -100% 0;
+ }
+}
+
+.root :global(.future-v2-card-head) {
+ display: grid;
+ gap: 6px;
+}
+
+.root :global(.future-v2-card-kicker) {
+ color: var(--text-muted);
+ font-size: 11px;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+.root :global(.future-v2-focus-card) {
+ background: linear-gradient(
+ 180deg,
+ rgba(34, 211, 238, 0.08) 0%,
+ rgba(255, 255, 255, 0.02) 100%
+ );
+}
+
+.root :global(.future-v2-support-card) {
+ background: linear-gradient(
+ 180deg,
+ rgba(148, 163, 184, 0.08) 0%,
+ rgba(255, 255, 255, 0.02) 100%
+ );
+}
+
+.root :global(.future-v2-hero-card) {
+ background: linear-gradient(
+ 180deg,
+ rgba(99, 102, 241, 0.12) 0%,
+ rgba(255, 255, 255, 0.02) 100%
+ );
+}
+
+.root :global(.future-v2-hero-title) {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text-primary);
+ letter-spacing: -0.01em;
+}
+
+.root :global(.future-v2-hero-main) {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin-top: 14px;
+}
+
+.root :global(.future-v2-hero-temp) {
+ font-size: 56px;
+ font-weight: 800;
+ letter-spacing: -0.04em;
+ line-height: 1;
+ color: #f8fafc;
+ text-shadow: 0 10px 30px rgba(34, 211, 238, 0.14);
+}
+
+.root :global(.future-v2-hero-divider) {
+ width: 1px;
+ height: 56px;
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.root :global(.future-v2-hero-weather) {
+ display: grid;
+ gap: 4px;
+ font-size: 15px;
+ color: var(--text-secondary);
+}
+
+.root :global(.future-v2-hero-icon) {
+ display: inline-flex;
+ align-items: center;
+ line-height: 1;
+}
+
+.root :global(.future-v2-hero-obs) {
+ margin-top: 10px;
+ color: var(--text-secondary);
+ font-size: 26px;
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+}
+
+.root :global(.future-v2-daylight) {
+ margin-top: 14px;
+ padding: 12px 12px 10px;
+ border-radius: 12px;
+ border: 1px solid rgba(245, 158, 11, 0.14);
+ background: linear-gradient(
+ 180deg,
+ rgba(245, 158, 11, 0.08) 0%,
+ rgba(34, 211, 238, 0.04) 100%
+ );
+}
+
+.root :global(.future-v2-daylight-head) {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 8px;
+}
+
+.root :global(.future-v2-daylight-head span) {
+ color: var(--text-muted);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.root :global(.future-v2-daylight-head strong) {
+ color: #fde68a;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.root :global(.future-v2-daylight-bar) {
+ position: relative;
+ height: 12px;
+ border-radius: 999px;
+ background: linear-gradient(
+ 90deg,
+ rgba(15, 23, 42, 0.85) 0%,
+ rgba(56, 189, 248, 0.18) 15%,
+ rgba(250, 204, 21, 0.24) 50%,
+ rgba(56, 189, 248, 0.16) 85%,
+ rgba(15, 23, 42, 0.85) 100%
+ );
+ overflow: hidden;
+}
+
+.root :global(.future-v2-daylight-bar::before) {
+ content: "";
+ position: absolute;
+ inset: 0;
+ width: var(--daylight-progress, 0%);
+ background: linear-gradient(
+ 90deg,
+ rgba(34, 211, 238, 0.22) 0%,
+ rgba(250, 204, 21, 0.4) 70%,
+ rgba(251, 146, 60, 0.5) 100%
+ );
+}
+
+.root :global(.future-v2-daylight-bar::after) {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: var(--daylight-progress, 0%);
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: radial-gradient(circle, #fff7cc 0%, #facc15 65%, #fb923c 100%);
+ box-shadow:
+ 0 0 0 3px rgba(250, 204, 21, 0.16),
+ 0 0 18px rgba(250, 204, 21, 0.22);
+ transform: translate(-50%, -50%);
+}
+
+.root :global(.future-v2-daylight-times) {
+ margin-top: 8px;
+ display: flex;
+ justify-content: space-between;
+ color: var(--text-secondary);
+ font-size: 11px;
+}
+
+.root :global(.future-v2-mini-grid) {
+ margin-top: 14px;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.root :global(.future-v2-mini-grid.future-v2-mini-grid-tight) {
+ margin-top: 8px;
+}
+
+.root :global(.future-v2-mini-item) {
+ border-radius: 10px;
+ border: 1px solid var(--border-subtle);
+ background: rgba(255, 255, 255, 0.025);
+ padding: 10px;
+ display: grid;
+ gap: 4px;
+}
+
+.root :global(.future-v2-signal-item) {
+ gap: 8px;
+}
+
+.root :global(.future-v2-signal-head) {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+}
+
+.root :global(.future-v2-mini-item span) {
+ color: var(--text-muted);
+ font-size: 11px;
+}
+
+.root :global(.future-v2-mini-item strong) {
+ color: var(--text-primary);
+ font-size: 23px;
+ line-height: 1.15;
+ font-weight: 700;
+}
+
+.root :global(.future-v2-signal-tag) {
+ font-style: normal;
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 1;
+ padding: 5px 8px;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--text-secondary);
+}
+
+.root :global(.future-v2-signal-tag.cyan) {
+ color: #6FB7FF;
+ border-color: rgba(34, 211, 238, 0.22);
+ background: rgba(34, 211, 238, 0.08);
+}
+
+.root :global(.future-v2-signal-tag.blue) {
+ color: #93c5fd;
+ border-color: rgba(96, 165, 250, 0.22);
+ background: rgba(96, 165, 250, 0.08);
+}
+
+.root :global(.future-v2-signal-tag.amber) {
+ color: #fbbf24;
+ border-color: rgba(251, 191, 36, 0.22);
+ background: rgba(251, 191, 36, 0.08);
+}
+
+.root :global(.future-v2-signal-meter) {
+ position: relative;
+ height: 7px;
+ border-radius: 999px;
+ overflow: hidden;
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.root :global(.future-v2-signal-fill) {
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(
+ 90deg,
+ rgba(203, 213, 225, 0.25) 0%,
+ rgba(255, 255, 255, 0.32) 100%
+ );
+}
+
+.root :global(.future-v2-signal-fill.cyan) {
+ background: linear-gradient(90deg, #155e75 0%, #22d3ee 100%);
+}
+
+.root :global(.future-v2-signal-fill.blue) {
+ background: linear-gradient(90deg, #2563eb 0%, #60a5fa 100%);
+}
+
+.root :global(.future-v2-signal-fill.amber) {
+ background: linear-gradient(90deg, #b45309 0%, #f59e0b 100%);
+}
+
+.root :global(.future-v2-pace-card) {
+ background:
+ radial-gradient(
+ circle at top right,
+ rgba(34, 211, 238, 0.12) 0%,
+ rgba(15, 23, 42, 0) 42%
+ ),
+ linear-gradient(
+ 180deg,
+ rgba(8, 15, 28, 0.96) 0%,
+ rgba(255, 255, 255, 0.02) 100%
+ );
+}
+
+.root :global(.future-v2-pace-head) {
+ margin-top: 12px;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 10px;
+}
+
+.root :global(.future-v2-pace-kicker) {
+ color: var(--text-muted);
+ font-size: 11px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.root :global(.future-v2-pace-delta) {
+ margin-top: 10px;
+ font-size: 34px;
+ font-weight: 800;
+ letter-spacing: -0.04em;
+ line-height: 1.05;
+}
+
+.root :global(.future-v2-pace-delta.warm) {
+ color: #fbbf24;
+}
+
+.root :global(.future-v2-pace-delta.cold) {
+ color: #6FB7FF;
+}
+
+.root :global(.future-v2-pace-delta.neutral) {
+ color: #e2e8f0;
+}
+
+.root :global(.future-v2-pace-summary) {
+ margin-top: 8px;
+ color: var(--text-secondary);
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.root :global(.future-v2-pace-signal-grid) {
+ margin-top: 12px;
+ display: grid;
+ gap: 10px;
+}
+
+.root :global(.future-v2-pace-signal-card) {
+ border-radius: 10px;
+ border: 1px solid var(--border-subtle);
+ background: rgba(255, 255, 255, 0.025);
+ padding: 10px;
+ display: grid;
+ gap: 8px;
+}
+
+.root :global(.future-v2-pace-signal-note) {
+ color: var(--text-secondary);
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.root :global(.future-v2-pace-meter) {
+ position: relative;
+ height: 10px;
+ margin-top: 12px;
+ border-radius: 999px;
+ overflow: hidden;
+ background: linear-gradient(
+ 90deg,
+ rgba(34, 211, 238, 0.12) 0%,
+ rgba(255, 255, 255, 0.06) 46%,
+ rgba(255, 255, 255, 0.06) 54%,
+ rgba(251, 191, 36, 0.12) 100%
+ );
+}
+
+.root :global(.future-v2-pace-meter-midline) {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 50%;
+ width: 1px;
+ background: rgba(255, 255, 255, 0.2);
+ transform: translateX(-50%);
+ z-index: 1;
+}
+
+.root :global(.future-v2-pace-meter-fill) {
+ position: absolute;
+ top: 0;
+ left: var(--pace-left, 46%);
+ width: var(--pace-width, 8%);
+ bottom: 0;
+ border-radius: inherit;
+}
+
+.root :global(.future-v2-pace-meter-fill.warm) {
+ background: linear-gradient(90deg, #b45309 0%, #fbbf24 100%);
+}
+
+.root :global(.future-v2-pace-meter-fill.cold) {
+ background: linear-gradient(90deg, #0f766e 0%, #6FB7FF 100%);
+}
+
+.root :global(.future-v2-pace-meter-fill.neutral) {
+ background: linear-gradient(90deg, #6B7A90 0%, #cbd5e1 100%);
+}
+
+.root :global(.intraday-scene-shell) {
+ margin-top: 12px;
+ margin-bottom: 10px;
+ border-radius: 14px;
+ border: 1px solid rgba(99, 102, 241, 0.14);
+ background: radial-gradient(
+ circle at 50% 18%,
+ rgba(34, 211, 238, 0.14) 0%,
+ rgba(255, 255, 255, 0.02) 48%,
+ rgba(255, 255, 255, 0.015) 100%
+ );
+ overflow: hidden;
+}
+
+.root :global(.intraday-scene-frame) {
+ height: 196px;
+}
+
+.root :global(.intraday-scene-frame canvas) {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+
+.root :global(.intraday-scene-legend) {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ padding: 10px 12px 12px;
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+ background: rgba(2, 6, 23, 0.18);
+}
+
+.root :global(.intraday-scene-chip) {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ min-width: 0;
+}
+
+.root :global(.intraday-scene-chip-dot) {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin-top: 5px;
+ box-shadow: 0 0 10px currentColor;
+ flex: 0 0 auto;
+}
+
+.root :global(.intraday-scene-chip-copy) {
+ min-width: 0;
+ display: grid;
+ gap: 2px;
+}
+
+.root :global(.intraday-scene-chip-copy strong) {
+ color: var(--text-primary);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.root :global(.intraday-scene-chip-copy span) {
+ color: var(--text-secondary);
+ font-size: 11px;
+ line-height: 1.35;
+}
+
+.root :global(.future-v2-card-title) {
+ margin: 0;
+ color: var(--text-primary);
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+}
+
+.root :global(.future-v2-stack) {
+ margin-top: 14px;
+ display: grid;
+ gap: 12px;
+}
+
+.root :global(.future-v2-subpanel) {
+ border: 1px solid var(--border-subtle);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.025);
+ padding: 12px;
+}
+
+.root :global(.future-v2-subpanel-title) {
+ margin: 0;
+ color: var(--text-primary);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+}
+
+.root :global(.future-v2-market-v3) {
+ margin-top: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ position: relative;
+}
+
+.root :global(.future-v2-market-card) {
+ background:
+ radial-gradient(
+ circle at top right,
+ rgba(34, 197, 94, 0.08) 0%,
+ rgba(15, 23, 42, 0) 40%
+ ),
+ linear-gradient(
+ 180deg,
+ rgba(7, 16, 26, 0.96) 0%,
+ rgba(255, 255, 255, 0.02) 100%
+ );
+}
+
+.root :global(.market-layer-loading-overlay) {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(15, 23, 42, 0.6);
+ backdrop-filter: blur(2px);
+ z-index: 10;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ border-radius: 8px;
+ color: var(--accent-cyan);
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.root :global(.market-sub-title) {
+ color: var(--text-secondary);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 8px;
+ font-weight: 600;
+}
+
+.root :global(.market-layer-target) {
+ background: rgba(34, 211, 238, 0.04);
+ border: 1px solid rgba(34, 211, 238, 0.15);
+ border-radius: 8px;
+ padding: 12px;
+}
+
+.root :global(.market-target-header) {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.root :global(.market-target-bucket) {
+ font-size: 16px;
+ color: var(--text-primary);
+ font-weight: 700;
+}
+
+.root :global(.market-edge-box) {
+ background: rgba(15, 23, 42, 0.4);
+ border-radius: 6px;
+ padding: 10px;
+}
+
+.root :global(.market-edge-header) {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 8px;
+ font-size: 12px;
+ color: var(--text-primary);
+ font-weight: 600;
+}
+
+.root :global(.market-edge-val) {
+ font-size: 15px;
+ font-weight: 700;
+}
+
+.root :global(.market-edge-val.positive) {
+ color: var(--accent-green);
+}
+
+.root :global(.market-edge-val.negative) {
+ color: var(--text-secondary);
+}
+
+.root :global(.market-edge-compare) {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+ padding-top: 8px;
+}
+
+.root :global(.edge-stat) {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.root :global(.edge-label) {
+ font-size: 10px;
+ color: var(--text-muted);
+}
+
+.root :global(.edge-value) {
+ font-size: 13px;
+ color: var(--text-secondary);
+ font-weight: 600;
+}
+
+.root :global(.market-layer-book),
+.root :global(.market-layer-context) {
+ padding: 0 4px;
+}
+
+.root :global(.market-book-row),
+.root :global(.market-context-row) {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 13px;
+ padding: 6px 0;
+ border-bottom: 1px dashed rgba(255, 255, 255, 0.05);
+}
+
+.root :global(.market-book-row:last-child),
+.root :global(.market-context-row:last-child) {
+ border-bottom: none;
+}
+
+.root :global(.book-label),
+.root :global(.market-context-row span) {
+ color: var(--text-secondary);
+}
+
+.root :global(.book-quote strong),
+.root :global(.market-context-row strong) {
+ color: var(--text-primary);
+ font-weight: 600;
+}
+
+.root :global(.book-quote span) {
+ color: var(--text-muted);
+ font-size: 11px;
+}
+
+.root :global(.book-spread) {
+ color: var(--text-muted);
+ font-size: 11px;
+}
+
+.root :global(.mt-3) {
+ margin-top: 12px;
+}
+
+.root :global(.future-v2-market-signal) {
+ margin-top: 10px;
+ padding: 8px 10px;
+ border-radius: 10px;
+ background: rgba(34, 211, 238, 0.08);
+ border: 1px solid rgba(34, 211, 238, 0.22);
+ color: var(--accent-cyan);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.root :global(.future-v2-main-chart) {
+ min-height: 340px;
+}
+
+.root :global(.future-v2-chart-thresholds) {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.root :global(.future-v2-chart-thresholds span) {
+ min-height: 26px;
+ display: inline-flex;
+ align-items: center;
+ padding: 5px 8px;
+ border: 1px solid rgba(34, 211, 238, 0.16);
+ border-radius: 8px;
+ background: rgba(8, 47, 73, 0.24);
+ color: #cbd5e1;
+ font-size: 12px;
+ line-height: 1.35;
+}
+
+.root :global(.future-v2-meteorology-grid) {
+ display: grid;
+ grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.9fr) minmax(0, 0.9fr);
+ gap: 12px;
+}
+
+.root :global(.future-v2-evidence-list) {
+ display: grid;
+ gap: 8px;
+}
+
+.root :global(.future-v2-evidence-row) {
+ padding: 10px;
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ border-left-width: 3px;
+ border-radius: 8px;
+ background: rgba(15, 23, 42, 0.5);
+}
+
+.root :global(.future-v2-evidence-row.cyan) {
+ border-left-color: #22d3ee;
+}
+
+.root :global(.future-v2-evidence-row.amber) {
+ border-left-color: #f59e0b;
+}
+
+.root :global(.future-v2-evidence-row.blue) {
+ border-left-color: #60a5fa;
+}
+
+.root :global(.future-v2-evidence-head) {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ align-items: flex-start;
+}
+
+.root :global(.future-v2-evidence-head strong) {
+ color: #f8fafc;
+ font-size: 13px;
+ line-height: 1.35;
+}
+
+.root :global(.future-v2-evidence-head span) {
+ color: var(--text-muted);
+ font-size: 11px;
+ line-height: 1.35;
+ text-align: right;
+}
+
+.root :global(.future-v2-evidence-row p) {
+ margin: 7px 0 0;
+ color: var(--text-secondary);
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.root :global(.future-v2-rule-list) {
+ display: grid;
+ gap: 8px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.root :global(.future-v2-rule-list li) {
+ position: relative;
+ padding-left: 16px;
+ color: var(--text-secondary);
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.root :global(.future-v2-rule-list li::before) {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0.65em;
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background: #22d3ee;
+}
+
+.root
+ :global(.future-v2-rule-panel:nth-child(2) .future-v2-rule-list li::before) {
+ background: #f59e0b;
+}
+
+.root :global(.future-v2-model-note) {
+ margin-top: 12px;
+ padding-top: 10px;
+ border-top: 1px solid rgba(148, 163, 184, 0.14);
+ color: var(--text-muted);
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.root :global(.future-modal-section) {
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid var(--border-subtle);
+ border-radius: 14px;
+ padding: 14px;
+}
+
+.root :global(.future-modal-section h3) {
+ margin: 0 0 14px;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text-primary);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.root :global(.section-inline-icon) {
+ display: inline-flex;
+ width: 16px;
+ height: 16px;
+ align-items: center;
+ justify-content: center;
+ color: var(--accent-cyan);
+ flex: 0 0 16px;
+}
+
+.root :global(.section-inline-icon svg) {
+ width: 16px;
+ height: 16px;
+}
+
+.root :global(.future-modal-grid) {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+}
+
+.root :global(.future-chart-wrapper) {
+ height: 220px;
+}
+
+.root :global(.future-prob-bars .prob-bar-fill) {
+ min-width: 56px;
+}
+
+.root :global(.future-trend-grid) {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.root :global(.future-trend-card) {
+ border: 1px solid var(--border-subtle);
+ border-radius: 12px;
+ padding: 12px;
+ background: rgba(255, 255, 255, 0.025);
+}
+
+.root :global(.future-trend-label) {
+ display: block;
+ color: var(--text-muted);
+ font-size: 11px;
+ margin-bottom: 6px;
+}
+
+.root :global(.future-trend-value) {
+ display: block;
+ color: var(--text-primary);
+ font-size: 17px;
+ font-weight: 700;
+}
+
+.root :global(.future-trend-meter) {
+ position: relative;
+ height: 8px;
+ margin-top: 10px;
+ border-radius: 999px;
+ overflow: hidden;
+ background: rgba(255, 255, 255, 0.06);
+}
+
+.root :global(.future-trend-meter.center) {
+ background: linear-gradient(
+ 90deg,
+ rgba(56, 189, 248, 0.1) 0%,
+ rgba(255, 255, 255, 0.05) 48%,
+ rgba(255, 255, 255, 0.05) 52%,
+ rgba(245, 158, 11, 0.1) 100%
+ );
+}
+
+.root :global(.future-trend-meter-midline) {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 50%;
+ width: 1px;
+ background: rgba(255, 255, 255, 0.18);
+ transform: translateX(-50%);
+ z-index: 1;
+}
+
+.root :global(.future-trend-meter-fill) {
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ border-radius: inherit;
+ background: linear-gradient(90deg, #334155 0%, #9FB2C7 100%);
+}
+
+.root :global(.future-trend-meter-fill.warm) {
+ background: linear-gradient(90deg, #b45309 0%, #f59e0b 100%);
+}
+
+.root :global(.future-trend-meter-fill.cold) {
+ background: linear-gradient(90deg, #0f766e 0%, #22d3ee 100%);
+}
+
+.root :global(.future-trend-note) {
+ margin-top: 6px;
+ color: var(--text-secondary);
+ font-size: 12px;
+ line-height: 1.45;
+}
+
+.root :global(.future-trend-summary-muted) {
+ color: var(--text-muted);
+ border: 1px dashed rgba(148, 163, 184, 0.18);
+ background: rgba(255, 255, 255, 0.018);
+}
+
+.root :global(.future-trend-card-empty) {
+ background: linear-gradient(
+ 180deg,
+ rgba(148, 163, 184, 0.05) 0%,
+ rgba(255, 255, 255, 0.02) 100%
+ );
+}
+
+.root :global(.future-subsection-title) {
+ color: var(--text-primary);
+ font-size: 0.95rem;
+ font-weight: 700;
+ margin-top: 18px;
+}
+
+.root :global(.future-trend-value.warm) {
+ color: #22C55E;
+}
+
+.root :global(.future-trend-value.cold) {
+ color: #f59e0b;
+}
+
+.root :global(.future-text-block) {
+ color: var(--text-secondary);
+ font-size: 13px;
+ line-height: 1.7;
+}
+
+.root :global(.future-text-block strong) {
+ color: var(--text-primary);
+}
+
+.root :global(.future-front-score) {
+ display: grid;
+ gap: 12px;
+}
+
+.root :global(.future-front-bar) {
+ position: relative;
+ height: 10px;
+ border-radius: 999px;
+ background: linear-gradient(
+ 90deg,
+ rgba(56, 189, 248, 0.4) 0%,
+ rgba(56, 189, 248, 0.4) 30%,
+ rgba(255, 255, 255, 0.06) 45%,
+ rgba(255, 255, 255, 0.06) 55%,
+ rgba(245, 158, 11, 0.4) 70%,
+ rgba(245, 158, 11, 0.4) 100%
+ );
+ overflow: hidden;
+}
+
+.root :global(.future-front-bar::after) {
+ content: "";
+ position: absolute;
+ top: 50%;
+ left: var(--score-position, 50%);
+ width: 14px;
+ height: 14px;
+ border-radius: 999px;
+ background: var(--text-primary);
+ transform: translate(-50%, -50%);
+ box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
+ z-index: 2;
+ transition: left 0.5s ease;
+}
+
+.root :global(.future-front-meta) {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.root :global(.future-front-pill) {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ border-radius: 999px;
+ border: 1px solid var(--border-subtle);
+ background: rgba(255, 255, 255, 0.03);
+}
+
+@media (max-width: 960px) {
+ .root :global(.future-v2-meteorology-brief),
+ .root :global(.future-v2-meteorology-grid) {
+ grid-template-columns: 1fr;
+ }
+
+ .root :global(.future-v2-meteorology-paths) {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .root :global(.future-v2-decision-rail) {
+ grid-template-columns: 1fr;
+ }
+
+ .root :global(.future-v2-decision-grid) {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+
+ .root :global(.future-v2-layout) {
+ grid-template-columns: 1fr;
+ }
+
+ .root :global(.future-v2-mini-item strong) {
+ font-size: 18px;
+ }
+
+ .root :global(.future-v2-daylight-head) {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .root :global(.future-modal-grid),
+ .root :global(.future-trend-grid) {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 1200px) {
+ .root :global(.future-trend-grid) {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 640px) {
+ .root :global(.future-modal-body) {
+ gap: 12px;
+ padding-right: 0;
+ }
+
+ .root :global(.future-v2-meteorology-brief) {
+ padding: 12px;
+ }
+
+ .root :global(.future-v2-meteorology-paths) {
+ grid-template-columns: 1fr;
+ }
+
+ .root :global(.future-v2-anchor-row) {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .root :global(.future-v2-decision-grid) {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .root :global(.future-v2-meteorology-copy h3) {
+ font-size: 16px;
+ }
+
+ .root :global(.future-v2-card),
+ .root :global(.future-modal-section) {
+ border-radius: 12px;
+ padding: 12px;
+ }
+
+ .root :global(.future-v2-hero-main) {
+ align-items: flex-start;
+ gap: 12px;
+ }
+
+ .root :global(.future-v2-hero-temp) {
+ font-size: 44px;
+ }
+
+ .root :global(.future-v2-hero-divider) {
+ height: 44px;
+ }
+
+ .root :global(.future-v2-hero-obs) {
+ font-size: 18px;
+ }
+
+ .root :global(.future-v2-mini-grid) {
+ grid-template-columns: 1fr;
+ }
+
+ .root :global(.future-v2-mini-item strong) {
+ font-size: 20px;
+ }
+
+ .root :global(.future-v2-signal-head) {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .root :global(.future-v2-main-chart) {
+ min-height: 280px;
+ }
+
+ .root :global(.future-chart-wrapper) {
+ height: 190px;
+ }
+
+ .root :global(.future-trend-card) {
+ padding: 10px;
+ }
+
+ .root :global(.future-v2-subpanel) {
+ padding: 10px;
+ }
+
+ .root :global(.intraday-scene-frame) {
+ height: 174px;
+ }
+
+ .root :global(.intraday-scene-legend) {
+ grid-template-columns: 1fr;
+ }
+
+ .root :global(.future-trend-value) {
+ font-size: 16px;
+ }
+
+ .root :global(.future-trend-note),
+ .root :global(.future-text-block) {
+ font-size: 12px;
+ line-height: 1.6;
+ }
+
+ .root :global(.future-subsection-title) {
+ font-size: 0.9rem;
+ margin-top: 14px;
+ }
+
+ .root :global(.future-front-meta) {
+ gap: 8px;
+ }
+
+ .root :global(.future-front-pill) {
+ width: 100%;
+ justify-content: center;
+ }
+}
diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx
index 9d02c0f7..9ff5579b 100644
--- a/frontend/components/dashboard/FutureForecastModal.tsx
+++ b/frontend/components/dashboard/FutureForecastModal.tsx
@@ -1,32 +1,11 @@
"use client";
-import {
- Cloud,
- CloudFog,
- CloudLightning,
- CloudRain,
- CloudSnow,
- CloudSun,
- Search,
- Sun,
- Wind,
-} from "lucide-react";
-
-import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
import { CSSProperties, useEffect, useMemo, useState } from "react";
-import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
-import {
- ModelForecast,
- ProbabilityDistribution,
-} from "@/components/dashboard/PanelSections";
-import { getTemperatureChartData } from "@/lib/chart-utils";
-import {
- getFutureModalView,
-} from "@/lib/dashboard-utils";
+import { getFutureModalView } from "@/lib/dashboard-utils";
import { getModelView, getProbabilityView } from "@/lib/model-utils";
import { getTodayPaceView } from "@/lib/pace-utils";
import { dashboardClient } from "@/lib/dashboard-client";
@@ -35,677 +14,38 @@ import {
normalizeObservationSourceCode,
normalizeObservationSourceLabel,
} from "@/lib/source-labels";
-import type {
- IntradayMeteorologySignal,
- MarketScan,
-} from "@/lib/dashboard-types";
-
-const TODAY_MARKET_SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000;
-
-function normalizeMarketValue(value?: number | null) {
- if (value == null) return null;
- const numeric = Number(value);
- if (!Number.isFinite(numeric)) return null;
- if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
- return Math.max(0, Math.min(1, numeric));
-}
-
-function formatMinuteAxisLabel(value: number) {
- if (!Number.isFinite(value)) return "";
- const total = Math.max(0, Math.round(value));
- const hour = Math.floor(total / 60) % 24;
- const minute = total % 60;
- return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
-}
-
-function WeatherIcon({ emoji, size = 32 }: { emoji: string; size?: number }) {
- if (emoji === "☀️") return ;
- if (emoji === "⛅" || emoji === "🌤️")
- return ;
- if (emoji === "☁️") return ;
- if (emoji === "🌧️" || emoji === "🌦️")
- return ;
- if (emoji === "⛈️") return ;
- if (emoji === "❄️" || emoji === "🌨️")
- return ;
- if (emoji === "🌫️") return ;
- if (emoji === "💨") return ;
- return ;
-}
-
-function formatMarketPercent(value?: number | null) {
- const normalized = normalizeMarketValue(value);
- if (normalized == null) return "--";
- return `${(normalized * 100).toFixed(1)}%`;
-}
-
-function formatBucketLabel(
- bucket?: {
- label?: string | null;
- bucket?: string | null;
- range?: string | null;
- value?: number | null;
- temp?: number | null;
- } | null,
-) {
- if (!bucket) return "--";
- const direct =
- String(bucket.label || "").trim() ||
- String(bucket.bucket || "").trim() ||
- String(bucket.range || "").trim();
- if (direct) {
- let str = direct.toUpperCase().replace(/\s+/g, "");
- str = str.replace(/°?C($|\+|-)/g, "℃$1");
- if (!str.includes("℃") && /[0-9]/.test(str)) {
- str += "℃";
- }
- return str;
- }
-
- const temp = Number(bucket.value ?? bucket.temp);
- if (Number.isFinite(temp)) {
- return `${Math.round(temp)}℃`;
- }
- return "--";
-}
-
-function parseBucketBoundaries(
- bucket?: {
- label?: string | null;
- bucket?: string | null;
- range?: string | null;
- value?: number | null;
- temp?: number | null;
- } | null,
-) {
- if (!bucket) return null;
- const raw =
- String(bucket.label || "").trim() ||
- String(bucket.bucket || "").trim() ||
- String(bucket.range || "").trim();
- if (!raw) return null;
- const numbers = Array.from(raw.matchAll(/-?\d+(?:\.\d+)?/g)).map((match) =>
- Number(match[0]),
- );
- if (!numbers.length) return null;
- if (raw.includes("+")) {
- return {
- lower: numbers[0] ?? null,
- upper: null as number | null,
- boundaryLabel: `${numbers[0]}°C`,
- };
- }
- if (numbers.length >= 2) {
- return {
- lower: numbers[0],
- upper: numbers[1],
- boundaryLabel: null as string | null,
- };
- }
- return {
- lower: numbers[0],
- upper: null as number | null,
- boundaryLabel: `${numbers[0]}°C`,
- };
-}
-
-function clamp(value: number, min: number, max: number) {
- return Math.min(Math.max(value, min), max);
-}
-
-function parseClockMinutes(value?: string | null) {
- const text = String(value || "").trim();
- const match = text.match(/^(\d{1,2}):(\d{2})$/);
- if (!match) return null;
- const hours = Number(match[1]);
- const minutes = Number(match[2]);
- if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
- return hours * 60 + minutes;
-}
-
-function parseLeadingNumber(value?: string | number | null) {
- if (typeof value === "number" && Number.isFinite(value)) return value;
- const text = String(value || "").trim();
- const match = text.match(/[-+]?\d+(?:\.\d+)?/);
- if (!match) return null;
- const numeric = Number(match[0]);
- return Number.isFinite(numeric) ? numeric : null;
-}
-
-function parsePercentFromText(value?: string | number | null) {
- if (typeof value === "number" && Number.isFinite(value)) {
- return clamp(value, 0, 100);
- }
- const text = String(value || "").trim();
- const percentMatch = text.match(/([-+]?\d+(?:\.\d+)?)\s*%/);
- if (percentMatch) {
- const numeric = Number(percentMatch[1]);
- return Number.isFinite(numeric) ? clamp(numeric, 0, 100) : null;
- }
- return parseLeadingNumber(text);
-}
-
-function formatConfidenceLabel(value?: string | null, locale = "zh-CN") {
- const normalized = String(value || "")
- .trim()
- .toLowerCase();
- if (normalized === "high") return locale === "en-US" ? "High" : "高";
- if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
- if (normalized === "low") return locale === "en-US" ? "Low" : "低";
- return locale === "en-US" ? "Pending" : "待确认";
-}
-
-function formatSignalDirection(value?: string | null, locale = "zh-CN") {
- const normalized = String(value || "")
- .trim()
- .toLowerCase();
- if (normalized === "support")
- return locale === "en-US" ? "Support" : "支持升温";
- if (normalized === "suppress")
- return locale === "en-US" ? "Suppress" : "压制峰值";
- return locale === "en-US" ? "Neutral" : "中性";
-}
-
-function formatSignalStrength(value?: string | null, locale = "zh-CN") {
- const normalized = String(value || "")
- .trim()
- .toLowerCase();
- if (normalized === "strong") return locale === "en-US" ? "Strong" : "强";
- if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
- return locale === "en-US" ? "Weak" : "弱";
-}
-
-function signalTone(signal?: IntradayMeteorologySignal | null) {
- const direction = String(signal?.direction || "")
- .trim()
- .toLowerCase();
- if (direction === "support") return "cyan";
- if (direction === "suppress") return "amber";
- return "blue";
-}
-
-function localizedText(
- locale: string,
- primary?: string | null,
- english?: string | null,
-) {
- const en = String(english || "").trim();
- const value = String(primary || "").trim();
- if (locale === "en-US" && en) return en;
- return value || en;
-}
-
-function localizedList(
- locale: string,
- primary?: string[] | null,
- english?: string[] | null,
-) {
- const en = Array.isArray(english)
- ? english.filter((item) => String(item || "").trim())
- : [];
- const value = Array.isArray(primary)
- ? primary.filter((item) => String(item || "").trim())
- : [];
- if (locale === "en-US" && en.length) return en;
- return value.length ? value : en;
-}
-
-function getTrendMetricVisual(metric: {
- label?: string;
- value?: string;
- tone?: string;
-}) {
- const label = String(metric.label || "").toLowerCase();
- const value = String(metric.value || "");
- const numeric = parseLeadingNumber(value);
-
- if (label.includes("降水") || label.includes("precip")) {
- const precipPercent = parsePercentFromText(value);
- if (precipPercent == null) return null;
- return {
- mode: "fill" as const,
- percent: precipPercent,
- tone: "cold" as const,
- };
- }
-
- if (numeric == null) return null;
-
- if (label.includes("温度") || label.includes("temp")) {
- return {
- mode: "center" as const,
- percent: clamp(50 + (numeric / 4) * 50, 0, 100),
- tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
- };
- }
-
- if (label.includes("露点") || label.includes("dew")) {
- return {
- mode: "center" as const,
- percent: clamp(50 + (numeric / 3) * 50, 0, 100),
- tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
- };
- }
-
- if (label.includes("气压") || label.includes("pressure")) {
- return {
- mode: "center" as const,
- percent: clamp(50 + (numeric / 4) * 50, 0, 100),
- tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
- };
- }
-
- if (label.includes("云量") || label.includes("cloud")) {
- return {
- mode: "center" as const,
- percent: clamp(50 + (numeric / 40) * 50, 0, 100),
- tone: numeric >= 0 ? ("cold" as const) : ("warm" as const),
- };
- }
-
- return null;
-}
-
-function DailyTemperatureChart({
- dateStr,
- forceToday = false,
-}: {
- dateStr: string;
- forceToday?: boolean;
-}) {
- const store = useDashboardStore();
- const { locale, t } = useI18n();
- const detail = store.selectedDetail;
- const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
- const isToday =
- forceToday || (detail ? dateStr === detail.local_date : false);
- const todayChartData = useMemo(
- () => (detail && isToday ? getTemperatureChartData(detail, locale) : null),
- [detail, isToday, locale],
- );
-
- const canvasRef = useChart(() => {
- if (!detail || !view) {
- return {
- data: { datasets: [], labels: [] },
- type: "line",
- } satisfies ChartConfiguration<"line">;
- }
-
- if (isToday && todayChartData) {
- const datasets: NonNullable<
- ChartConfiguration<"line">["data"]
- >["datasets"] = [];
-
- if (todayChartData.datasets.hasMgmHourly) {
- datasets.push({
- backgroundColor: "rgba(234, 179, 8, 0.05)",
- borderColor: "rgba(234, 179, 8, 0.8)",
- borderWidth: 2,
- data: todayChartData.datasets.mgmHourlySeries,
- fill: false,
- label: locale === "en-US" ? "MGM Forecast" : "MGM 预测",
- parsing: false,
- pointHoverRadius: 6,
- pointRadius: 3,
- spanGaps: true,
- tension: 0.3,
- });
- } else {
- datasets.push({
- backgroundColor: "rgba(77, 163, 255, 0.06)",
- borderColor: "rgba(77, 163, 255, 0.66)",
- borderWidth: 1.5,
- data: todayChartData.datasets.debPastSeries,
- fill: true,
- label: locale === "en-US" ? "DEB Forecast" : "DEB 预测",
- parsing: false,
- pointHoverRadius: 3,
- pointRadius: 0,
- tension: 0.3,
- });
- datasets.push({
- borderColor: "rgba(77, 163, 255, 0.36)",
- borderDash: [5, 3],
- borderWidth: 1.5,
- data: todayChartData.datasets.debFutureSeries,
- fill: false,
- label: locale === "en-US" ? "DEB Forecast" : "DEB 预测",
- parsing: false,
- pointRadius: 0,
- tension: 0.3,
- });
- }
-
- datasets.push({
- backgroundColor: "#4DA3FF",
- borderColor: "#4DA3FF",
- borderWidth: 0,
- data: todayChartData.datasets.metarSeries,
- fill: false,
- label:
- todayChartData.observationLabel ||
- (locale === "en-US" ? "Observation" : "观测实况"),
- order: 0,
- parsing: false,
- pointHoverRadius: 7,
- pointRadius: 5,
- showLine: false,
- });
-
- if (todayChartData.datasets.airportMetarSeries?.length > 0) {
- datasets.push({
- backgroundColor: "#60a5fa",
- borderColor: "#60a5fa",
- borderWidth: 1,
- data: todayChartData.datasets.airportMetarSeries,
- fill: false,
- label: locale === "en-US" ? "Airport METAR" : "机场 METAR",
- order: 0,
- parsing: false,
- pointHoverRadius: 6,
- pointRadius: 4,
- showLine: false,
- });
- }
-
- if (todayChartData.datasets.mgmSeries?.length > 0) {
- datasets.push({
- backgroundColor: "#facc15",
- borderColor: "#facc15",
- borderWidth: 0,
- data: todayChartData.datasets.mgmSeries,
- fill: false,
- label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
- order: -1,
- parsing: false,
- pointHoverRadius: 9,
- pointRadius: 7,
- showLine: false,
- });
- }
-
- if (
- !todayChartData.datasets.hasMgmHourly &&
- Math.abs(todayChartData.datasets.offset) > 0.3
- ) {
- datasets.push({
- borderColor: "rgba(77, 163, 255, 0.22)",
- borderDash: [2, 4],
- borderWidth: 1,
- data: todayChartData.datasets.tempsSeries,
- fill: false,
- label: locale === "en-US" ? "OM Raw" : "OM 原始",
- parsing: false,
- pointRadius: 0,
- tension: 0.3,
- });
- }
- if ((todayChartData.tafMarkers || []).length > 0) {
- datasets.push({
- backgroundColor: "#f59e0b",
- borderColor: "#f59e0b",
- borderWidth: 0,
- data: todayChartData.datasets.tafCurrentMarkerSeries,
- fill: false,
- label: locale === "en-US" ? "Current TAF" : "当前 TAF",
- order: -3,
- parsing: false,
- pointHoverRadius: 8,
- pointRadius: 6,
- showLine: false,
- });
- datasets.push({
- backgroundColor: "rgba(250, 204, 21, 0.72)",
- borderColor: "rgba(250, 204, 21, 0.72)",
- borderWidth: 0,
- data: todayChartData.datasets.tafPeakWindowMarkerSeries,
- fill: false,
- label: locale === "en-US" ? "Peak-window TAF" : "峰值窗口 TAF",
- order: -2,
- parsing: false,
- pointHoverRadius: 7,
- pointRadius: 4,
- showLine: false,
- });
- datasets.push({
- backgroundColor: "#f59e0b",
- borderColor: "#f59e0b",
- borderWidth: 0,
- data: todayChartData.datasets.tafMarkerSeries,
- fill: false,
- label: locale === "en-US" ? "TAF Timing" : "TAF 时段",
- order: -4,
- parsing: false,
- pointHoverRadius: 0,
- pointRadius: 0,
- showLine: false,
- });
- }
-
- return {
- data: {
- datasets,
- labels: [],
- },
- options: {
- interaction: { intersect: false, mode: "nearest" },
- maintainAspectRatio: false,
- plugins: {
- legend: {
- labels: {
- color: "#9FB2C7",
- filter: (legendItem, chartData) => {
- const text = String(legendItem.text || "");
- if (!text) return false;
- if (text === "TAF Timing" || text === "TAF 时段")
- return false;
- if (!text.includes("DEB")) return true;
-
- const firstDebIndex = (chartData.datasets || []).findIndex(
- (dataset) => String(dataset.label || "").includes("DEB"),
- );
- return legendItem.datasetIndex === firstDebIndex;
- },
- font: { family: "Inter", size: 11 },
- },
- },
- tooltip: {
- backgroundColor: "rgba(15, 23, 42, 0.96)",
- borderColor: "rgba(77, 163, 255, 0.24)",
- borderWidth: 1,
- callbacks: {
- title: (items) => {
- const rawX = items?.[0]?.parsed?.x;
- return rawX != null
- ? formatMinuteAxisLabel(Number(rawX))
- : "";
- },
- label: (ctx) => {
- const label = String(ctx.dataset.label || "");
- const raw = ctx.raw as
- | {
- marker?: {
- summary?: string;
- markerType?: string;
- displayType?: string;
- isCurrent?: boolean;
- isPeakWindow?: boolean;
- };
- }
- | undefined;
- if (
- label === "TAF Timing" ||
- label === "TAF 时段" ||
- label === "Current TAF" ||
- label === "当前 TAF" ||
- label === "Peak-window TAF" ||
- label === "峰值窗口 TAF"
- ) {
- const marker = raw?.marker;
- if (!marker) return label;
- const markerType = String(marker.markerType || "");
- const displayType = String(
- marker.displayType || marker.markerType || "",
- );
- const summary = String(marker.summary || "");
- const prefix =
- marker.isCurrent && marker.isPeakWindow
- ? locale === "en-US"
- ? "Current / peak-window TAF"
- : "当前 / 峰值窗口 TAF"
- : marker.isCurrent
- ? locale === "en-US"
- ? "Current TAF"
- : "当前 TAF"
- : marker.isPeakWindow
- ? locale === "en-US"
- ? "Peak-window TAF"
- : "峰值窗口 TAF"
- : label;
- return `${prefix}: ${
- markerType
- ? summary.replace(markerType, displayType)
- : summary
- }`;
- }
- const value = ctx.parsed.y;
- if (value == null) return label;
- return `${label}: ${value.toFixed(1)}${detail.temp_symbol || "°C"}`;
- },
- },
- },
- },
- responsive: true,
- scales: {
- x: {
- max: todayChartData.xMax,
- min: todayChartData.xMin,
- grid: { color: "rgba(255,255,255,0.04)" },
- type: "linear",
- ticks: {
- callback: (value) => {
- const num = Number(value);
- if (!Number.isFinite(num)) return "";
- const minutes = Math.round(num);
- if (
- minutes !== todayChartData.xMin &&
- minutes !== todayChartData.xMax &&
- minutes % 120 !== 0
- ) {
- return "";
- }
- return formatMinuteAxisLabel(minutes);
- },
- color: "#6B7A90",
- font: { family: "Inter", size: 10 },
- maxRotation: 0,
- },
- },
- y: {
- grid: { color: "rgba(255,255,255,0.04)" },
- max: todayChartData.max,
- min: todayChartData.min,
- ticks: {
- callback: (value) =>
- `${Number(value).toFixed(todayChartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
- color: "#6B7A90",
- font: { family: "Inter", size: 10 },
- stepSize: todayChartData.yTickStep,
- },
- },
- },
- },
- type: "line",
- } satisfies ChartConfiguration<"line">;
- }
-
- const labels = view.slice.map((point) => point.label);
- const unit = detail.temp_symbol || "°C";
-
- return {
- data: {
- datasets: [
- {
- backgroundColor: "rgba(77, 163, 255, 0.08)",
- borderColor: "#4DA3FF",
- data: view.slice.map((point) => point.temp),
- fill: false,
- label:
- locale === "en-US" ? "Open-Meteo Temperature" : "Open-Meteo 温度",
- pointRadius: 2,
- tension: 0.28,
- },
- {
- backgroundColor: "transparent",
- borderColor: "#93C5FD",
- borderDash: [5, 4],
- data: view.slice.map((point) => point.dewPoint),
- fill: false,
- label: locale === "en-US" ? "Dew Point" : "露点",
- pointRadius: 0,
- tension: 0.24,
- },
- ],
- labels,
- },
- options: {
- interaction: { intersect: false, mode: "index" },
- maintainAspectRatio: false,
- plugins: {
- legend: {
- labels: {
- color: "#9FB2C7",
- font: { family: "Inter", size: 11 },
- },
- },
- tooltip: {
- backgroundColor: "rgba(15, 23, 42, 0.96)",
- borderColor: "rgba(77, 163, 255, 0.24)",
- borderWidth: 1,
- callbacks: {
- label: (ctx) =>
- `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${unit}`,
- },
- },
- },
- responsive: true,
- scales: {
- x: {
- grid: { color: "rgba(255,255,255,0.04)" },
- ticks: {
- color: "#6B7A90",
- font: { family: "Inter", size: 10 },
- maxRotation: 0,
- },
- },
- y: {
- grid: { color: "rgba(255,255,255,0.04)" },
- ticks: {
- callback: (value) => `${value}${unit}`,
- color: "#6B7A90",
- font: { family: "Inter", size: 10 },
- },
- },
- },
- },
- type: "line",
- } satisfies ChartConfiguration<"line">;
- }, [detail, isToday, locale, todayChartData, view]);
-
- return (
- <>
-
-
-
- {isToday && (
-
- {todayChartData?.legendText || t("future.chartLegendEmpty")}
-
- )}
- >
- );
-}
+import type { MarketScan } from "@/lib/dashboard-types";
+import { FutureForecastForwardView } from "./FutureForecastForwardView";
+import {
+ FutureModelForecastPanel,
+ FutureProbabilityPanel,
+ FutureTemperaturePathChart,
+} from "./FutureForecastModalPanels";
+import { FutureForecastTodayDecisionBrief } from "./FutureForecastTodayDecisionBrief";
+import { FutureForecastTodayEvidenceGrid } from "./FutureForecastTodayEvidenceGrid";
+import {
+ FutureRefreshLock,
+ FutureSyncStatusStrip,
+ type FutureSyncStatusItem,
+} from "./FutureForecastModalStatus";
+import {
+ FutureAnchorStatusCard,
+ FuturePaceCard,
+ FuturePaceLoadingCard,
+ type FuturePaceSignalItem,
+} from "./FutureForecastTodayCards";
+import {
+ TODAY_MARKET_SCAN_AUTO_REFRESH_MS,
+ clamp,
+ formatBucketLabel,
+ formatMarketPercent,
+ getTrendMetricVisual,
+ localizedList,
+ localizedText,
+ parseBucketBoundaries,
+ parseClockMinutes,
+ parseLeadingNumber,
+} from "./FutureForecastModal.utils";
export function FutureForecastModal() {
const store = useDashboardStore();
@@ -1111,6 +451,19 @@ function FutureForecastModalContent({
locale,
showDeferredTodaySections,
]);
+ const paceSignalItems = useMemo(
+ () =>
+ [boundaryRiskView, peakWindowStateView, networkLeadView]
+ .filter((item) => item != null)
+ .map((item) => ({
+ label: item.label,
+ note: item.note,
+ status: item.status,
+ tone: item.tone,
+ value: item.value,
+ })) as FuturePaceSignalItem[],
+ [boundaryRiskView, networkLeadView, peakWindowStateView],
+ );
const isNoaaSettlement =
detail.current?.settlement_source === "noaa" ||
detail.current?.settlement_source_label === "NOAA";
@@ -1533,7 +886,7 @@ function FutureForecastModalContent({
? `Probability buckets are derived from the ${probabilityEngineLabel} layer.`
: `概率桶当前由 ${probabilityEngineLabel} 层推导。`,
},
- ] as const;
+ ] satisfies FutureSyncStatusItem[];
return (
- {isTodayBlockingRefresh && (
-
-
-
-
- {locale === "en-US"
- ? "Refreshing latest intraday data"
- : "正在刷新最新日内数据"}
-
-
- {locale === "en-US"
- ? "Old cached readings are temporarily locked to prevent misjudgement. The analysis will unlock after the latest anchor observation, model layer, and probability layer are ready."
- : "旧缓存读数已临时锁定,避免误判。最新锚点观测、模型层和概率层就绪后会自动解锁。"}
-
-
-
- )}
+ {isTodayBlockingRefresh &&
}
{isToday && (
-
-
-
-
- {locale === "en-US"
- ? "Professional meteorology read"
- : "专业气象判断"}
-
-
- {anchorSourceLabel}
-
-
-
{meteorologyHeadline}
-
{anchorRuleText}
-
-
- {locale === "en-US" ? "Confidence" : "置信度"} ·{" "}
- {formatConfidenceLabel(
- intradayMeteorology.confidence,
- locale,
- )}
-
-
- {locale === "en-US" ? "Path state" : "路径状态"} ·{" "}
- {pathStatus}
-
-
- {nextObservationLabel} · {nextObservationTime}
-
-
-
-
-
- {locale === "en-US" ? "Anchor" : "锚点"}
- {settlementStationName}
- {anchorSourceLabel}
-
-
-
- {locale === "en-US" ? "Base" : "基准"}
- {baseCaseBucket || "--"}
-
-
- {locale === "en-US" ? "Upside" : "上修"}
-
- {intradayMeteorology.upside_bucket || "--"}
-
-
-
- {locale === "en-US" ? "Downside" : "下修"}
-
- {intradayMeteorology.downside_bucket || "--"}
-
-
-
-
- {locale === "en-US" ? "Gap to base" : "距基准还差"}
-
- {gapToBaseText}
-
-
-
-
+
)}
-
- {syncStatusItems.map((item) => (
-
-
-
- {item.label}
- {item.note}
-
-
- ))}
-
+
{isNoaaSettlement && (
{locale === "en-US"
@@ -1773,253 +1034,29 @@ function FutureForecastModalContent({
{isToday ? (
-
-
-
- {locale === "en-US" ? "Anchor Status" : "锚点状态"}
-
-
- {locale === "en-US"
- ? "Settlement anchor and current clock"
- : "结算锚点与当前时钟"}
-
-
-
-
- {currentTempText}
-
-
-
-
-
-
- {weatherSummary.weatherText}
-
-
-
- @{detail.current?.obs_time || "--"}
-
- {daylightProgress ? (
-
-
-
- {locale === "en-US" ? "Solar Window" : "昼夜进度"}
-
-
- {locale === "en-US"
- ? `${daylightProgress.phase} ${Math.round(daylightProgress.percent)}%`
- : `${daylightProgress.phase} ${Math.round(daylightProgress.percent)}%`}
-
-
-
-
- {detail.forecast?.sunrise || "--"}
- {detail.forecast?.sunset || "--"}
-
-
- ) : null}
-
-
-
- {locale === "en-US" ? "High so far" : "日内已见高点"}
-
-
- {topObservedTemp ?? "--"}
- {detail.temp_symbol}
-
-
-
-
- {locale === "en-US" ? "Anchor clock" : "锚点时钟"}
-
- {detail.current?.obs_time || "--"}
-
-
-
- {locale === "en-US" ? "Gap to base" : "距基准档"}
-
-
- {gapToBaseBucket != null
- ? `${gapToBaseBucket.toFixed(1)}${detail.temp_symbol}`
- : "--"}
-
-
-
-
- {locale === "en-US" ? "Path state" : "路径状态"}
-
- {pathStatus}
-
-
-
- {locale === "en-US" ? "Sunrise" : "日出时间"}
-
- {detail.forecast?.sunrise || "--"}
-
-
-
- {locale === "en-US" ? "Sunset" : "日落时间"}
-
- {detail.forecast?.sunset || "--"}
-
-
-
+
{showDeferredTodaySections && paceView ? (
-
-
-
- {locale === "en-US" ? "Current Pace" : "当前节奏"}
-
-
- {locale === "en-US"
- ? "Expected now vs airport anchor"
- : "此刻应到 vs 机场锚点"}
-
-
-
-
- {paceView.kicker}
-
-
- {paceView.badge}
-
-
-
- {paceView.deltaText}
-
-
- {paceView.summary}
-
-
-
-
-
-
-
-
- {locale === "en-US" ? "Expected now" : "预期此刻"}
-
-
- {paceView.expectedNow.toFixed(1)}
- {detail.temp_symbol}
-
-
-
- {paceView.observedLabel}
-
- {paceView.observedNow.toFixed(1)}
- {detail.temp_symbol}
-
-
-
- {paceView.paceAdjustedLabel}
-
- {paceView.paceAdjustedHigh != null
- ? `${paceView.paceAdjustedHigh.toFixed(1)}${detail.temp_symbol}`
- : "--"}
-
-
-
-
- {locale === "en-US" ? "Peak window" : "峰值窗口"}
-
- {paceView.peakWindowText}
-
-
-
- {[
- boundaryRiskView,
- peakWindowStateView,
- networkLeadView,
- ]
- .filter((item) => item != null)
- .map((item) => (
-
-
- {item.label}
-
- {item.status}
-
-
-
{item.value}
-
- {item.note}
-
-
- ))}
-
-
+
) : isToday ? (
-
-
-
- {locale === "en-US" ? "Current Pace" : "当前节奏"}
-
-
- {locale === "en-US"
- ? "Backfilling intraday pace context"
- : "正在补齐日内节奏上下文"}
-
-
-
- {locale === "en-US"
- ? "Expected-now pace, boundary risk, and airport-vs-network cues are loading in the background."
- : "预期此刻节奏、边界风险和机场对比站网信号正在后台补齐。"}
-
-
+
) : null}
@@ -2035,10 +1072,7 @@ function FutureForecastModalContent({
: "今日气温路径(锚点观测 + 模型)"}
-
+
{locale === "en-US" ? "Base" : "基准"} ·{" "}
@@ -2055,122 +1089,14 @@ function FutureForecastModalContent({
-
-
-
-
- {locale === "en-US" ? "Evidence chain" : "气象证据链"}
-
-
- {locale === "en-US"
- ? "Signal Contributions"
- : "信号贡献"}
-
-
-
- {meteorologySignals.length > 0 ? (
- meteorologySignals.map((signal, index) => (
-
-
-
- {localizedText(
- locale,
- signal.label,
- signal.label_en,
- ) || "--"}
-
-
- {formatSignalDirection(
- signal.direction,
- locale,
- )}{" "}
- ·{" "}
- {formatSignalStrength(
- signal.strength,
- locale,
- )}
-
-
-
- {localizedText(
- locale,
- signal.summary,
- signal.summary_en,
- ) || "--"}
-
-
- ))
- ) : (
-
- {locale === "en-US"
- ? "Meteorology signals are still loading."
- : "气象信号仍在加载。"}
-
- )}
-
-
-
-
-
-
- {locale === "en-US" ? "Failure modes" : "失效条件"}
-
-
- {locale === "en-US"
- ? "What Downgrades the Read"
- : "什么会让判断降级"}
-
-
-
- {(invalidationRules.length > 0
- ? invalidationRules
- : [
- locale === "en-US"
- ? "If observations stop tracking the expected curve, wait for the next refresh."
- : "若实测不再贴近预期曲线,等待下一次刷新确认。",
- ]
- ).map((rule, index) => (
- {rule}
- ))}
-
-
-
-
-
-
- {locale === "en-US" ? "Confirmation" : "确认条件"}
-
-
- {locale === "en-US"
- ? "What Confirms the Path"
- : "什么会确认主路径"}
-
-
-
- {(confirmationRules.length > 0
- ? confirmationRules
- : [
- locale === "en-US"
- ? airportMetarAnchor
- ? "Keep watching the next anchor METAR report."
- : "Keep watching the next official anchor observation."
- : airportMetarAnchor
- ? "继续观察下一次锚点 METAR 报文。"
- : "继续观察下一次官方锚点观测。",
- ]
- ).map((rule, index) => (
- {rule}
- ))}
-
- {modelSummary}
-
-
+
@@ -2187,7 +1113,7 @@ function FutureForecastModalContent({
{probabilitySummary}
-
) : (
- <>
-
-
- {t("future.targetForecast")}
-
- {view.forecastEntry?.max_temp ?? "--"}
- {detail.temp_symbol}
-
-
-
- {t("future.deb")}
-
- {view.deb ?? "--"}
- {detail.temp_symbol}
-
-
-
- {t("future.mu")}
-
- {view.mu != null
- ? `${view.mu.toFixed(1)}${detail.temp_symbol}`
- : "--"}
-
-
-
- {t("future.score")}
-
- {view.front.score > 0 ? "+" : ""}
- {view.front.score}
-
-
-
-
-
- {t("future.targetTempTrend")}
-
-
-
-
-
- {t("future.probability")}
-
-
-
- {t("future.models")}
-
-
-
- >
+
)}
diff --git a/frontend/components/dashboard/FutureForecastModal.utils.ts b/frontend/components/dashboard/FutureForecastModal.utils.ts
new file mode 100644
index 00000000..e9ff97fc
--- /dev/null
+++ b/frontend/components/dashboard/FutureForecastModal.utils.ts
@@ -0,0 +1,252 @@
+import type { IntradayMeteorologySignal } from "@/lib/dashboard-types";
+
+export const TODAY_MARKET_SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000;
+
+export function normalizeMarketValue(value?: number | null) {
+ if (value == null) return null;
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) return null;
+ if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
+ return Math.max(0, Math.min(1, numeric));
+}
+
+export function formatMinuteAxisLabel(value: number) {
+ if (!Number.isFinite(value)) return "";
+ const total = Math.max(0, Math.round(value));
+ const hour = Math.floor(total / 60) % 24;
+ const minute = total % 60;
+ return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
+}
+
+export function formatMarketPercent(value?: number | null) {
+ const normalized = normalizeMarketValue(value);
+ if (normalized == null) return "--";
+ return `${(normalized * 100).toFixed(1)}%`;
+}
+
+export function formatBucketLabel(
+ bucket?: {
+ label?: string | null;
+ bucket?: string | null;
+ range?: string | null;
+ value?: number | null;
+ temp?: number | null;
+ } | null,
+) {
+ if (!bucket) return "--";
+ const direct =
+ String(bucket.label || "").trim() ||
+ String(bucket.bucket || "").trim() ||
+ String(bucket.range || "").trim();
+ if (direct) {
+ let str = direct.toUpperCase().replace(/\s+/g, "");
+ str = str.replace(/°?C($|\+|-)/g, "℃$1");
+ if (!str.includes("℃") && /[0-9]/.test(str)) {
+ str += "℃";
+ }
+ return str;
+ }
+
+ const temp = Number(bucket.value ?? bucket.temp);
+ if (Number.isFinite(temp)) {
+ return `${Math.round(temp)}℃`;
+ }
+ return "--";
+}
+
+export function parseBucketBoundaries(
+ bucket?: {
+ label?: string | null;
+ bucket?: string | null;
+ range?: string | null;
+ value?: number | null;
+ temp?: number | null;
+ } | null,
+) {
+ if (!bucket) return null;
+ const raw =
+ String(bucket.label || "").trim() ||
+ String(bucket.bucket || "").trim() ||
+ String(bucket.range || "").trim();
+ if (!raw) return null;
+ const numbers = Array.from(raw.matchAll(/-?\d+(?:\.\d+)?/g)).map((match) =>
+ Number(match[0]),
+ );
+ if (!numbers.length) return null;
+ if (raw.includes("+")) {
+ return {
+ lower: numbers[0] ?? null,
+ upper: null as number | null,
+ boundaryLabel: `${numbers[0]}°C`,
+ };
+ }
+ if (numbers.length >= 2) {
+ return {
+ lower: numbers[0],
+ upper: numbers[1],
+ boundaryLabel: null as string | null,
+ };
+ }
+ return {
+ lower: numbers[0],
+ upper: null as number | null,
+ boundaryLabel: `${numbers[0]}°C`,
+ };
+}
+
+export function clamp(value: number, min: number, max: number) {
+ return Math.min(Math.max(value, min), max);
+}
+
+export function parseClockMinutes(value?: string | null) {
+ const text = String(value || "").trim();
+ const match = text.match(/^(\d{1,2}):(\d{2})$/);
+ if (!match) return null;
+ const hours = Number(match[1]);
+ const minutes = Number(match[2]);
+ if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
+ return hours * 60 + minutes;
+}
+
+export function parseLeadingNumber(value?: string | number | null) {
+ if (typeof value === "number" && Number.isFinite(value)) return value;
+ const text = String(value || "").trim();
+ const match = text.match(/[-+]?\d+(?:\.\d+)?/);
+ if (!match) return null;
+ const numeric = Number(match[0]);
+ return Number.isFinite(numeric) ? numeric : null;
+}
+
+export function parsePercentFromText(value?: string | number | null) {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return clamp(value, 0, 100);
+ }
+ const text = String(value || "").trim();
+ const percentMatch = text.match(/([-+]?\d+(?:\.\d+)?)\s*%/);
+ if (percentMatch) {
+ const numeric = Number(percentMatch[1]);
+ return Number.isFinite(numeric) ? clamp(numeric, 0, 100) : null;
+ }
+ return parseLeadingNumber(text);
+}
+
+export function formatConfidenceLabel(value?: string | null, locale = "zh-CN") {
+ const normalized = String(value || "")
+ .trim()
+ .toLowerCase();
+ if (normalized === "high") return locale === "en-US" ? "High" : "高";
+ if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
+ if (normalized === "low") return locale === "en-US" ? "Low" : "低";
+ return locale === "en-US" ? "Pending" : "待确认";
+}
+
+export function formatSignalDirection(value?: string | null, locale = "zh-CN") {
+ const normalized = String(value || "")
+ .trim()
+ .toLowerCase();
+ if (normalized === "support")
+ return locale === "en-US" ? "Support" : "支持升温";
+ if (normalized === "suppress")
+ return locale === "en-US" ? "Suppress" : "压制峰值";
+ return locale === "en-US" ? "Neutral" : "中性";
+}
+
+export function formatSignalStrength(value?: string | null, locale = "zh-CN") {
+ const normalized = String(value || "")
+ .trim()
+ .toLowerCase();
+ if (normalized === "strong") return locale === "en-US" ? "Strong" : "强";
+ if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
+ return locale === "en-US" ? "Weak" : "弱";
+}
+
+export function signalTone(signal?: IntradayMeteorologySignal | null) {
+ const direction = String(signal?.direction || "")
+ .trim()
+ .toLowerCase();
+ if (direction === "support") return "cyan";
+ if (direction === "suppress") return "amber";
+ return "blue";
+}
+
+export function localizedText(
+ locale: string,
+ primary?: string | null,
+ english?: string | null,
+) {
+ const en = String(english || "").trim();
+ const value = String(primary || "").trim();
+ if (locale === "en-US" && en) return en;
+ return value || en;
+}
+
+export function localizedList(
+ locale: string,
+ primary?: string[] | null,
+ english?: string[] | null,
+) {
+ const en = Array.isArray(english)
+ ? english.filter((item) => String(item || "").trim())
+ : [];
+ const value = Array.isArray(primary)
+ ? primary.filter((item) => String(item || "").trim())
+ : [];
+ if (locale === "en-US" && en.length) return en;
+ return value.length ? value : en;
+}
+
+export function getTrendMetricVisual(metric: {
+ label?: string;
+ value?: string;
+ tone?: string;
+}) {
+ const label = String(metric.label || "").toLowerCase();
+ const value = String(metric.value || "");
+ const numeric = parseLeadingNumber(value);
+
+ if (label.includes("降水") || label.includes("precip")) {
+ const precipPercent = parsePercentFromText(value);
+ if (precipPercent == null) return null;
+ return {
+ mode: "fill" as const,
+ percent: precipPercent,
+ tone: "cold" as const,
+ };
+ }
+
+ if (numeric == null) return null;
+
+ if (label.includes("温度") || label.includes("temp")) {
+ return {
+ mode: "center" as const,
+ percent: clamp(50 + (numeric / 4) * 50, 0, 100),
+ tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
+ };
+ }
+
+ if (label.includes("露点") || label.includes("dew")) {
+ return {
+ mode: "center" as const,
+ percent: clamp(50 + (numeric / 3) * 50, 0, 100),
+ tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
+ };
+ }
+
+ if (label.includes("气压") || label.includes("pressure")) {
+ return {
+ mode: "center" as const,
+ percent: clamp(50 + (numeric / 4) * 50, 0, 100),
+ tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
+ };
+ }
+
+ if (label.includes("云量") || label.includes("cloud")) {
+ return {
+ mode: "center" as const,
+ percent: clamp(50 + (numeric / 40) * 50, 0, 100),
+ tone: numeric >= 0 ? ("cold" as const) : ("warm" as const),
+ };
+ }
+
+ return null;
+}
diff --git a/frontend/components/dashboard/FutureForecastModalChart.tsx b/frontend/components/dashboard/FutureForecastModalChart.tsx
new file mode 100644
index 00000000..dd59d65b
--- /dev/null
+++ b/frontend/components/dashboard/FutureForecastModalChart.tsx
@@ -0,0 +1,411 @@
+"use client";
+
+import type { ChartConfiguration } from "chart.js";
+import { useMemo } from "react";
+import { useChart } from "@/hooks/useChart";
+import { useDashboardStore } from "@/hooks/useDashboardStore";
+import { useI18n } from "@/hooks/useI18n";
+import { getTemperatureChartData } from "@/lib/chart-utils";
+import { getFutureModalView } from "@/lib/dashboard-utils";
+import { formatMinuteAxisLabel } from "./FutureForecastModal.utils";
+
+export function DailyTemperatureChart({
+ dateStr,
+ forceToday = false,
+}: {
+ dateStr: string;
+ forceToday?: boolean;
+}) {
+ const store = useDashboardStore();
+ const { locale, t } = useI18n();
+ const detail = store.selectedDetail;
+ const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
+ const isToday =
+ forceToday || (detail ? dateStr === detail.local_date : false);
+ const todayChartData = useMemo(
+ () => (detail && isToday ? getTemperatureChartData(detail, locale) : null),
+ [detail, isToday, locale],
+ );
+
+ const canvasRef = useChart(() => {
+ if (!detail || !view) {
+ return {
+ data: { datasets: [], labels: [] },
+ type: "line",
+ } satisfies ChartConfiguration<"line">;
+ }
+
+ if (isToday && todayChartData) {
+ const datasets: NonNullable<
+ ChartConfiguration<"line">["data"]
+ >["datasets"] = [];
+
+ if (todayChartData.datasets.hasMgmHourly) {
+ datasets.push({
+ backgroundColor: "rgba(234, 179, 8, 0.05)",
+ borderColor: "rgba(234, 179, 8, 0.8)",
+ borderWidth: 2,
+ data: todayChartData.datasets.mgmHourlySeries,
+ fill: false,
+ label: locale === "en-US" ? "MGM Forecast" : "MGM 预测",
+ parsing: false,
+ pointHoverRadius: 6,
+ pointRadius: 3,
+ spanGaps: true,
+ tension: 0.3,
+ });
+ } else {
+ datasets.push({
+ backgroundColor: "rgba(77, 163, 255, 0.06)",
+ borderColor: "rgba(77, 163, 255, 0.66)",
+ borderWidth: 1.5,
+ data: todayChartData.datasets.debPastSeries,
+ fill: true,
+ label: locale === "en-US" ? "DEB Forecast" : "DEB 预测",
+ parsing: false,
+ pointHoverRadius: 3,
+ pointRadius: 0,
+ tension: 0.3,
+ });
+ datasets.push({
+ borderColor: "rgba(77, 163, 255, 0.36)",
+ borderDash: [5, 3],
+ borderWidth: 1.5,
+ data: todayChartData.datasets.debFutureSeries,
+ fill: false,
+ label: locale === "en-US" ? "DEB Forecast" : "DEB 预测",
+ parsing: false,
+ pointRadius: 0,
+ tension: 0.3,
+ });
+ }
+
+ datasets.push({
+ backgroundColor: "#4DA3FF",
+ borderColor: "#4DA3FF",
+ borderWidth: 0,
+ data: todayChartData.datasets.metarSeries,
+ fill: false,
+ label:
+ todayChartData.observationLabel ||
+ (locale === "en-US" ? "Observation" : "观测实况"),
+ order: 0,
+ parsing: false,
+ pointHoverRadius: 7,
+ pointRadius: 5,
+ showLine: false,
+ });
+
+ if (todayChartData.datasets.airportMetarSeries?.length > 0) {
+ datasets.push({
+ backgroundColor: "#60a5fa",
+ borderColor: "#60a5fa",
+ borderWidth: 1,
+ data: todayChartData.datasets.airportMetarSeries,
+ fill: false,
+ label: locale === "en-US" ? "Airport METAR" : "机场 METAR",
+ order: 0,
+ parsing: false,
+ pointHoverRadius: 6,
+ pointRadius: 4,
+ showLine: false,
+ });
+ }
+
+ if (todayChartData.datasets.mgmSeries?.length > 0) {
+ datasets.push({
+ backgroundColor: "#facc15",
+ borderColor: "#facc15",
+ borderWidth: 0,
+ data: todayChartData.datasets.mgmSeries,
+ fill: false,
+ label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
+ order: -1,
+ parsing: false,
+ pointHoverRadius: 9,
+ pointRadius: 7,
+ showLine: false,
+ });
+ }
+
+ if (
+ !todayChartData.datasets.hasMgmHourly &&
+ Math.abs(todayChartData.datasets.offset) > 0.3
+ ) {
+ datasets.push({
+ borderColor: "rgba(77, 163, 255, 0.22)",
+ borderDash: [2, 4],
+ borderWidth: 1,
+ data: todayChartData.datasets.tempsSeries,
+ fill: false,
+ label: locale === "en-US" ? "OM Raw" : "OM 原始",
+ parsing: false,
+ pointRadius: 0,
+ tension: 0.3,
+ });
+ }
+ if ((todayChartData.tafMarkers || []).length > 0) {
+ datasets.push({
+ backgroundColor: "#f59e0b",
+ borderColor: "#f59e0b",
+ borderWidth: 0,
+ data: todayChartData.datasets.tafCurrentMarkerSeries,
+ fill: false,
+ label: locale === "en-US" ? "Current TAF" : "当前 TAF",
+ order: -3,
+ parsing: false,
+ pointHoverRadius: 8,
+ pointRadius: 6,
+ showLine: false,
+ });
+ datasets.push({
+ backgroundColor: "rgba(250, 204, 21, 0.72)",
+ borderColor: "rgba(250, 204, 21, 0.72)",
+ borderWidth: 0,
+ data: todayChartData.datasets.tafPeakWindowMarkerSeries,
+ fill: false,
+ label: locale === "en-US" ? "Peak-window TAF" : "峰值窗口 TAF",
+ order: -2,
+ parsing: false,
+ pointHoverRadius: 7,
+ pointRadius: 4,
+ showLine: false,
+ });
+ datasets.push({
+ backgroundColor: "#f59e0b",
+ borderColor: "#f59e0b",
+ borderWidth: 0,
+ data: todayChartData.datasets.tafMarkerSeries,
+ fill: false,
+ label: locale === "en-US" ? "TAF Timing" : "TAF 时段",
+ order: -4,
+ parsing: false,
+ pointHoverRadius: 0,
+ pointRadius: 0,
+ showLine: false,
+ });
+ }
+
+ return {
+ data: {
+ datasets,
+ labels: [],
+ },
+ options: {
+ interaction: { intersect: false, mode: "nearest" },
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ labels: {
+ color: "#9FB2C7",
+ filter: (legendItem, chartData) => {
+ const text = String(legendItem.text || "");
+ if (!text) return false;
+ if (text === "TAF Timing" || text === "TAF 时段")
+ return false;
+ if (!text.includes("DEB")) return true;
+
+ const firstDebIndex = (chartData.datasets || []).findIndex(
+ (dataset) => String(dataset.label || "").includes("DEB"),
+ );
+ return legendItem.datasetIndex === firstDebIndex;
+ },
+ font: { family: "Inter", size: 11 },
+ },
+ },
+ tooltip: {
+ backgroundColor: "rgba(15, 23, 42, 0.96)",
+ borderColor: "rgba(77, 163, 255, 0.24)",
+ borderWidth: 1,
+ callbacks: {
+ title: (items) => {
+ const rawX = items?.[0]?.parsed?.x;
+ return rawX != null
+ ? formatMinuteAxisLabel(Number(rawX))
+ : "";
+ },
+ label: (ctx) => {
+ const label = String(ctx.dataset.label || "");
+ const raw = ctx.raw as
+ | {
+ marker?: {
+ summary?: string;
+ markerType?: string;
+ displayType?: string;
+ isCurrent?: boolean;
+ isPeakWindow?: boolean;
+ };
+ }
+ | undefined;
+ if (
+ label === "TAF Timing" ||
+ label === "TAF 时段" ||
+ label === "Current TAF" ||
+ label === "当前 TAF" ||
+ label === "Peak-window TAF" ||
+ label === "峰值窗口 TAF"
+ ) {
+ const marker = raw?.marker;
+ if (!marker) return label;
+ const markerType = String(marker.markerType || "");
+ const displayType = String(
+ marker.displayType || marker.markerType || "",
+ );
+ const summary = String(marker.summary || "");
+ const prefix =
+ marker.isCurrent && marker.isPeakWindow
+ ? locale === "en-US"
+ ? "Current / peak-window TAF"
+ : "当前 / 峰值窗口 TAF"
+ : marker.isCurrent
+ ? locale === "en-US"
+ ? "Current TAF"
+ : "当前 TAF"
+ : marker.isPeakWindow
+ ? locale === "en-US"
+ ? "Peak-window TAF"
+ : "峰值窗口 TAF"
+ : label;
+ return `${prefix}: ${
+ markerType
+ ? summary.replace(markerType, displayType)
+ : summary
+ }`;
+ }
+ const value = ctx.parsed.y;
+ if (value == null) return label;
+ return `${label}: ${value.toFixed(1)}${detail.temp_symbol || "°C"}`;
+ },
+ },
+ },
+ },
+ responsive: true,
+ scales: {
+ x: {
+ max: todayChartData.xMax,
+ min: todayChartData.xMin,
+ grid: { color: "rgba(255,255,255,0.04)" },
+ type: "linear",
+ ticks: {
+ callback: (value) => {
+ const num = Number(value);
+ if (!Number.isFinite(num)) return "";
+ const minutes = Math.round(num);
+ if (
+ minutes !== todayChartData.xMin &&
+ minutes !== todayChartData.xMax &&
+ minutes % 120 !== 0
+ ) {
+ return "";
+ }
+ return formatMinuteAxisLabel(minutes);
+ },
+ color: "#6B7A90",
+ font: { family: "Inter", size: 10 },
+ maxRotation: 0,
+ },
+ },
+ y: {
+ grid: { color: "rgba(255,255,255,0.04)" },
+ max: todayChartData.max,
+ min: todayChartData.min,
+ ticks: {
+ callback: (value) =>
+ `${Number(value).toFixed(todayChartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
+ color: "#6B7A90",
+ font: { family: "Inter", size: 10 },
+ stepSize: todayChartData.yTickStep,
+ },
+ },
+ },
+ },
+ type: "line",
+ } satisfies ChartConfiguration<"line">;
+ }
+
+ const labels = view.slice.map((point) => point.label);
+ const unit = detail.temp_symbol || "°C";
+
+ return {
+ data: {
+ datasets: [
+ {
+ backgroundColor: "rgba(77, 163, 255, 0.08)",
+ borderColor: "#4DA3FF",
+ data: view.slice.map((point) => point.temp),
+ fill: false,
+ label:
+ locale === "en-US" ? "Open-Meteo Temperature" : "Open-Meteo 温度",
+ pointRadius: 2,
+ tension: 0.28,
+ },
+ {
+ backgroundColor: "transparent",
+ borderColor: "#93C5FD",
+ borderDash: [5, 4],
+ data: view.slice.map((point) => point.dewPoint),
+ fill: false,
+ label: locale === "en-US" ? "Dew Point" : "露点",
+ pointRadius: 0,
+ tension: 0.24,
+ },
+ ],
+ labels,
+ },
+ options: {
+ interaction: { intersect: false, mode: "index" },
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ labels: {
+ color: "#9FB2C7",
+ font: { family: "Inter", size: 11 },
+ },
+ },
+ tooltip: {
+ backgroundColor: "rgba(15, 23, 42, 0.96)",
+ borderColor: "rgba(77, 163, 255, 0.24)",
+ borderWidth: 1,
+ callbacks: {
+ label: (ctx) =>
+ `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${unit}`,
+ },
+ },
+ },
+ responsive: true,
+ scales: {
+ x: {
+ grid: { color: "rgba(255,255,255,0.04)" },
+ ticks: {
+ color: "#6B7A90",
+ font: { family: "Inter", size: 10 },
+ maxRotation: 0,
+ },
+ },
+ y: {
+ grid: { color: "rgba(255,255,255,0.04)" },
+ ticks: {
+ callback: (value) => `${value}${unit}`,
+ color: "#6B7A90",
+ font: { family: "Inter", size: 10 },
+ },
+ },
+ },
+ },
+ type: "line",
+ } satisfies ChartConfiguration<"line">;
+ }, [detail, isToday, locale, todayChartData, view]);
+
+ return (
+ <>
+
+
+
+ {isToday && (
+
+ {todayChartData?.legendText || t("future.chartLegendEmpty")}
+
+ )}
+ >
+ );
+}
diff --git a/frontend/components/dashboard/FutureForecastModalPanels.tsx b/frontend/components/dashboard/FutureForecastModalPanels.tsx
new file mode 100644
index 00000000..72fb2d3a
--- /dev/null
+++ b/frontend/components/dashboard/FutureForecastModalPanels.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import type { useDashboardStore } from "@/hooks/useDashboardStore";
+import type { MarketScan } from "@/lib/dashboard-types";
+
+const DailyTemperatureChart = dynamic(
+ () =>
+ import("./FutureForecastModalChart").then(
+ (module) => module.DailyTemperatureChart,
+ ),
+ {
+ loading: () =>
,
+ ssr: false,
+ },
+);
+
+const ProbabilityDistribution = dynamic(
+ () =>
+ import("@/components/dashboard/PanelSections").then(
+ (module) => module.ProbabilityDistribution,
+ ),
+ {
+ loading: () =>
,
+ ssr: false,
+ },
+);
+
+const ModelForecast = dynamic(
+ () =>
+ import("@/components/dashboard/PanelSections").then(
+ (module) => module.ModelForecast,
+ ),
+ {
+ loading: () =>
,
+ ssr: false,
+ },
+);
+
+type DashboardDetail = NonNullable<
+ ReturnType["selectedDetail"]
+>;
+
+export function FutureTemperaturePathChart({
+ dateStr,
+ forceToday,
+}: {
+ dateStr: string;
+ forceToday: boolean;
+}) {
+ return ;
+}
+
+export function FutureProbabilityPanel({
+ detail,
+ targetDate,
+ marketScan,
+ hideTitle = true,
+}: {
+ detail: DashboardDetail;
+ targetDate: string;
+ marketScan?: MarketScan | null;
+ hideTitle?: boolean;
+}) {
+ return (
+
+ );
+}
+
+export function FutureModelForecastPanel({
+ detail,
+ targetDate,
+ hideTitle = true,
+}: {
+ detail: DashboardDetail;
+ targetDate: string;
+ hideTitle?: boolean;
+}) {
+ return (
+
+ );
+}
diff --git a/frontend/components/dashboard/FutureForecastModalStatus.tsx b/frontend/components/dashboard/FutureForecastModalStatus.tsx
new file mode 100644
index 00000000..85ff77a3
--- /dev/null
+++ b/frontend/components/dashboard/FutureForecastModalStatus.tsx
@@ -0,0 +1,63 @@
+import clsx from "clsx";
+
+export type FutureSyncStatusItem = {
+ key: string;
+ state: "ready" | "syncing";
+ label: string;
+ note: string;
+};
+
+export function FutureRefreshLock({ locale }: { locale: string }) {
+ return (
+
+
+
+
+ {locale === "en-US"
+ ? "Refreshing latest intraday data"
+ : "正在刷新最新日内数据"}
+
+
+ {locale === "en-US"
+ ? "Old cached readings are temporarily locked to prevent misjudgement. The analysis will unlock after the latest anchor observation, model layer, and probability layer are ready."
+ : "旧缓存读数已临时锁定,避免误判。最新锚点观测、模型层和概率层就绪后会自动解锁。"}
+
+
+
+ );
+}
+
+export function FutureSyncStatusStrip({
+ items,
+ compact = false,
+}: {
+ items: readonly FutureSyncStatusItem[];
+ compact?: boolean;
+}) {
+ return (
+
+ {items.map((item) => (
+
+
+
+ {item.label}
+ {item.note}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/components/dashboard/FutureForecastModalWeatherIcon.tsx b/frontend/components/dashboard/FutureForecastModalWeatherIcon.tsx
new file mode 100644
index 00000000..ae80b950
--- /dev/null
+++ b/frontend/components/dashboard/FutureForecastModalWeatherIcon.tsx
@@ -0,0 +1,26 @@
+import {
+ Cloud,
+ CloudFog,
+ CloudLightning,
+ CloudRain,
+ CloudSnow,
+ CloudSun,
+ Search,
+ Sun,
+ Wind,
+} from "lucide-react";
+
+export function WeatherIcon({ emoji, size = 32 }: { emoji: string; size?: number }) {
+ if (emoji === "☀️") return ;
+ if (emoji === "⛅" || emoji === "🌤️")
+ return ;
+ if (emoji === "☁️") return ;
+ if (emoji === "🌧️" || emoji === "🌦️")
+ return ;
+ if (emoji === "⛈️") return ;
+ if (emoji === "❄️" || emoji === "🌨️")
+ return ;
+ if (emoji === "🌫️") return ;
+ if (emoji === "💨") return ;
+ return ;
+}
diff --git a/frontend/components/dashboard/FutureForecastTodayCards.tsx b/frontend/components/dashboard/FutureForecastTodayCards.tsx
new file mode 100644
index 00000000..f1824f3a
--- /dev/null
+++ b/frontend/components/dashboard/FutureForecastTodayCards.tsx
@@ -0,0 +1,281 @@
+import clsx from "clsx";
+import type { CSSProperties } from "react";
+import type { getTodayPaceView } from "@/lib/pace-utils";
+import { WeatherIcon } from "./FutureForecastModalWeatherIcon";
+
+type Locale = string;
+type TodayPaceView = NonNullable>;
+
+type WeatherSummaryView = {
+ weatherIcon: string;
+ weatherText: string;
+};
+
+type DaylightProgressView = {
+ phase: string;
+ percent: number;
+};
+
+export type FuturePaceSignalItem = {
+ label: string;
+ tone: "cyan" | "blue" | "amber";
+ status: string;
+ value: string;
+ note: string;
+};
+
+export function FutureAnchorStatusCard({
+ locale,
+ currentTempText,
+ weatherSummary,
+ obsTime,
+ daylightProgress,
+ sunrise,
+ sunset,
+ topObservedTemp,
+ tempSymbol,
+ gapToBaseBucket,
+ pathStatus,
+}: {
+ locale: Locale;
+ currentTempText: string;
+ weatherSummary: WeatherSummaryView;
+ obsTime?: string | null;
+ daylightProgress: DaylightProgressView | null;
+ sunrise?: string | null;
+ sunset?: string | null;
+ topObservedTemp: number | string | null | undefined;
+ tempSymbol: string;
+ gapToBaseBucket: number | null;
+ pathStatus: string;
+}) {
+ const observedHigh = topObservedTemp ?? "--";
+
+ return (
+
+
+
+ {locale === "en-US" ? "Anchor Status" : "锚点状态"}
+
+
+ {locale === "en-US"
+ ? "Settlement anchor and current clock"
+ : "结算锚点与当前时钟"}
+
+
+
+
{currentTempText}
+
+
+
+
+
+ {weatherSummary.weatherText}
+
+
+ @{obsTime || "--"}
+ {daylightProgress ? (
+
+
+ {locale === "en-US" ? "Solar Window" : "昼夜进度"}
+
+ {daylightProgress.phase} {Math.round(daylightProgress.percent)}%
+
+
+
+
+ {sunrise || "--"}
+ {sunset || "--"}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function FuturePaceCard({
+ locale,
+ paceView,
+ tempSymbol,
+ signalItems,
+}: {
+ locale: Locale;
+ paceView: TodayPaceView;
+ tempSymbol: string;
+ signalItems: FuturePaceSignalItem[];
+}) {
+ return (
+
+
+
+ {locale === "en-US" ? "Current Pace" : "当前节奏"}
+
+
+ {locale === "en-US"
+ ? "Expected now vs airport anchor"
+ : "此刻应到 vs 机场锚点"}
+
+
+
+ {paceView.kicker}
+ {paceView.badge}
+
+
+ {paceView.deltaText}
+
+ {paceView.summary}
+
+
+
+
+
+
+
+
+
+
+ {signalItems.length ? (
+
+ {signalItems.map((item) => (
+
+
+ {item.label}
+ {item.status}
+
+
{item.value}
+
{item.note}
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+export function FuturePaceLoadingCard({ locale }: { locale: Locale }) {
+ return (
+
+
+
+ {locale === "en-US" ? "Current Pace" : "当前节奏"}
+
+
+ {locale === "en-US"
+ ? "Backfilling intraday pace context"
+ : "正在补齐日内节奏上下文"}
+
+
+
+ {locale === "en-US"
+ ? "Expected-now pace, boundary risk, and airport-vs-network cues are loading in the background."
+ : "预期此刻节奏、边界风险和机场对比站网信号正在后台补齐。"}
+
+
+ );
+}
+
+function FutureMiniMetric({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function FutureSignalTag({
+ tone,
+ children,
+}: {
+ tone: string;
+ children: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/components/dashboard/FutureForecastTodayDecisionBrief.tsx b/frontend/components/dashboard/FutureForecastTodayDecisionBrief.tsx
new file mode 100644
index 00000000..0143e5ad
--- /dev/null
+++ b/frontend/components/dashboard/FutureForecastTodayDecisionBrief.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import { formatConfidenceLabel } from "./FutureForecastModal.utils";
+
+export function FutureForecastTodayDecisionBrief({
+ anchorRuleText,
+ anchorSourceLabel,
+ baseCaseBucket,
+ confidence,
+ displayName,
+ downsideBucket,
+ gapToBaseText,
+ locale,
+ meteorologyHeadline,
+ nextObservationLabel,
+ nextObservationTime,
+ pathStatus,
+ settlementStationName,
+ upsideBucket,
+}: {
+ anchorRuleText: string;
+ anchorSourceLabel: string;
+ baseCaseBucket: string;
+ confidence: string | null | undefined;
+ displayName: string;
+ downsideBucket: string | null | undefined;
+ gapToBaseText: string;
+ locale: string;
+ meteorologyHeadline: string;
+ nextObservationLabel: string;
+ nextObservationTime: string;
+ pathStatus: string;
+ settlementStationName: string;
+ upsideBucket: string | null | undefined;
+}) {
+ return (
+
+
+
+
+ {locale === "en-US" ? "Professional meteorology read" : "专业气象判断"}
+
+
{anchorSourceLabel}
+
+
{meteorologyHeadline}
+
{anchorRuleText}
+
+
+ {locale === "en-US" ? "Confidence" : "置信度"} ·{" "}
+ {formatConfidenceLabel(confidence, locale)}
+
+
+ {locale === "en-US" ? "Path state" : "路径状态"} · {pathStatus}
+
+
+ {nextObservationLabel} · {nextObservationTime}
+
+
+
+
+
+ {locale === "en-US" ? "Anchor" : "锚点"}
+ {settlementStationName}
+ {anchorSourceLabel}
+
+
+
+ {locale === "en-US" ? "Base" : "基准"}
+ {baseCaseBucket || "--"}
+
+
+ {locale === "en-US" ? "Upside" : "上修"}
+ {upsideBucket || "--"}
+
+
+ {locale === "en-US" ? "Downside" : "下修"}
+ {downsideBucket || "--"}
+
+
+ {locale === "en-US" ? "Gap to base" : "距基准还差"}
+ {gapToBaseText}
+
+
+
+
+ );
+}
diff --git a/frontend/components/dashboard/FutureForecastTodayEvidenceGrid.tsx b/frontend/components/dashboard/FutureForecastTodayEvidenceGrid.tsx
new file mode 100644
index 00000000..96913865
--- /dev/null
+++ b/frontend/components/dashboard/FutureForecastTodayEvidenceGrid.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+import clsx from "clsx";
+import {
+ formatSignalDirection,
+ formatSignalStrength,
+ localizedText,
+ signalTone,
+} from "./FutureForecastModal.utils";
+
+type MeteorologySignal = {
+ direction?: string | null;
+ label?: string | null;
+ label_en?: string | null;
+ strength?: string | null;
+ summary?: string | null;
+ summary_en?: string | null;
+};
+
+export function FutureForecastTodayEvidenceGrid({
+ airportMetarAnchor,
+ confirmationRules,
+ invalidationRules,
+ locale,
+ meteorologySignals,
+ modelSummary,
+}: {
+ airportMetarAnchor: boolean;
+ confirmationRules: string[];
+ invalidationRules: string[];
+ locale: string;
+ meteorologySignals: MeteorologySignal[];
+ modelSummary: string;
+}) {
+ const fallbackInvalidationRule =
+ locale === "en-US"
+ ? "If observations stop tracking the expected curve, wait for the next refresh."
+ : "若实测不再贴近预期曲线,等待下一次刷新确认。";
+ const fallbackConfirmationRule =
+ locale === "en-US"
+ ? airportMetarAnchor
+ ? "Keep watching the next anchor METAR report."
+ : "Keep watching the next official anchor observation."
+ : airportMetarAnchor
+ ? "继续观察下一次锚点 METAR 报文。"
+ : "继续观察下一次官方锚点观测。";
+
+ return (
+
+
+
+
+ {locale === "en-US" ? "Evidence chain" : "气象证据链"}
+
+
{locale === "en-US" ? "Signal Contributions" : "信号贡献"}
+
+
+ {meteorologySignals.length > 0 ? (
+ meteorologySignals.map((signal, index) => (
+
+
+
+ {localizedText(locale, signal.label, signal.label_en) || "--"}
+
+
+ {formatSignalDirection(signal.direction, locale)} ·{" "}
+ {formatSignalStrength(signal.strength, locale)}
+
+
+
+ {localizedText(locale, signal.summary, signal.summary_en) ||
+ "--"}
+
+
+ ))
+ ) : (
+
+ {locale === "en-US"
+ ? "Meteorology signals are still loading."
+ : "气象信号仍在加载。"}
+
+ )}
+
+
+
+
+
+
+ {locale === "en-US" ? "Failure modes" : "失效条件"}
+
+
+ {locale === "en-US" ? "What Downgrades the Read" : "什么会让判断降级"}
+
+
+
+ {(invalidationRules.length > 0
+ ? invalidationRules
+ : [fallbackInvalidationRule]
+ ).map((rule, index) => (
+ {rule}
+ ))}
+
+
+
+
+
+
+ {locale === "en-US" ? "Confirmation" : "确认条件"}
+
+
+ {locale === "en-US" ? "What Confirms the Path" : "什么会确认主路径"}
+
+
+
+ {(confirmationRules.length > 0
+ ? confirmationRules
+ : [fallbackConfirmationRule]
+ ).map((rule, index) => (
+ {rule}
+ ))}
+
+ {modelSummary}
+
+
+ );
+}
diff --git a/frontend/components/dashboard/HistoryChart.tsx b/frontend/components/dashboard/HistoryChart.tsx
new file mode 100644
index 00000000..b5ff2e2b
--- /dev/null
+++ b/frontend/components/dashboard/HistoryChart.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import type { ChartConfiguration } from "chart.js";
+import { useMemo } from "react";
+import { useChart } from "@/hooks/useChart";
+import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
+import { useI18n } from "@/hooks/useI18n";
+import { getHistorySummary } from "@/lib/dashboard-utils";
+
+export function HistoryChart() {
+ const store = useDashboardStore();
+ const { locale } = useI18n();
+ const { data } = useHistoryData();
+ const isNoaaSettlement =
+ store.selectedDetail?.current?.settlement_source === "noaa" ||
+ store.selectedDetail?.current?.settlement_source_label === "NOAA";
+ const noaaStationCode = String(
+ store.selectedDetail?.current?.station_code ||
+ store.selectedDetail?.risk?.icao ||
+ "NOAA",
+ )
+ .trim()
+ .toUpperCase();
+ const summary = useMemo(
+ () => getHistorySummary(data, store.selectedDetail?.local_date),
+ [data, store.selectedDetail?.local_date],
+ );
+ const hasMgm =
+ store.selectedCity === "ankara" &&
+ summary.mgmSeriesComplete &&
+ summary.mgms.some((value) => value != null);
+ const hasBestBaseline =
+ Boolean(summary.bestModelName) &&
+ summary.bestModelName !== "MGM" &&
+ summary.bestModelSeries.some((value) => value != null);
+
+ const canvasRef = useChart(() => {
+ const datasets: NonNullable<
+ ChartConfiguration<"line">["data"]
+ >["datasets"] = [
+ {
+ backgroundColor: "rgba(248, 113, 113, 0.1)",
+ borderColor: "#f87171",
+ borderWidth: 2,
+ data: summary.actuals,
+ label: isNoaaSettlement
+ ? locale === "en-US"
+ ? `NOAA Settled High (${noaaStationCode})`
+ : `NOAA 结算最高温 (${noaaStationCode})`
+ : locale === "en-US"
+ ? "Observed High"
+ : "实测最高温",
+ pointBackgroundColor: "#f87171",
+ pointBorderColor: "#fff",
+ pointHoverRadius: 7,
+ pointRadius: 5,
+ tension: 0.2,
+ },
+ {
+ backgroundColor: "transparent",
+ borderColor: "#34d399",
+ borderDash: [5, 4],
+ borderWidth: 2,
+ data: summary.debs,
+ label: locale === "en-US" ? "DEB Fusion" : "DEB 融合",
+ pointHoverRadius: 6,
+ pointRadius: 4,
+ tension: 0.2,
+ },
+ ];
+
+ if (hasMgm) {
+ datasets.push({
+ backgroundColor: "transparent",
+ borderColor: "#fb923c",
+ borderWidth: 2,
+ data: summary.mgms,
+ label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报",
+ pointHoverRadius: 6,
+ pointRadius: 4,
+ tension: 0.2,
+ });
+ }
+
+ if (hasBestBaseline) {
+ datasets.push({
+ backgroundColor: "transparent",
+ borderColor: "#60a5fa",
+ borderDash: [4, 3],
+ borderWidth: 2,
+ data: summary.bestModelSeries,
+ label:
+ locale === "en-US"
+ ? `Best Baseline (${summary.bestModelName})`
+ : `最佳单模型 (${summary.bestModelName})`,
+ pointHoverRadius: 6,
+ pointRadius: 4,
+ tension: 0.2,
+ });
+ }
+
+ return {
+ data: {
+ datasets,
+ labels: summary.dates,
+ },
+ options: {
+ interaction: { intersect: false, mode: "index" },
+ maintainAspectRatio: false,
+ plugins: {
+ legend: {
+ labels: {
+ boxHeight: 12,
+ boxWidth: 34,
+ color: "#94a3b8",
+ font: { family: "Inter", size: 14 },
+ padding: 18,
+ },
+ },
+ tooltip: {
+ backgroundColor: "rgba(15, 23, 42, 0.9)",
+ borderColor: "rgba(255, 255, 255, 0.1)",
+ borderWidth: 1,
+ bodyFont: { family: "Inter", size: 13 },
+ titleFont: { family: "Inter", size: 13, weight: 600 },
+ callbacks: {
+ label: (ctx) =>
+ `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
+ },
+ },
+ },
+ responsive: true,
+ scales: {
+ x: {
+ grid: { color: "rgba(255,255,255,0.04)" },
+ ticks: {
+ color: "#64748b",
+ font: { family: "Inter", size: 12 },
+ padding: 8,
+ },
+ },
+ y: {
+ grid: { color: "rgba(255,255,255,0.04)" },
+ ticks: {
+ color: "#64748b",
+ font: { family: "Inter", size: 12 },
+ padding: 8,
+ },
+ },
+ },
+ },
+ type: "line",
+ } satisfies ChartConfiguration<"line">;
+ }, [hasBestBaseline, hasMgm, isNoaaSettlement, noaaStationCode, summary, locale]);
+
+ if (!summary.recentData.length) return null;
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/components/dashboard/HistoryModal.module.css b/frontend/components/dashboard/HistoryModal.module.css
new file mode 100644
index 00000000..ac6b8854
--- /dev/null
+++ b/frontend/components/dashboard/HistoryModal.module.css
@@ -0,0 +1,530 @@
+/* History modal styles moved out of Dashboard.module.css. */
+
+.root :global(.history-btn) {
+ background: rgba(34, 211, 238, 0.1);
+ color: var(--accent-cyan);
+ border: 1px solid rgba(34, 211, 238, 0.3);
+ padding: 4px 10px;
+ border-radius: 6px;
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ margin-left: 8px;
+}
+
+.root :global(.history-btn:hover) {
+ background: rgba(34, 211, 238, 0.2);
+ border-color: var(--accent-cyan);
+}
+
+.root :global(.history-btn:disabled) {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.root :global(.modal-content.history-modal) {
+ width: min(96vw, 1180px);
+ max-width: 1180px;
+ max-height: calc(100vh - 32px);
+}
+
+.root :global(.history-modal .modal-body) {
+ padding: 24px 28px 28px;
+}
+
+.root :global(.history-modal .history-stats) {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 16px;
+ margin-bottom: 24px;
+}
+
+.root :global(.history-modal .h-stat-card) {
+ min-width: 0;
+ padding: 16px 18px;
+ border-radius: 12px;
+ background: linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0.035) 0%,
+ rgba(255, 255, 255, 0.02) 100%
+ );
+}
+
+.root :global(.history-modal .h-stat-card .label) {
+ font-size: 12px;
+ margin-bottom: 8px;
+}
+
+.root :global(.history-modal .h-stat-card .val) {
+ font-size: 32px;
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+}
+
+.root :global(.history-modal .history-chart-wrapper) {
+ height: 420px;
+ border: 1px solid var(--border-subtle);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.02);
+ padding: 12px 14px 8px;
+}
+
+.root :global(.history-modal .history-chart-wrapper canvas) {
+ width: 100% !important;
+ height: 100% !important;
+}
+
+.root :global(.history-modal .history-chart-loading) {
+ background:
+ linear-gradient(
+ 90deg,
+ rgba(255, 255, 255, 0.025),
+ rgba(96, 165, 250, 0.08),
+ rgba(255, 255, 255, 0.025)
+ );
+ background-size: 220% 100%;
+ animation: history-chart-loading 1.4s ease-in-out infinite;
+}
+
+@keyframes history-chart-loading {
+ 0% {
+ background-position: 100% 0;
+ }
+
+ 100% {
+ background-position: -100% 0;
+ }
+}
+
+.root :global(.history-modal .history-modal-loading) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 420px;
+}
+
+.root :global(.history-modal .history-fetch-loading) {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 18px;
+ width: min(100%, 420px);
+ padding: 24px 26px;
+ border-radius: 24px;
+ border: 1px solid rgba(96, 165, 250, 0.16);
+ background: linear-gradient(
+ 180deg,
+ rgba(10, 18, 36, 0.72) 0%,
+ rgba(8, 14, 30, 0.5) 100%
+ );
+ box-shadow:
+ 0 18px 48px rgba(0, 0, 0, 0.28),
+ inset 0 1px 0 rgba(255, 255, 255, 0.03);
+}
+
+.root :global(.history-modal .history-fetch-scan) {
+ position: relative;
+ width: 84px;
+ height: 84px;
+ border-radius: 50%;
+ background: radial-gradient(
+ circle,
+ rgba(14, 165, 233, 0.08) 0%,
+ rgba(14, 165, 233, 0.02) 62%,
+ transparent 100%
+ );
+}
+
+.root :global(.history-modal .history-fetch-ring) {
+ position: absolute;
+ inset: 0;
+ border-radius: 50%;
+ border: 1px solid rgba(56, 189, 248, 0.16);
+}
+
+.root :global(.history-modal .history-fetch-ring-1) {
+ transform: scale(0.7);
+}
+
+.root :global(.history-modal .history-fetch-ring-2) {
+ transform: scale(0.92);
+}
+
+.root :global(.history-modal .history-fetch-sweep) {
+ position: absolute;
+ inset: 5px;
+ border-radius: 50%;
+ background: conic-gradient(
+ from 120deg,
+ rgba(34, 211, 238, 0) 0deg,
+ rgba(34, 211, 238, 0.04) 220deg,
+ rgba(56, 189, 248, 0.78) 300deg,
+ rgba(253, 224, 71, 0.32) 332deg,
+ rgba(34, 211, 238, 0) 360deg
+ );
+ animation: history-fetch-spin 2.2s linear infinite;
+}
+
+.root :global(.history-modal .history-fetch-core) {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ transform: translate(-50%, -50%);
+ background: rgba(103, 232, 249, 0.95);
+ box-shadow: 0 0 18px rgba(34, 211, 238, 0.42);
+}
+
+.root :global(.history-modal .history-fetch-bars) {
+ display: flex;
+ align-items: end;
+ gap: 7px;
+ height: 22px;
+}
+
+.root :global(.history-modal .history-fetch-bar) {
+ width: 8px;
+ border-radius: 999px;
+ background: linear-gradient(
+ 180deg,
+ rgba(250, 204, 21, 0.18) 0%,
+ rgba(251, 146, 60, 0.9) 100%
+ );
+ animation: history-fetch-pulse 1.6s ease-in-out infinite;
+}
+
+.root :global(.history-modal .history-fetch-bar-1) {
+ height: 10px;
+}
+
+.root :global(.history-modal .history-fetch-bar-2) {
+ height: 18px;
+ animation-delay: 0.18s;
+}
+
+.root :global(.history-modal .history-fetch-bar-3) {
+ height: 14px;
+ animation-delay: 0.34s;
+}
+
+.root :global(.history-modal .history-fetch-bar-4) {
+ height: 20px;
+ animation-delay: 0.5s;
+}
+
+.root :global(.history-modal .history-fetch-lines) {
+ position: relative;
+ width: 180px;
+ height: 22px;
+ overflow: hidden;
+}
+
+.root :global(.history-modal .history-fetch-line) {
+ position: absolute;
+ left: -26%;
+ height: 1px;
+ border-radius: 999px;
+ background: linear-gradient(
+ 90deg,
+ rgba(34, 211, 238, 0) 0%,
+ rgba(125, 211, 252, 0.72) 48%,
+ rgba(34, 211, 238, 0) 100%
+ );
+ animation: history-fetch-flow 2.4s linear infinite;
+}
+
+.root :global(.history-modal .history-fetch-line-1) {
+ top: 4px;
+ width: 122px;
+}
+
+.root :global(.history-modal .history-fetch-line-2) {
+ top: 11px;
+ width: 168px;
+ animation-delay: -0.8s;
+}
+
+.root :global(.history-modal .history-fetch-line-3) {
+ top: 18px;
+ width: 138px;
+ animation-delay: -1.4s;
+}
+
+.root :global(.history-modal .history-fetch-copy) {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ text-align: center;
+}
+
+.root :global(.history-modal .history-fetch-copy strong) {
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: rgba(103, 232, 249, 0.94);
+}
+
+.root :global(.history-modal .history-fetch-copy span) {
+ max-width: 300px;
+ font-size: 12px;
+ line-height: 1.5;
+ color: rgba(203, 213, 225, 0.82);
+}
+
+@keyframes history-fetch-spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes history-fetch-pulse {
+ 0%,
+ 100% {
+ transform: scaleY(0.86);
+ opacity: 0.8;
+ }
+
+ 50% {
+ transform: scaleY(1.08);
+ opacity: 1;
+ }
+}
+
+@keyframes history-fetch-flow {
+ from {
+ transform: translateX(0);
+ }
+
+ to {
+ transform: translateX(150%);
+ }
+}
+
+.root :global(.history-modal .history-peak-reference) {
+ margin-top: 18px;
+ padding: 14px 16px;
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ border-radius: 16px;
+ background: rgba(15, 23, 42, 0.42);
+}
+
+.root :global(.history-modal .history-model-reference) {
+ margin-top: 18px;
+ padding: 14px 16px;
+ border: 1px solid rgba(34, 211, 238, 0.18);
+ border-radius: 16px;
+ background: rgba(8, 47, 73, 0.2);
+}
+
+.root :global(.history-modal .history-peak-reference-title) {
+ color: var(--text-primary);
+ font-size: 14px;
+ font-weight: 700;
+ margin-bottom: 10px;
+}
+
+.root :global(.history-modal .history-model-reference-scroll),
+.root :global(.history-modal .history-peak-reference-scroll) {
+ display: grid;
+ gap: 8px;
+ max-height: 360px;
+ overflow-y: auto;
+ padding-right: 6px;
+ scrollbar-width: thin;
+}
+
+.root :global(.history-modal .history-model-reference-scroll) {
+ scrollbar-color: rgba(34, 211, 238, 0.5) rgba(15, 23, 42, 0.32);
+}
+
+.root :global(.history-modal .history-peak-reference-scroll) {
+ scrollbar-color: rgba(96, 165, 250, 0.5) rgba(15, 23, 42, 0.32);
+}
+
+.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar) {
+ width: 8px;
+}
+
+.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar-track) {
+ background: rgba(15, 23, 42, 0.32);
+ border-radius: 999px;
+}
+
+.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar-thumb) {
+ background: linear-gradient(
+ 180deg,
+ rgba(56, 189, 248, 0.72) 0%,
+ rgba(96, 165, 250, 0.52) 100%
+ );
+ border-radius: 999px;
+}
+
+.root :global(.history-modal .history-peak-reference-row),
+.root :global(.history-modal .history-model-reference-row) {
+ display: grid;
+ grid-template-columns: minmax(72px, 88px) 1fr;
+ gap: 10px;
+ padding: 10px 12px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.root :global(.history-modal .history-peak-reference-date) {
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.root :global(.history-modal .history-model-reference-body) {
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+}
+
+.root :global(.history-modal .history-model-reference-summary) {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px 14px;
+ color: var(--text-secondary);
+ font-size: 12px;
+}
+
+.root :global(.history-modal .history-model-reference-summary strong) {
+ color: var(--text-primary);
+}
+
+.root :global(.history-modal .history-model-reference-models) {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+}
+
+.root :global(.history-modal .history-model-reference-model) {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+ padding: 8px 10px;
+ border: 1px solid rgba(148, 163, 184, 0.12);
+ border-radius: 8px;
+ background: rgba(15, 23, 42, 0.38);
+ color: var(--text-secondary);
+ font-size: 11px;
+}
+
+.root :global(.history-modal .history-model-name) {
+ overflow: hidden;
+ color: var(--accent-cyan);
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.root :global(.history-modal .history-model-error) {
+ color: var(--text-muted);
+}
+
+.root :global(.history-modal .history-peak-reference-meta) {
+ color: var(--text-secondary);
+ font-size: 12px;
+ line-height: 1.7;
+}
+
+@media (max-width: 980px) {
+ .root :global(.history-modal .modal-body) {
+ padding: 18px;
+ }
+
+ .root :global(.history-modal .history-stats) {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+ }
+
+ .root :global(.history-modal .history-chart-wrapper) {
+ height: 360px;
+ }
+
+ .root :global(.history-modal .history-modal-loading) {
+ min-height: 340px;
+ }
+
+ .root :global(.history-modal .history-fetch-loading) {
+ width: min(100%, 360px);
+ }
+
+ .root :global(.history-modal .history-peak-reference-scroll),
+ .root :global(.history-modal .history-model-reference-scroll) {
+ max-height: 320px;
+ }
+
+ .root :global(.history-modal .history-model-reference-models) {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 640px) {
+ .root :global(.modal-content.history-modal) {
+ width: min(100vw - 12px, 100%);
+ max-width: none;
+ }
+
+ .root :global(.history-modal .history-stats) {
+ grid-template-columns: 1fr;
+ }
+
+ .root :global(.history-modal .h-stat-card .val) {
+ font-size: 28px;
+ }
+
+ .root :global(.history-modal .history-chart-wrapper) {
+ height: 300px;
+ padding: 8px 10px 6px;
+ }
+
+ .root :global(.history-modal .history-modal-loading) {
+ min-height: 280px;
+ }
+
+ .root :global(.history-modal .history-fetch-loading) {
+ width: 100%;
+ padding: 18px 16px;
+ }
+
+ .root :global(.history-modal .history-fetch-lines) {
+ width: 150px;
+ }
+
+ .root :global(.history-modal .history-fetch-scan) {
+ width: 72px;
+ height: 72px;
+ }
+
+ .root :global(.history-modal .history-peak-reference),
+ .root :global(.history-modal .history-model-reference) {
+ padding: 12px;
+ }
+
+ .root :global(.history-modal .history-peak-reference-scroll),
+ .root :global(.history-modal .history-model-reference-scroll) {
+ max-height: 260px;
+ padding-right: 2px;
+ }
+
+ .root :global(.history-modal .history-peak-reference-row),
+ .root :global(.history-modal .history-model-reference-row) {
+ grid-template-columns: 1fr;
+ gap: 6px;
+ }
+
+ .root :global(.history-modal .history-model-reference-models) {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/frontend/components/dashboard/HistoryModal.tsx b/frontend/components/dashboard/HistoryModal.tsx
index 80428423..5257c9e0 100644
--- a/frontend/components/dashboard/HistoryModal.tsx
+++ b/frontend/components/dashboard/HistoryModal.tsx
@@ -1,167 +1,22 @@
"use client";
-import type { ChartConfiguration } from "chart.js";
+import dynamic from "next/dynamic";
import { useMemo } from "react";
-import { useChart } from "@/hooks/useChart";
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import { getHistorySummary } from "@/lib/dashboard-utils";
-function HistoryChart() {
- const store = useDashboardStore();
- const { locale } = useI18n();
- const { data } = useHistoryData();
- const isNoaaSettlement =
- store.selectedDetail?.current?.settlement_source === "noaa" ||
- store.selectedDetail?.current?.settlement_source_label === "NOAA";
- const noaaStationCode = String(
- store.selectedDetail?.current?.station_code ||
- store.selectedDetail?.risk?.icao ||
- "NOAA",
- )
- .trim()
- .toUpperCase();
- const summary = useMemo(
- () => getHistorySummary(data, store.selectedDetail?.local_date),
- [data, store.selectedDetail?.local_date],
- );
- const hasMgm =
- store.selectedCity === "ankara" &&
- summary.mgmSeriesComplete &&
- summary.mgms.some((value) => value != null);
- const hasBestBaseline =
- Boolean(summary.bestModelName) &&
- summary.bestModelName !== "MGM" &&
- summary.bestModelSeries.some((value) => value != null);
-
- const canvasRef = useChart(() => {
- const datasets: NonNullable<
- ChartConfiguration<"line">["data"]
- >["datasets"] = [
- {
- backgroundColor: "rgba(248, 113, 113, 0.1)",
- borderColor: "#f87171",
- borderWidth: 2,
- data: summary.actuals,
- label: isNoaaSettlement
- ? locale === "en-US"
- ? `NOAA Settled High (${noaaStationCode})`
- : `NOAA 结算最高温 (${noaaStationCode})`
- : locale === "en-US"
- ? "Observed High"
- : "实测最高温",
- pointBackgroundColor: "#f87171",
- pointBorderColor: "#fff",
- pointHoverRadius: 7,
- pointRadius: 5,
- tension: 0.2,
- },
- {
- backgroundColor: "transparent",
- borderColor: "#34d399",
- borderDash: [5, 4],
- borderWidth: 2,
- data: summary.debs,
- label: locale === "en-US" ? "DEB Fusion" : "DEB 融合",
- pointHoverRadius: 6,
- pointRadius: 4,
- tension: 0.2,
- },
- ];
-
- if (hasMgm) {
- datasets.push({
- backgroundColor: "transparent",
- borderColor: "#fb923c",
- borderWidth: 2,
- data: summary.mgms,
- label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报",
- pointHoverRadius: 6,
- pointRadius: 4,
- tension: 0.2,
- });
- }
-
- if (hasBestBaseline) {
- datasets.push({
- backgroundColor: "transparent",
- borderColor: "#60a5fa",
- borderDash: [4, 3],
- borderWidth: 2,
- data: summary.bestModelSeries,
- label:
- locale === "en-US"
- ? `Best Baseline (${summary.bestModelName})`
- : `最佳单模型 (${summary.bestModelName})`,
- pointHoverRadius: 6,
- pointRadius: 4,
- tension: 0.2,
- });
- }
-
- return {
- data: {
- datasets,
- labels: summary.dates,
- },
- options: {
- interaction: { intersect: false, mode: "index" },
- maintainAspectRatio: false,
- plugins: {
- legend: {
- labels: {
- boxHeight: 12,
- boxWidth: 34,
- color: "#94a3b8",
- font: { family: "Inter", size: 14 },
- padding: 18,
- },
- },
- tooltip: {
- backgroundColor: "rgba(15, 23, 42, 0.9)",
- borderColor: "rgba(255, 255, 255, 0.1)",
- borderWidth: 1,
- bodyFont: { family: "Inter", size: 13 },
- titleFont: { family: "Inter", size: 13, weight: 600 },
- callbacks: {
- label: (ctx) =>
- `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
- },
- },
- },
- responsive: true,
- scales: {
- x: {
- grid: { color: "rgba(255,255,255,0.04)" },
- ticks: {
- color: "#64748b",
- font: { family: "Inter", size: 12 },
- padding: 8,
- },
- },
- y: {
- grid: { color: "rgba(255,255,255,0.04)" },
- ticks: {
- color: "#64748b",
- font: { family: "Inter", size: 12 },
- padding: 8,
- },
- },
- },
- },
- type: "line",
- } satisfies ChartConfiguration<"line">;
- }, [hasBestBaseline, hasMgm, isNoaaSettlement, noaaStationCode, summary, locale]);
-
- if (!summary.recentData.length) return null;
-
- return (
-
-
-
- );
-}
+const HistoryChart = dynamic(
+ () =>
+ import("@/components/dashboard/HistoryChart").then(
+ (module) => module.HistoryChart,
+ ),
+ {
+ loading: () =>
,
+ ssr: false,
+ },
+);
export function HistoryModal() {
const store = useDashboardStore();
diff --git a/frontend/components/dashboard/ModelForecast.tsx b/frontend/components/dashboard/ModelForecast.tsx
new file mode 100644
index 00000000..8d13a7f2
--- /dev/null
+++ b/frontend/components/dashboard/ModelForecast.tsx
@@ -0,0 +1,283 @@
+"use client";
+
+import clsx from "clsx";
+import { useI18n } from "@/hooks/useI18n";
+import { CityDetail } from "@/lib/dashboard-types";
+import { getModelView } from "@/lib/model-utils";
+
+function EmptyState({ text }: { text: string }) {
+ return (
+ {text}
+ );
+}
+
+type ModelMetadata = NonNullable<
+ NonNullable["open_meteo_multi_model"]
+>["model_metadata"];
+
+function getModelGroupMeta(
+ name: string,
+ metadata: ModelMetadata,
+ locale: string,
+) {
+ const meta = metadata?.[name] || {};
+ const tier = String(meta.tier || "").toLowerCase();
+ const upperName = String(name || "").toUpperCase();
+
+ if (tier.includes("aifs") || upperName.includes("AIFS")) {
+ return {
+ key: "aifs",
+ label: locale === "en-US" ? "AIFS model" : "AIFS 模型",
+ order: 1,
+ tone: "blue",
+ };
+ }
+ if (
+ tier.includes("europe") ||
+ upperName.includes("ICON-EU") ||
+ upperName.includes("ICON-D2")
+ ) {
+ return {
+ key: "europe",
+ label: locale === "en-US" ? "Europe high-resolution" : "欧洲高分辨率",
+ order: 2,
+ tone: "cyan",
+ };
+ }
+ if (
+ tier.includes("north_america") ||
+ upperName === "RDPS" ||
+ upperName === "HRDPS"
+ ) {
+ return {
+ key: "north-america",
+ label:
+ locale === "en-US" ? "North America high-resolution" : "北美高分辨率",
+ order: 3,
+ tone: "amber",
+ };
+ }
+ return {
+ key: "global",
+ label: locale === "en-US" ? "Global baseline" : "全球基准",
+ order: 0,
+ tone: "neutral",
+ };
+}
+
+function formatModelMetaLine(
+ name: string,
+ metadata: ModelMetadata,
+ locale: string,
+) {
+ const meta = metadata?.[name] || {};
+ const provider = String(meta.provider || "").trim();
+ const model = String(meta.model || "").trim();
+ const horizon = String(meta.horizon || "").trim();
+ const resolution = Number(meta.resolution_km);
+ const parts = [
+ provider,
+ model && model !== name ? model : "",
+ Number.isFinite(resolution)
+ ? `${resolution}${locale === "en-US" ? " km" : " 公里"}`
+ : "",
+ horizon,
+ ].filter(Boolean);
+ return parts.join(" · ");
+}
+
+
+export function ModelForecast({
+ detail,
+ hideTitle = false,
+ targetDate,
+}: {
+ detail: CityDetail;
+ hideTitle?: boolean;
+ targetDate?: string | null;
+}) {
+ const { locale, t } = useI18n();
+ const view = getModelView(detail, targetDate);
+ const modelsMap = { ...view.models };
+ const modelMetadata =
+ detail.source_forecasts?.open_meteo_multi_model?.model_metadata || {};
+
+ const modelEntries = Object.entries(modelsMap).filter(
+ ([, value]) =>
+ value !== null && value !== undefined && Number.isFinite(Number(value)),
+ );
+ const hasSingleModelOnly = modelEntries.length === 1;
+
+ // 如果没有任何数值,给出提示
+ if (modelEntries.length === 0) {
+ return (
+
+ {!hideTitle && {t("section.models")} }
+
+
+
+
+ );
+ }
+
+ const numericValues = modelEntries.map(([, value]) => Number(value));
+ const comparisonValues =
+ view.deb != null ? [...numericValues, Number(view.deb)] : numericValues;
+ const minValue = comparisonValues.length
+ ? Math.min(...comparisonValues) - 1
+ : 0;
+ const maxValue = comparisonValues.length
+ ? Math.max(...comparisonValues) + 1
+ : 1;
+ const range = Math.max(maxValue - minValue, 1);
+ const sortedEntries = modelEntries.sort(
+ (a, b) => Number(b[1] || 0) - Number(a[1] || 0),
+ );
+ const groupedEntries = sortedEntries
+ .reduce(
+ (acc, [name, value]) => {
+ const group = getModelGroupMeta(name, modelMetadata, locale);
+ const existing = acc.find((item) => item.key === group.key);
+ const entry = {
+ metaLine: formatModelMetaLine(name, modelMetadata, locale),
+ name,
+ value: Number(value),
+ };
+ if (existing) {
+ existing.entries.push(entry);
+ } else {
+ acc.push({ ...group, entries: [entry] });
+ }
+ return acc;
+ },
+ [] as Array<{
+ entries: Array<{ metaLine: string; name: string; value: number }>;
+ key: string;
+ label: string;
+ order: number;
+ tone: string;
+ }>,
+ )
+ .sort((a, b) => a.order - b.order);
+ const spread =
+ numericValues.length >= 2
+ ? Math.max(...numericValues) - Math.min(...numericValues)
+ : null;
+ const metadataSource =
+ detail.source_forecasts?.open_meteo_multi_model?.provider === "open-meteo"
+ ? "Open-Meteo"
+ : null;
+
+ return (
+
+ {!hideTitle && {t("section.models")} }
+
+
+
+ {locale === "en-US" ? "Available models" : "可用模型"} ·{" "}
+ {modelEntries.length}
+
+
+ {locale === "en-US" ? "Spread" : "分歧"} ·{" "}
+
+ {spread != null
+ ? `${spread.toFixed(1)}${detail.temp_symbol}`
+ : "--"}
+
+
+ {metadataSource && (
+
+ {locale === "en-US" ? "API" : "接口"} ·{" "}
+ {metadataSource}
+
+ )}
+
+ {hasSingleModelOnly && (
+
+ {locale === "en-US"
+ ? "Single-model fallback: waiting for the rest of the model cluster."
+ : "当前处于单模型回退,其他模型结果还没回传。"}
+
+ )}
+ {groupedEntries.map((group) => (
+
+
+ {group.label}
+ {group.entries.length}
+
+ {group.entries.map(({ metaLine, name, value }) => {
+ const width = ((value - minValue) / range) * 100;
+ const debLine =
+ view.deb != null
+ ? ((Number(view.deb) - minValue) / range) * 100
+ : null;
+
+ return (
+
+
+ {name}
+ {metaLine && {metaLine} }
+
+
+
+
+ {value}
+ {detail.temp_symbol}
+
+ {debLine != null && (
+
+ )}
+
+
+ );
+ })}
+
+ ))}
+ {view.deb != null && (
+
+
+ DEB
+
+
+
+
+ {Number(view.deb)}
+ {detail.temp_symbol}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/components/dashboard/OpportunityTable.tsx b/frontend/components/dashboard/OpportunityTable.tsx
index e94848df..c6c9e9b2 100644
--- a/frontend/components/dashboard/OpportunityTable.tsx
+++ b/frontend/components/dashboard/OpportunityTable.tsx
@@ -7,2042 +7,28 @@ import type {
CityDetail,
ScanOpportunityRow,
} from "@/lib/dashboard-types";
-import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
-import { getModelView, getProbabilityView } from "@/lib/model-utils";
-import { getTodayPaceView } from "@/lib/pace-utils";
import {
formatTemperatureValue,
- normalizeTemperatureLabel,
normalizeTemperatureSymbol,
} from "@/lib/temperature-utils";
-
-type PhaseMeta = {
- label: string;
- tone: "green" | "amber" | "blue" | "red";
-};
-
-function formatPercent(value?: number | null, signed = false) {
- if (value == null || Number.isNaN(Number(value))) return "--";
- const numeric = Number(value);
- return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
-}
-
-function normalizeProbability(value?: number | null) {
- if (value == null) return null;
- const numeric = Number(value);
- if (!Number.isFinite(numeric)) return null;
- return Math.max(0, Math.min(1, numeric > 1 ? numeric / 100 : numeric));
-}
-
-function formatWindowMinutes(value: number | null | undefined, locale: string) {
- if (value == null || !Number.isFinite(Number(value))) return "--";
- const minutes = Math.max(0, Math.round(Number(value)));
- const hours = Math.floor(minutes / 60);
- const remains = minutes % 60;
- if (locale === "en-US") {
- if (hours <= 0) return `${remains}m left`;
- return `${hours}h ${remains}m left`;
- }
- if (hours <= 0) return `剩余 ${remains} 分钟`;
- return `剩余 ${hours}h ${remains}m`;
-}
-
-function formatMinuteSpan(value: number | null | undefined, locale: string) {
- if (value == null || !Number.isFinite(Number(value))) return "--";
- const minutes = Math.max(0, Math.round(Number(value)));
- const hours = Math.floor(minutes / 60);
- const remains = minutes % 60;
- if (locale === "en-US") {
- if (hours <= 0) return `${remains}m`;
- return `${hours}h ${remains}m`;
- }
- if (hours <= 0) return `${remains} 分钟`;
- return `${hours}h ${remains}m`;
-}
-
-function formatAction(
- row: ScanOpportunityRow,
- locale: string,
- tempSymbol?: string | null,
-) {
- return formatTradeSide(row, locale, tempSymbol);
-}
-
-export function getWindowPhaseMeta(
- row: Pick,
- locale: string,
-): PhaseMeta {
- const mode = String(row.window_phase || "").toLowerCase();
- if (mode === "city_snapshot") {
- return {
- label: locale === "en-US" ? "City Snapshot" : "城市概况",
- tone: "blue",
- };
- }
- if (mode === "active_peak") {
- return {
- label: locale === "en-US" ? "Peak Window" : "峰值窗口",
- tone: "red",
- };
- }
- if (mode === "setup_today") {
- return {
- label: locale === "en-US" ? "Touch Play" : "触达博弈",
- tone: "red",
- };
- }
- if (mode === "early_today") {
- return {
- label: locale === "en-US" ? "Early Today" : "日内早段",
- tone: "blue",
- };
- }
- if (mode === "tomorrow" || mode === "week_ahead") {
- return {
- label: locale === "en-US" ? "Early" : "早期机会",
- tone: "blue",
- };
- }
- if (mode === "post_peak") {
- return {
- label: locale === "en-US" ? "Post Peak" : "峰后确认",
- tone: "amber",
- };
- }
- if (row.trend_alignment) {
- return {
- label: locale === "en-US" ? "Trend" : "趋势确认",
- tone: "amber",
- };
- }
- return {
- label: locale === "en-US" ? "Tradable" : "可交易",
- tone: "green",
- };
-}
-
-function formatQuoteCents(value?: number | null) {
- if (value == null || Number.isNaN(Number(value))) return "--";
- const cents = Number(value) * 100;
- const text =
- cents < 1 || cents >= 99 || Math.abs(cents - Math.round(cents)) >= 0.05
- ? cents.toFixed(1)
- : Math.round(cents).toFixed(0);
- return `${text.replace(/\.0$/, "")}¢`;
-}
-
-function formatTradeSide(
- row: ScanOpportunityRow,
- locale: string,
- tempSymbol?: string | null,
-) {
- const side = String(row.side || "").toLowerCase();
- const isEn = locale === "en-US";
- const { lower, upper } = getTargetRange(row);
- const threshold =
- lower != null && upper == null
- ? formatTemperatureValue(lower, tempSymbol)
- : upper != null && lower == null
- ? formatTemperatureValue(upper, tempSymbol)
- : null;
- if (threshold && lower != null && upper == null) {
- if (side === "yes") return isEn ? `High reaches ${threshold}` : `最高温达到 ${threshold}`;
- if (side === "no") return isEn ? `High stays below ${threshold}` : `最高温低于 ${threshold}`;
- }
- if (threshold && upper != null && lower == null) {
- if (side === "yes") return isEn ? `High stays at/below ${threshold}` : `最高温不高于 ${threshold}`;
- if (side === "no") return isEn ? `High exceeds ${threshold}` : `最高温高于 ${threshold}`;
- }
- if (lower != null && upper != null && Math.abs(lower - upper) > 0.01) {
- const range = `${formatTemperatureValue(lower, tempSymbol)} ~ ${formatTemperatureValue(upper, tempSymbol)}`;
- if (side === "yes") return isEn ? `High lands in ${range}` : `最高温落在 ${range}`;
- if (side === "no") return isEn ? `High avoids ${range}` : `最高温不在 ${range}`;
- }
- const bucket = formatThreshold(row, tempSymbol);
- if (side === "yes") return isEn ? `High lands on ${bucket}` : `最高温落在 ${bucket} 桶`;
- if (side === "no") return isEn ? `High avoids ${bucket}` : `最高温不落在 ${bucket} 桶`;
- if (row.action) {
- return normalizeTemperatureLabel(
- String(row.action).replace(String(row.target_label || ""), ""),
- tempSymbol,
- )
- .replace(/\s+/g, " ")
- .trim()
- .toUpperCase();
- }
- return locale === "en-US" ? "WATCH" : "观察";
-}
-
-function formatThreshold(row: ScanOpportunityRow, tempSymbol?: string | null) {
- const targetLabel = normalizeTemperatureLabel(row.target_label, tempSymbol);
- if (targetLabel) return targetLabel;
- if (row.target_lower != null && row.target_upper != null) {
- return `${formatTemperatureValue(Number(row.target_lower), tempSymbol)} ~ ${formatTemperatureValue(Number(row.target_upper), tempSymbol)}`;
- }
- if (row.target_threshold != null) {
- return formatTemperatureValue(Number(row.target_threshold), tempSymbol);
- }
- if (row.target_value != null) {
- return formatTemperatureValue(Number(row.target_value), tempSymbol);
- }
- return "--";
-}
-
-function formatTemperatureDelta(value: number, tempSymbol?: string | null) {
- return formatTemperatureValue(Math.abs(value), tempSymbol, { digits: 1 });
-}
-
-function getDebDistanceSummary(
- row: ScanOpportunityRow,
- locale: string,
- tempSymbol?: string | null,
-) {
- const deb =
- row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
- ? Number(row.deb_prediction)
- : null;
- if (deb == null) return locale === "en-US" ? "DEB pending" : "DEB 待确认";
- const { lower, upper } = getTargetRange(row);
- if (lower != null && upper == null) {
- const delta = deb - lower;
- if (Math.abs(delta) < 0.05) return locale === "en-US" ? "DEB on threshold" : "DEB 贴近阈值";
- return delta >= 0
- ? locale === "en-US"
- ? `DEB above by ${formatTemperatureDelta(delta, tempSymbol)}`
- : `DEB 高于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`
- : locale === "en-US"
- ? `DEB below by ${formatTemperatureDelta(delta, tempSymbol)}`
- : `DEB 低于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`;
- }
- if (upper != null && lower == null) {
- const delta = deb - upper;
- if (Math.abs(delta) < 0.05) return locale === "en-US" ? "DEB on threshold" : "DEB 贴近阈值";
- return delta <= 0
- ? locale === "en-US"
- ? `DEB below by ${formatTemperatureDelta(delta, tempSymbol)}`
- : `DEB 低于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`
- : locale === "en-US"
- ? `DEB above by ${formatTemperatureDelta(delta, tempSymbol)}`
- : `DEB 高于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`;
- }
- if (lower != null && upper != null) {
- if (deb >= lower && deb <= upper) return locale === "en-US" ? "DEB inside bucket" : "DEB 位于桶内";
- const nearest = deb < lower ? lower : upper;
- const delta = deb - nearest;
- return deb < lower
- ? locale === "en-US"
- ? `DEB below bucket by ${formatTemperatureDelta(delta, tempSymbol)}`
- : `DEB 低于桶 ${formatTemperatureDelta(delta, tempSymbol)}`
- : locale === "en-US"
- ? `DEB above bucket by ${formatTemperatureDelta(delta, tempSymbol)}`
- : `DEB 高于桶 ${formatTemperatureDelta(delta, tempSymbol)}`;
- }
- return locale === "en-US"
- ? `DEB ${formatTemperatureValue(deb, tempSymbol, { digits: 1 })}`
- : `DEB ${formatTemperatureValue(deb, tempSymbol, { digits: 1 })}`;
-}
-
-function getModelSupportSummary(
- row: ScanOpportunityRow,
- locale: string,
-) {
- const sources = Object.values(row.model_cluster_sources || {})
- .map((value) => Number(value))
- .filter((value) => Number.isFinite(value));
- const deb =
- row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
- ? Number(row.deb_prediction)
- : null;
- if (!sources.length || deb == null) return locale === "en-US" ? "Models pending" : "模型待确认";
- const { lower, upper } = getTargetRange(row);
- let supports = 0;
- if (lower != null && upper == null) {
- supports = sources.filter((value) => (deb >= lower ? value >= lower : value < lower)).length;
- } else if (upper != null && lower == null) {
- supports = sources.filter((value) => (deb <= upper ? value <= upper : value > upper)).length;
- } else if (lower != null && upper != null) {
- if (deb >= lower && deb <= upper) {
- supports = sources.filter((value) => value >= lower && value <= upper).length;
- } else if (deb < lower) {
- supports = sources.filter((value) => value < lower).length;
- } else {
- supports = sources.filter((value) => value > upper).length;
- }
- } else {
- const tolerance = 1;
- supports = sources.filter((value) => Math.abs(value - deb) <= tolerance).length;
- }
- return locale === "en-US"
- ? `${supports}/${sources.length} models support DEB`
- : `${supports}/${sources.length} 模型支持 DEB`;
-}
-
-function getMetarConflictSummary(
- row: ScanOpportunityRow,
- detail: CityDetail | null,
- locale: string,
-) {
- const obs = getMetarObservationContext(row, detail);
- if (obs.stale || obs.maxTemp == null) return locale === "en-US" ? "METAR pending" : "METAR 待确认";
- const deb =
- row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
- ? Number(row.deb_prediction)
- : null;
- const { lower, upper } = getTargetRange(row);
- if (deb == null || (lower == null && upper == null)) {
- return locale === "en-US" ? "METAR read only" : "METAR 仅参考";
- }
- const phase = String(row.window_phase || "").toLowerCase();
- const peakPending =
- phase === "early_today" ||
- phase === "setup_today" ||
- (row.minutes_until_peak_start != null && Number(row.minutes_until_peak_start) > 0);
- if (lower != null && upper == null) {
- if (deb < lower && obs.maxTemp >= lower) return locale === "en-US" ? "METAR conflicts" : "METAR 冲突";
- if (deb >= lower && obs.maxTemp < lower && peakPending) return locale === "en-US" ? "Await peak" : "等待峰值";
- return locale === "en-US" ? "METAR no conflict" : "METAR 未冲突";
- }
- if (upper != null && lower == null) {
- if (deb <= upper && obs.maxTemp > upper) return locale === "en-US" ? "METAR conflicts" : "METAR 冲突";
- if (deb > upper && obs.maxTemp <= upper && peakPending) return locale === "en-US" ? "Await peak" : "等待峰值";
- return locale === "en-US" ? "METAR no conflict" : "METAR 未冲突";
- }
- if (lower != null && upper != null && deb >= lower && deb <= upper) {
- if (obs.maxTemp > upper) return locale === "en-US" ? "METAR above bucket" : "METAR 已越过桶";
- if (obs.maxTemp < lower && peakPending) return locale === "en-US" ? "Await peak" : "等待峰值";
- }
- return locale === "en-US" ? "METAR no conflict" : "METAR 未冲突";
-}
-
-function getOpportunityStrength(edgePercent?: number | null, locale = "zh-CN") {
- const edge = Number(edgePercent);
- const normalized = Number.isFinite(edge) ? edge : 0;
- if (normalized >= 20) {
- return {
- label: locale === "en-US" ? "High confidence" : "高胜率",
- tone: "strong",
- };
- }
- if (normalized >= 10) {
- return {
- label: locale === "en-US" ? "Medium confidence" : "中等胜率",
- tone: "medium",
- };
- }
- return {
- label: locale === "en-US" ? "Watch" : "观察",
- tone: "watch",
- };
-}
-
-function getLocalizedRowText(
- row: ScanOpportunityRow,
- locale: string,
- zh?: string | null,
- en?: string | null,
-) {
- return locale === "en-US" ? en || zh || null : zh || en || null;
-}
-
-function formatModelSources(row: ScanOpportunityRow, tempSymbol?: string | null) {
- const sources = row.model_cluster_sources || {};
- return Object.entries(sources)
- .filter(([, value]) => value != null && Number.isFinite(Number(value)))
- .sort(([left], [right]) => left.localeCompare(right))
- .map(([name, value]) => ({
- name,
- value: formatTemperatureValue(Number(value), tempSymbol, { digits: 1 }),
- }));
-}
-
-function formatModelClusterRange(
- sources?: Record | null,
- tempSymbol?: string | null,
-) {
- const values = Object.values(sources || {})
- .map((value) => Number(value))
- .filter((value) => Number.isFinite(value));
- if (!values.length) return "--";
- const low = Math.min(...values);
- const high = Math.max(...values);
- if (Math.abs(low - high) < 0.05) {
- return formatTemperatureValue(low, tempSymbol, { digits: 1 });
- }
- return `${formatTemperatureValue(low, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(high, tempSymbol, { digits: 1 })}`;
-}
-
-function getModelSourceSummary(
- row: ScanOpportunityRow,
- locale: string,
- tempSymbol?: string | null,
-) {
- const sources = formatModelSources(row, tempSymbol);
- if (!sources.length) {
- return locale === "en-US"
- ? "model cluster pending"
- : "模型集群暂未回传";
- }
- const shown = sources.map((item) => `${item.name} ${item.value}`).join(" / ");
- return locale === "en-US"
- ? `all models: ${shown}`
- : `全部模型:${shown}`;
-}
-
-function getShortAiConclusion(
- row: ScanOpportunityRow,
- locale: string,
- _edgePercent?: number | null,
- strengthLabel?: string,
-) {
- const directReason =
- getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
- getLocalizedRowText(
- row,
- locale,
- row.ai_watchlist_reason_zh,
- row.ai_watchlist_reason_en,
- );
- if (directReason) return directReason;
- const cityThesis = getLocalizedRowText(
- row,
- locale,
- row.ai_city_thesis_zh,
- row.ai_city_thesis_en,
- );
- if (cityThesis) return cityThesis;
-
- const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol);
- if (locale === "en-US") {
- return `${strengthLabel || "Watch"}: AI should validate against ${modelBasis}.`;
- }
- return `${strengthLabel || "观察"}:AI 需结合${modelBasis}确认。`;
-}
-
-function getRiskHints(
- row: ScanOpportunityRow,
- locale: string,
- modelProbability?: number | null,
-) {
- const hints: string[] = [];
- const spread = Number(row.spread);
- if (Number.isFinite(spread) && spread > 0.03) {
- hints.push(
- locale === "en-US"
- ? `Wide spread ${formatQuoteCents(spread)} may distort the displayed market price.`
- : `盘口价差 ${formatQuoteCents(spread)} 偏宽,可能扭曲市场价格参考。`,
- );
- }
- const quoteAgeSeconds =
- row.quote_age_ms != null && Number.isFinite(Number(row.quote_age_ms))
- ? Math.round(Number(row.quote_age_ms) / 1000)
- : null;
- if (quoteAgeSeconds != null && quoteAgeSeconds > 60) {
- hints.push(
- locale === "en-US"
- ? `Quote age ${quoteAgeSeconds}s; refresh before acting.`
- : `报价已 ${quoteAgeSeconds}s,执行前需要刷新。`,
- );
- }
- if (row.trend_alignment === false) {
- hints.push(
- locale === "en-US"
- ? "Intraday trend does not fully support this direction."
- : "日内趋势未完全支持该方向。",
- );
- }
- if (row.cluster_adjusted) {
- hints.push(
- locale === "en-US"
- ? "Tail bucket was cluster-adjusted; bucket confidence may be overstated."
- : "尾部桶已做模型集群折扣,温度桶信心可能偏乐观。",
- );
- }
- if (modelProbability != null && modelProbability < 10) {
- hints.push(
- locale === "en-US"
- ? "Low model probability makes the setup sensitive to calibration error."
- : "模型概率偏低,校准误差会显著影响判断。",
- );
- }
- if (!hints.length) {
- hints.push(
- locale === "en-US"
- ? "Main residual risk is late observation updates or a shifted peak window."
- : "主要残余风险是后续实测升温或峰值窗口漂移。",
- );
- }
- return hints;
-}
-
-function getRecommendationReasons(
- row: ScanOpportunityRow,
- locale: string,
- _edgePercent?: number | null,
- price?: number | null,
-) {
- const reasons: string[] = [];
- const aiReason = getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en);
- if (aiReason && String(row.ai_decision || "").toLowerCase() === "approve") {
- reasons.push(aiReason);
- }
- const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol);
- reasons.push(
- locale === "en-US"
- ? `AI uses ${modelBasis} with market ask ${formatQuoteCents(price)} only as downstream bucket context.`
- : `AI 以${modelBasis}为主,市场买价 ${formatQuoteCents(price)} 只作下游温度桶参考。`,
- );
- if (row.peak_alignment_score != null) {
- reasons.push(
- locale === "en-US"
- ? `Peak alignment score ${Number(row.peak_alignment_score).toFixed(2)} supports checking this bucket.`
- : `峰值对齐分 ${Number(row.peak_alignment_score).toFixed(2)},支持把该桶纳入检查。`,
- );
- }
- return reasons.slice(0, 3);
-}
-
-function getExclusionReasons(
- row: ScanOpportunityRow,
- locale: string,
- edgePercent?: number | null,
-) {
- const decision = String(row.ai_decision || "").toLowerCase();
- const aiReason =
- getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
- getLocalizedRowText(
- row,
- locale,
- row.ai_watchlist_reason_zh,
- row.ai_watchlist_reason_en,
- );
- if (decision === "veto" || decision === "downgrade" || decision === "watchlist") {
- return [
- aiReason ||
- (locale === "en-US"
- ? "AI did not classify this row as the primary forecast bucket."
- : "AI 未把该合约列为主预测桶。"),
- ];
- }
- if (edgePercent != null && Number(edgePercent) < 10) {
- return [
- locale === "en-US"
- ? "This bucket is not the current forecast center."
- : "该桶不是当前预测中枢。",
- ];
- }
- return [
- locale === "en-US"
- ? "No hard veto in the current AI/rule snapshot."
- : "当前 AI/规则快照没有硬性排除项。",
- ];
-}
-
-function getAiMeta(row: ScanOpportunityRow, locale: string) {
- const decision = String(row.ai_decision || "").toLowerCase();
- if (decision === "veto") {
- return {
- label: locale === "en-US" ? "AI veto" : "AI 排除",
- tone: "veto",
- reason: locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en,
- };
- }
- if (decision === "downgrade") {
- return {
- label: locale === "en-US" ? "AI downgrade" : "AI 降级",
- tone: "downgrade",
- reason: locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en,
- };
- }
- if (row.ai_rank != null || decision === "approve") {
- return {
- label: locale === "en-US" ? `AI pick ${row.ai_rank || ""}`.trim() : `AI 推荐 ${row.ai_rank || ""}`.trim(),
- tone: "approve",
- reason:
- (locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en) ||
- row.ai_model_cluster_note ||
- null,
- };
- }
- if (decision === "watchlist") {
- return {
- label: locale === "en-US" ? "AI watch" : "AI 观察",
- tone: "downgrade",
- reason:
- locale === "en-US"
- ? row.ai_watchlist_reason_en || row.ai_watchlist_reason_zh
- : row.ai_watchlist_reason_zh || row.ai_watchlist_reason_en,
- };
- }
- return null;
-}
-
-type ObservationPoint = { time?: string; temp?: number | null };
-
-type V4TradeDecision = {
- decision: "approve" | "downgrade" | "veto" | "watchlist";
- label: string;
- tone: "approve" | "downgrade" | "veto" | "watchlist";
- reason: string;
- metarSummary?: string | null;
- airportReport?: string | null;
- metarEvidence: string[];
-};
-
-type V4CityForecast = {
- predicted: number | null;
- low: number | null;
- high: number | null;
- 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";
-};
-
-function normalizeLookupKey(value?: string | null) {
- return String(value || "")
- .trim()
- .toLowerCase()
- .replace(/[\s_-]+/g, "");
-}
-
-function getDetailForRow(
- row: Pick,
- cityDetailsByName?: Record,
-) {
- if (!cityDetailsByName) return null;
- const rowKeys = [row.city, row.city_display_name, row.display_name]
- .map(normalizeLookupKey)
- .filter(Boolean);
- return (
- Object.entries(cityDetailsByName).find(([name, detail]) => {
- const detailKeys = [name, detail.name, detail.display_name]
- .map(normalizeLookupKey)
- .filter(Boolean);
- return rowKeys.some((key) => detailKeys.includes(key));
- })?.[1] || null
- );
-}
-
-function getDetailViewDate(detail: CityDetail, row?: ScanOpportunityRow | null) {
- if (!row) return detail.local_date;
- const rawDate = row.selected_date || row.local_date || "";
- const phase = String(row.window_phase || "").toLowerCase();
- if ((phase === "tomorrow" || phase === "week_ahead") && rawDate) return rawDate;
- if (!rawDate || rawDate === detail.local_date || row.local_date === detail.local_date) {
- return detail.local_date;
- }
- return detail.local_date || rawDate;
-}
-
-function normalizeBucketLabel(value?: string | null, tempSymbol?: string | null) {
- return normalizeTemperatureLabel(value, tempSymbol)
- .toLowerCase()
- .replace(/\s+/g, "")
- .replace(/℃/g, "°c");
-}
-
-function extractNumbers(value?: string | null) {
- return Array.from(String(value || "").matchAll(/-?\d+(?:\.\d+)?/g)).map((match) =>
- Number(match[0]),
- );
-}
-
-function normalizeObservationPoints(points?: ObservationPoint[] | null) {
- if (!Array.isArray(points)) return [];
- return points
- .map((point) => ({
- time: String(point?.time || "").trim(),
- temp:
- point?.temp != null && Number.isFinite(Number(point.temp))
- ? Number(point.temp)
- : null,
- }))
- .filter((point): point is { time: string; temp: number } =>
- Boolean(point.time && point.temp != null),
- )
- .sort((a, b) => getObservationSortMinutes(a.time) - getObservationSortMinutes(b.time));
-}
-
-function getObservationSortMinutes(time: string) {
- const parsed = Date.parse(time);
- if (Number.isFinite(parsed)) {
- const date = new Date(parsed);
- return date.getUTCHours() * 60 + date.getUTCMinutes();
- }
- const match = time.match(/(\d{1,2}):(\d{2})/);
- if (!match) return Number.MAX_SAFE_INTEGER;
- return Number(match[1]) * 60 + Number(match[2]);
-}
-
-function formatPeakWindowTiming(row: ScanOpportunityRow, locale: string) {
- const isEn = locale === "en-US";
- const phase = String(row.window_phase || "").toLowerCase();
- const label = String(row.peak_window_label || "").trim();
- const untilStart =
- row.minutes_until_peak_start != null && Number.isFinite(Number(row.minutes_until_peak_start))
- ? Number(row.minutes_until_peak_start)
- : null;
- const untilEnd =
- row.minutes_until_peak_end != null && Number.isFinite(Number(row.minutes_until_peak_end))
- ? Number(row.minutes_until_peak_end)
- : null;
- const windowText = label ? `${isEn ? "peak window" : "峰值窗口"} ${label}` : isEn ? "peak window" : "峰值窗口";
- if (phase === "active_peak" || (untilStart != null && untilStart <= 0 && untilEnd != null && untilEnd > 0)) {
- return isEn ? `Currently inside the ${windowText}.` : `当前已进入${windowText}。`;
- }
- if (phase === "post_peak" || (untilEnd != null && untilEnd <= 0)) {
- return isEn ? `The ${windowText} has passed.` : `${windowText}已结束。`;
- }
- if (untilStart != null && untilStart > 0) {
- return isEn
- ? `${windowText} starts in ${formatMinuteSpan(untilStart, locale)}.`
- : `${windowText}尚未开始,约 ${formatMinuteSpan(untilStart, locale)} 后进入。`;
- }
- if (phase === "early_today" || phase === "setup_today") {
- return isEn ? `Before the ${windowText}; latest METAR is not final peak evidence yet.` : `尚处峰值前,最新 METAR 还不能当作最终峰值证据。`;
- }
- return label ? (isEn ? `Reference ${windowText}.` : `参考${windowText}。`) : null;
-}
-
-function decodeRawMetarCloud(rawMetar?: string | null, locale = "zh-CN") {
- const raw = String(rawMetar || "").toUpperCase();
- const matches = Array.from(raw.matchAll(/\b(FEW|SCT|BKN|OVC)(\d{3})?\b/g));
- if (!matches.length) return "";
- const coverText: Record = {
- FEW: { zh: "少云", en: "few" },
- SCT: { zh: "散云", en: "scattered" },
- BKN: { zh: "多云", en: "broken" },
- OVC: { zh: "阴天", en: "overcast" },
- };
- return matches
- .slice(0, 3)
- .map((match) => {
- const cover = coverText[match[1]] || { zh: match[1], en: match[1] };
- const base = match[2] ? `${Number(match[2]) * 100}ft` : "";
- return locale === "en-US"
- ? [cover.en, base].filter(Boolean).join(" ")
- : [cover.zh, base].filter(Boolean).join(" ");
- })
- .join(locale === "en-US" ? ", " : "、");
-}
-
-function decodeRawMetarVisibility(rawMetar?: string | null) {
- const raw = String(rawMetar || "").toUpperCase();
- if (/\b9999\b/.test(raw)) return "10km+";
- const meterMatch = raw.match(/\b(\d{4})\b/);
- if (meterMatch) return `${Number(meterMatch[1]) / 1000}km`;
- 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,
- locale: string,
- tempSymbol?: string | null,
-) {
- const isEn = locale === "en-US";
- const context = row.metar_context || {};
- const airport: Partial> =
- detail?.airport_current || {};
- const station =
- context.station ||
- detail?.risk?.icao ||
- airport.station_code ||
- null;
- const obsTime =
- context.airport_obs_time ||
- context.last_time ||
- airport.obs_time ||
- row.metar_status?.last_observation_time ||
- null;
- const temp =
- context.airport_current_temp != null && Number.isFinite(Number(context.airport_current_temp))
- ? Number(context.airport_current_temp)
- : airport.temp != null && Number.isFinite(Number(airport.temp))
- ? Number(airport.temp)
- : null;
- const 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;
- const windDir =
- context.airport_wind_dir != null && Number.isFinite(Number(context.airport_wind_dir))
- ? Number(context.airport_wind_dir)
- : airport.wind_dir != null && Number.isFinite(Number(airport.wind_dir))
- ? Number(airport.wind_dir)
- : null;
- const cloud = String(context.airport_cloud_desc || airport.cloud_desc || "").trim();
- 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)
- : airport.visibility_mi != null && Number.isFinite(Number(airport.visibility_mi))
- ? Number(airport.visibility_mi)
- : null;
- const decodedVisibility = visibility != null ? `${visibility.toFixed(1)}mi` : decodeRawMetarVisibility(rawMetar);
-
- const parts: string[] = [];
- if (temp != null) parts.push(formatTemperatureValue(temp, tempSymbol, { digits: 1 }));
- if (windSpeed != null) {
- parts.push(
- windDir != null
- ? isEn
- ? `wind ${Math.round(windDir)}°/${Math.round(windSpeed)}kt`
- : `风 ${Math.round(windDir)}°/${Math.round(windSpeed)}kt`
- : isEn
- ? `wind ${Math.round(windSpeed)}kt`
- : `风 ${Math.round(windSpeed)}kt`,
- );
- }
- if (decodedCloud) parts.push(isEn ? `cloud ${decodedCloud}` : `云况 ${decodedCloud}`);
- 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" : "最新机场报文解读";
- const head = [station, obsTime].filter(Boolean).join(" ");
- 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);
- const mid = Math.floor(sorted.length / 2);
- return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
-}
-
-function getV4CityForecast(
- row: ScanOpportunityRow,
- group: OpportunityGroup,
- detail: CityDetail | null,
- locale: string,
- tempSymbol?: string | null,
-): V4CityForecast {
- const isEn = locale === "en-US";
- const aiPredicted =
- row.ai_predicted_max != null && Number.isFinite(Number(row.ai_predicted_max))
- ? Number(row.ai_predicted_max)
- : null;
- const aiLow =
- row.ai_predicted_low != null && Number.isFinite(Number(row.ai_predicted_low))
- ? Number(row.ai_predicted_low)
- : null;
- const aiHigh =
- row.ai_predicted_high != null && Number.isFinite(Number(row.ai_predicted_high))
- ? Number(row.ai_predicted_high)
- : null;
- const modelValues = Object.values(row.model_cluster_sources || {})
- .map((value) => Number(value))
- .filter((value) => Number.isFinite(value));
- const fallbackPredicted =
- aiPredicted ??
- (row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
- ? Number(row.deb_prediction)
- : median(modelValues));
- const fallbackLow = aiLow ?? (modelValues.length ? Math.min(...modelValues) : fallbackPredicted);
- const fallbackHigh = aiHigh ?? (modelValues.length ? Math.max(...modelValues) : fallbackPredicted);
- const peakWindow =
- getLocalizedRowText(row, locale, row.ai_peak_window_zh, row.ai_peak_window_en) ||
- formatPeakWindowTiming(row, locale);
- const airportRead =
- getLocalizedRowText(
- row,
- locale,
- 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 ||
- getModelSourceSummary(row, locale, tempSymbol);
- const reason =
- getLocalizedRowText(row, locale, row.ai_forecast_reason_zh, row.ai_forecast_reason_en) ||
- 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 })}; market temperature buckets are only mapped against that forecast range.`
- : `${group.cityName} 最终最高温先以 ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })} 附近为中枢,市场温度桶只用于对照 AI 预测区间。`
- : null);
- return {
- predicted: fallbackPredicted,
- low: fallbackLow,
- high: fallbackHigh,
- 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",
- };
-}
-
-function getForecastRangeLabel(forecast: V4CityForecast, tempSymbol?: string | null) {
- if (forecast.low == null && forecast.high == null) return "--";
- if (forecast.low != null && forecast.high != null) {
- if (Math.abs(forecast.low - forecast.high) < 0.05) {
- return formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 });
- }
- return `${formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(forecast.high, tempSymbol, { digits: 1 })}`;
- }
- if (forecast.low != null) return `>= ${formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 })}`;
- return `<= ${formatTemperatureValue(Number(forecast.high), tempSymbol, { digits: 1 })}`;
-}
-
-function getForecastContractFit(
- row: ScanOpportunityRow,
- forecast: V4CityForecast,
- locale: string,
- tempSymbol?: string | null,
-) {
- const isEn = locale === "en-US";
- const explicitMatch = String(row.ai_forecast_match || "").toLowerCase();
- const explicitReason = getLocalizedRowText(
- row,
- locale,
- row.ai_forecast_match_reason_zh,
- row.ai_forecast_match_reason_en,
- );
- if (explicitMatch && explicitReason) {
- return {
- label:
- explicitMatch === "core"
- ? isEn ? "Core bucket" : "核心桶"
- : explicitMatch === "outside"
- ? isEn ? "Outside forecast" : "预测区间外"
- : explicitMatch === "edge"
- ? isEn ? "Boundary bucket" : "边界桶"
- : isEn ? "Watch" : "观察",
- tone: explicitMatch === "core" ? "approve" : explicitMatch === "outside" ? "veto" : "watchlist",
- reason: explicitReason,
- };
- }
-
- const { lower, upper } = getTargetRange(row);
- const predicted = forecast.predicted;
- if (predicted == null || (lower == null && upper == null)) {
- return {
- label: isEn ? "Await forecast" : "等待预测",
- tone: "watchlist",
- reason: isEn ? "AI has no stable max-temperature center yet." : "AI 还没有稳定的最高温中枢。",
- };
- }
- const unit = normalizeTemperatureSymbol(tempSymbol);
- const tolerance = String(unit).toUpperCase().includes("F") ? 1.0 : 0.5;
- const inside =
- (lower == null || predicted >= lower - tolerance) &&
- (upper == null || predicted <= upper + tolerance);
- const rangeOverlaps =
- forecast.low != null &&
- forecast.high != null &&
- (lower == null || forecast.high >= lower - tolerance) &&
- (upper == null || forecast.low <= upper + tolerance);
- if (inside) {
- return {
- label: isEn ? "Core bucket" : "核心桶",
- tone: "approve",
- reason: isEn
- ? `AI max-temperature center ${formatTemperatureValue(predicted, unit, { digits: 1 })} sits inside this bucket.`
- : `AI 最高温中枢 ${formatTemperatureValue(predicted, unit, { digits: 1 })} 落在这个温度桶内。`,
- };
- }
- if (rangeOverlaps) {
- return {
- label: isEn ? "Boundary bucket" : "边界桶",
- tone: "watchlist",
- reason: isEn
- ? `This bucket touches the AI interval ${getForecastRangeLabel(forecast, unit)}, but is not the center.`
- : `该桶触及 AI 区间 ${getForecastRangeLabel(forecast, unit)},但不是预测中枢。`,
- };
- }
- return {
- label: isEn ? "Outside forecast" : "预测区间外",
- tone: "veto",
- reason: isEn
- ? `This bucket is outside the AI max-temperature interval ${getForecastRangeLabel(forecast, unit)}.`
- : `该桶位于 AI 最高温区间 ${getForecastRangeLabel(forecast, unit)} 之外。`,
- };
-}
-
-function firstNonEmptyPoints(...groups: Array>) {
- return groups.find((group) => group.length > 0) || [];
-}
-
-function getTargetRange(row: ScanOpportunityRow) {
- const lower =
- row.target_lower != null && Number.isFinite(Number(row.target_lower))
- ? Number(row.target_lower)
- : null;
- const upper =
- row.target_upper != null && Number.isFinite(Number(row.target_upper))
- ? Number(row.target_upper)
- : null;
- if (lower != null || upper != null) return { lower, upper };
-
- const rawLabel = String(row.target_label || row.action || "");
- const numbers = extractNumbers(rawLabel);
- if (numbers.length >= 2) {
- return { lower: Math.min(numbers[0], numbers[1]), upper: Math.max(numbers[0], numbers[1]) };
- }
- const value =
- row.target_threshold ??
- row.target_value ??
- (numbers.length ? numbers[0] : null);
- if (value == null || !Number.isFinite(Number(value))) {
- return { lower: null, upper: null };
- }
- const numeric = Number(value);
- if (/(\+|above|higher|or\s+higher|>=|≥|以上)/i.test(rawLabel)) {
- return { lower: numeric, upper: null };
- }
- if (/(below|or\s+below|<=|≤|以下)/i.test(rawLabel)) {
- return { lower: null, upper: numeric };
- }
- return { lower: numeric, upper: numeric };
-}
-
-function getMetarObservationContext(
- row: ScanOpportunityRow,
- detail: CityDetail | null,
-) {
- const context = row.metar_context || {};
- const metarToday = firstNonEmptyPoints(
- normalizeObservationPoints(context.today_obs),
- normalizeObservationPoints(row.metar_today_obs),
- );
- const detailMetarToday = normalizeObservationPoints(detail?.metar_today_obs);
- const metarRecent = firstNonEmptyPoints(
- normalizeObservationPoints(context.recent_obs),
- normalizeObservationPoints(row.metar_recent_obs),
- );
- const detailMetarRecent = normalizeObservationPoints(detail?.metar_recent_obs);
- const settlementToday = firstNonEmptyPoints(
- normalizeObservationPoints(context.settlement_today_obs),
- normalizeObservationPoints(row.settlement_today_obs),
- );
- const detailSettlementToday = normalizeObservationPoints(detail?.settlement_today_obs);
-
- const primaryPoints =
- metarToday.length
- ? metarToday
- : detailMetarToday.length
- ? detailMetarToday
- : metarRecent.length
- ? metarRecent
- : detailMetarRecent.length
- ? detailMetarRecent
- : settlementToday.length
- ? settlementToday
- : detailSettlementToday;
- const trendPoints =
- (metarRecent.length
- ? metarRecent
- : detailMetarRecent.length
- ? detailMetarRecent
- : primaryPoints.slice(-4));
- const explicitLast = context.last_temp ?? row.metar_status?.last_temp ?? detail?.metar_status?.last_temp;
- const lastPoint = primaryPoints[primaryPoints.length - 1] || null;
- const maxPoint = primaryPoints.reduce<{ time: string; temp: number } | null>(
- (best, point) => (!best || point.temp >= best.temp ? point : best),
- null,
- );
- const trendFirst = trendPoints[0] || null;
- const trendLast = trendPoints[trendPoints.length - 1] || null;
- const trendDelta =
- context.trend_delta != null && Number.isFinite(Number(context.trend_delta))
- ? Number(context.trend_delta)
- : trendFirst && trendLast && trendPoints.length >= 2
- ? trendLast.temp - trendFirst.temp
- : null;
- const lastTemp =
- explicitLast != null && Number.isFinite(Number(explicitLast))
- ? Number(explicitLast)
- : lastPoint?.temp ?? null;
- const maxTemp =
- context.max_temp != null && Number.isFinite(Number(context.max_temp))
- ? Number(context.max_temp)
- : maxPoint?.temp ?? null;
- const stale =
- context.stale_for_today === true ||
- row.metar_status?.stale_for_today === true ||
- detail?.metar_status?.stale_for_today === true;
-
- return {
- points: primaryPoints,
- lastTime: String(context.last_time || lastPoint?.time || ""),
- lastTemp,
- maxTime: String(context.max_time || maxPoint?.time || ""),
- maxTemp,
- trendDelta,
- stale,
- station: context.station || detail?.risk?.icao || detail?.airport_current?.station_code || null,
- };
-}
-
-function getMetarGate(
- row: ScanOpportunityRow,
- detail: CityDetail | null,
- locale: string,
- tempSymbol?: string | null,
-) {
- const isEn = locale === "en-US";
- const side = String(row.side || "").toLowerCase();
- const selectedDate = String(row.selected_date || "");
- const localDate = String(row.local_date || detail?.local_date || "");
- const futureContract = Boolean(selectedDate && localDate && selectedDate > localDate);
- if (futureContract) return null;
-
- const obs = getMetarObservationContext(row, detail);
- const evidence: string[] = [];
- 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}` : ""}`,
- );
- }
- if (obs.maxTemp != null) {
- evidence.push(
- `${isEn ? "METAR max" : "METAR 最高"} ${formatTemperatureValue(obs.maxTemp, unit, { digits: 1 })}${obs.maxTime ? ` @ ${obs.maxTime}` : ""}`,
- );
- }
- if (obs.trendDelta != null) {
- evidence.push(
- `${isEn ? "Recent METAR delta" : "近端 METAR 变化"} ${formatTemperatureValue(obs.trendDelta, unit, { digits: 1 })}`,
- );
- }
- if (obs.station) evidence.push(`${isEn ? "Station" : "站点"} ${obs.station}`);
-
- if (obs.stale || !obs.points.length || obs.maxTemp == null) {
- return {
- decision: "downgrade" as const,
- reason: isEn
- ? "AI has no same-day METAR confirmation yet, so this bucket remains forecast-only."
- : "AI 还没有拿到同日 METAR 确认,该桶暂时只能作为预测映射。",
- evidence,
- };
- }
-
- const { lower, upper } = getTargetRange(row);
- if (lower == null && upper == null) {
- return {
- decision: "watchlist" as const,
- reason: isEn
- ? "AI has METAR data, but the contract threshold cannot be mapped cleanly to a bucket."
- : "AI 已读取 METAR,但该合约阈值无法稳定映射到温度桶。",
- evidence,
- };
- }
-
- const epsilon = String(unit).toUpperCase().includes("F") ? 0.7 : 0.4;
- const trendDelta = obs.trendDelta;
- const isNotRising = trendDelta != null && trendDelta <= epsilon;
- const isFalling = trendDelta != null && trendDelta <= -epsilon;
- const phase = String(row.window_phase || "").toLowerCase();
- const remaining =
- row.remaining_window_minutes != null && Number.isFinite(Number(row.remaining_window_minutes))
- ? Number(row.remaining_window_minutes)
- : null;
- const minutesUntilPeakStart =
- row.minutes_until_peak_start != null && Number.isFinite(Number(row.minutes_until_peak_start))
- ? Number(row.minutes_until_peak_start)
- : null;
- const lateWindow =
- phase === "active_peak" ||
- phase === "post_peak" ||
- (remaining != null && remaining <= 180);
- const beforePeak =
- phase === "early_today" ||
- phase === "setup_today" ||
- phase === "tomorrow" ||
- phase === "week_ahead" ||
- (minutesUntilPeakStart != null && minutesUntilPeakStart > 0);
- const aboveUpper = upper != null && obs.maxTemp > upper + epsilon;
- const belowLower = lower != null && obs.maxTemp < lower - epsilon;
- const insideBucket =
- (lower == null || obs.maxTemp >= lower - epsilon) &&
- (upper == null || obs.maxTemp <= upper + epsilon);
-
- if (side === "no") {
- if (aboveUpper) {
- return {
- decision: "approve" as const,
- reason: isEn
- ? "METAR max has already moved above this bucket, so AI marks the NO bucket as observation-supported."
- : "METAR 实测最高已越过目标桶上沿,AI 判断 NO 桶有实测支撑。",
- evidence,
- };
- }
- if (belowLower && (lateWindow || isFalling || isNotRising)) {
- if (beforePeak && !lateWindow) {
- return {
- decision: "watchlist" as const,
- reason: isEn
- ? "The peak window has not arrived, so a still-low METAR path cannot confirm this NO bucket yet; AI keeps it on watch."
- : "峰值窗口尚未到来,METAR 暂未触达不能直接确认 NO 桶,AI 先列观察。",
- evidence,
- };
- }
- return {
- decision: "approve" as const,
- reason: isEn
- ? "METAR max remains below this bucket and recent observations are not strengthening, so AI favors the NO bucket."
- : "METAR 最高仍低于目标桶且近期走势不强,AI 倾向 NO 桶。",
- evidence,
- };
- }
- if (insideBucket && lateWindow && isNotRising) {
- return {
- decision: "downgrade" as const,
- reason: isEn
- ? "METAR max is still close to this bucket, so AI cannot treat the NO bucket as confirmed."
- : "METAR 最高仍贴近目标桶,AI 不能把 NO 桶视为已确认。",
- evidence,
- };
- }
- }
-
- if (side === "yes") {
- if (aboveUpper) {
- return {
- decision: "veto" as const,
- reason: isEn
- ? "METAR max has already exceeded the bucket, so AI marks this YES bucket as outside the observed path."
- : "METAR 实测最高已越过目标桶上沿,AI 判断该 YES 桶已偏离实测路径。",
- evidence,
- };
- }
- if (belowLower && (lateWindow || isFalling || isNotRising)) {
- if (beforePeak && !lateWindow) {
- return {
- decision: "watchlist" as const,
- reason: isEn
- ? "The peak window has not arrived, so METAR not reaching the bucket only means this bucket still needs peak-window confirmation."
- : "峰值窗口尚未到来,METAR 未触达目标桶只能说明仍需等待峰值验证,AI 暂列观察。",
- evidence,
- };
- }
- return {
- decision: "downgrade" as const,
- reason: isEn
- ? "METAR max has not reached the bucket and recent observations are weak, so AI downgrades the YES bucket."
- : "METAR 最高仍未触达目标桶且走势不强,AI 将 YES 桶降级观察。",
- evidence,
- };
- }
- if (insideBucket) {
- return {
- decision: "approve" as const,
- reason: isEn
- ? "METAR max is inside the target bucket, so AI sees observation support while monitoring an overshoot."
- : "METAR 实测最高已落入目标桶,AI 认为 YES 桶有实测依据,但仍需防止继续升穿上沿。",
- evidence,
- };
- }
- }
-
- return {
- decision: "watchlist" as const,
- reason: isEn
- ? "METAR does not give a clean final confirmation yet, so AI keeps this as watchlist."
- : "METAR 还没有给出干净的最终确认,AI 暂列观察。",
- evidence,
- };
-}
-
-function getV4DecisionLabel(
- decision: V4TradeDecision["decision"],
- locale: string,
-) {
- if (locale === "en-US") {
- if (decision === "approve") return "AI Confirmed";
- if (decision === "veto") return "AI Outside";
- if (decision === "downgrade") return "AI Downgrade";
- return "AI Watch";
- }
- if (decision === "approve") return "AI 确认";
- if (decision === "veto") return "AI 区间外";
- if (decision === "downgrade") return "AI 降级";
- return "AI 观察";
-}
-
-function getV4TradeDecision(
- row: ScanOpportunityRow,
- detail: CityDetail | null,
- locale: string,
- edgePercent?: number | null,
- tempSymbol?: string | null,
-): V4TradeDecision {
- const isEn = locale === "en-US";
- const backendMetarDecision = String(row.v4_metar_decision || "").toLowerCase();
- const backendMetarReason =
- getLocalizedRowText(row, locale, row.v4_metar_reason_zh, row.v4_metar_reason_en) ||
- null;
- const metarGate = getMetarGate(row, detail, locale, tempSymbol);
- const aiDecision = String(row.ai_decision || "").toLowerCase();
- const aiReason =
- getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
- getLocalizedRowText(
- row,
- locale,
- row.ai_watchlist_reason_zh,
- row.ai_watchlist_reason_en,
- );
-
- let decision: V4TradeDecision["decision"] =
- backendMetarDecision === "veto" ||
- backendMetarDecision === "downgrade" ||
- backendMetarDecision === "approve" ||
- backendMetarDecision === "watchlist"
- ? (backendMetarDecision as V4TradeDecision["decision"])
- : metarGate?.decision ||
- (aiDecision === "veto" || aiDecision === "downgrade" || aiDecision === "approve" || aiDecision === "watchlist"
- ? (aiDecision as V4TradeDecision["decision"])
- : Number(edgePercent || 0) >= 20
- ? "watchlist"
- : "watchlist");
-
- if (metarGate?.decision === "veto") decision = "veto";
- if (metarGate?.decision === "watchlist" && backendMetarDecision === "downgrade") {
- decision = "watchlist";
- }
- if (metarGate?.decision === "downgrade" && decision !== "veto") decision = "downgrade";
- if (metarGate?.decision === "approve" && decision !== "veto" && decision !== "downgrade") {
- decision = "approve";
- }
-
- const reason =
- (metarGate?.decision === "watchlist" ? metarGate.reason : null) ||
- backendMetarReason ||
- metarGate?.reason ||
- aiReason ||
- (isEn
- ? "AI keeps this on watch until METAR and the full weather-model cluster align."
- : "AI 会等 METAR 与全量天气模型集群对齐后再确认。");
- const airportReport = formatAirportReportRead(
- row,
- detail,
- locale,
- normalizeTemperatureSymbol(tempSymbol),
- );
- const metarSummary =
- metarGate?.evidence?.filter((item) => item !== airportReport).join(" · ") || null;
- return {
- decision,
- label: getV4DecisionLabel(decision, locale),
- tone: decision,
- reason,
- metarSummary,
- airportReport,
- metarEvidence: metarGate?.evidence || [],
- };
-}
-
-function getBucketText(bucket: { label?: string | null; bucket?: string | null; range?: string | null }) {
- return [bucket.label, bucket.bucket, bucket.range]
- .map((value) => String(value || "").trim())
- .filter(Boolean);
-}
-
-function bucketMatchesRow(
- bucket: {
- label?: string | null;
- bucket?: string | null;
- range?: string | null;
- value?: number | string | null;
- },
- row: ScanOpportunityRow,
- tempSymbol?: string | null,
-) {
- const targetLabel = normalizeBucketLabel(row.target_label, tempSymbol);
- const bucketLabels = getBucketText(bucket).map((label) =>
- normalizeBucketLabel(label, tempSymbol),
- );
- if (targetLabel && bucketLabels.some((label) => label === targetLabel)) {
- return true;
- }
-
- const rawTargetLabel = String(row.target_label || "");
- const targetNumbers = extractNumbers(rawTargetLabel);
- const targetValue =
- row.target_value ?? row.target_threshold ?? row.target_lower ?? row.target_upper ?? targetNumbers[0] ?? null;
- if (targetValue == null || !Number.isFinite(Number(targetValue))) return false;
-
- const bucketNumbers = [
- ...(bucket.value != null && Number.isFinite(Number(bucket.value))
- ? [Number(bucket.value)]
- : []),
- ...getBucketText(bucket).flatMap(extractNumbers),
- ];
- const matchesNumber = bucketNumbers.some(
- (value) => Math.abs(Number(value) - Number(targetValue)) < 0.05,
- );
- if (!matchesNumber) return false;
-
- const targetIsUpper =
- /(\+|以上|or\s*above|above|greater|>=|≥)/i.test(rawTargetLabel) ||
- (row.target_lower != null && row.target_upper == null);
- const targetIsLower =
- /(<=|≤|below|or\s*below|以下)/i.test(rawTargetLabel) ||
- (row.target_upper != null && row.target_lower == null);
- const bucketRaw = getBucketText(bucket).join(" ");
- const bucketIsUpper = /(\+|以上|or\s*above|above|greater|>=|≥|inf|∞)/i.test(bucketRaw);
- const bucketIsLower = /(<=|≤|below|or\s*below|以下|-inf|-∞)/i.test(bucketRaw);
-
- if (targetIsUpper || bucketIsUpper) return targetIsUpper === bucketIsUpper;
- if (targetIsLower || bucketIsLower) return targetIsLower === bucketIsLower;
- return true;
-}
-
-function getDetailBucketEventProbability(
- detail: CityDetail | null,
- row: ScanOpportunityRow,
- tempSymbol?: string | null,
-) {
- if (!detail) return null;
- const view = getProbabilityView(detail, getDetailViewDate(detail, row));
- const buckets = Array.isArray(view.probabilitiesAll)
- ? view.probabilitiesAll
- : [];
- if (!buckets.length) return null;
- const matched = buckets.find((bucket) => bucketMatchesRow(bucket, row, tempSymbol));
- return normalizeProbability(matched?.probability);
-}
-
-type OpportunityGroup = {
- key: string;
- cityName: string;
- date?: string | null;
- tempSymbol?: string | null;
- debLabel: string;
- peakLabel: string;
- peakProbability?: number | null;
- phaseMeta: PhaseMeta;
- localTime?: string | null;
- remainingMinutes?: number | null;
- rows: ScanOpportunityRow[];
-};
-
-function buildOpportunityGroups(
- rows: ScanOpportunityRow[],
- locale: string,
- cityDetailsByName?: Record,
-): OpportunityGroup[] {
- const groups = new Map();
- for (const row of rows) {
- const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
- const detail = getDetailForRow(row, cityDetailsByName);
- const cityName = getLocalizedCityName(
- row.city,
- row.city_display_name || row.display_name || row.city,
- locale,
- );
- const date = detail ? getDetailViewDate(detail, row) : row.selected_date || row.local_date || "";
- const key = `${row.city || cityName}|${date}`;
- const modelView = detail ? getModelView(detail, date) : null;
- const debPrediction = modelView?.deb ?? row.deb_prediction ?? null;
- const modelClusterLabel = formatModelClusterRange(
- modelView?.models || row.model_cluster_sources,
- tempSymbol,
- );
- const existing = groups.get(key);
- if (!existing) {
- groups.set(key, {
- key,
- cityName,
- date,
- tempSymbol,
- debLabel:
- debPrediction != null
- ? formatTemperatureValue(Number(debPrediction), tempSymbol, { digits: 1 })
- : "--",
- peakLabel: modelClusterLabel,
- peakProbability: null,
- phaseMeta: getWindowPhaseMeta(row, locale),
- localTime: row.local_time,
- remainingMinutes: row.remaining_window_minutes,
- rows: [row],
- });
- continue;
- }
- existing.rows.push(row);
- if (existing.peakLabel === "--" && modelClusterLabel !== "--") {
- existing.peakLabel = modelClusterLabel;
- }
- }
- return Array.from(groups.values()).map((group) => ({
- ...group,
- rows: [...group.rows].sort(
- (a, b) =>
- Number(b.edge_percent ?? -Infinity) - Number(a.edge_percent ?? -Infinity) ||
- Number(b.final_score ?? -Infinity) - Number(a.final_score ?? -Infinity),
- ),
- }));
-}
-
-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);
-}
+import {
+ buildOpportunityGroups,
+ formatWindowMinutes,
+ getAiMeta,
+ getBucketDisplayLabel,
+ getDebDistanceSummary,
+ getDecisionReasonItems,
+ getDetailForRow,
+ getForecastRangeLabel,
+ getForecastRiskItems,
+ getMetarConflictSummary,
+ getModelSupportSummary,
+ getPaceSignalLabel,
+ getThresholdDecision,
+ getV4CityForecast,
+} from "./OpportunityTable.utils";
+
+export { getWindowPhaseMeta } from "./OpportunityTable.utils";
export const OpportunityTable = React.memo(function OpportunityTable({
rows,
diff --git a/frontend/components/dashboard/OpportunityTable.utils.ts b/frontend/components/dashboard/OpportunityTable.utils.ts
new file mode 100644
index 00000000..c2f30561
--- /dev/null
+++ b/frontend/components/dashboard/OpportunityTable.utils.ts
@@ -0,0 +1,87 @@
+export { getWindowPhaseMeta, type PhaseMeta } from "./opportunity-window-phase";
+export { getLocalizedRowText } from "./opportunity-copy";
+export {
+ getAiMeta,
+ getExclusionReasons,
+ getOpportunityStrength,
+ getRecommendationReasons,
+ getRiskHints,
+ getShortAiConclusion,
+} from "./opportunity-ai-meta";
+export {
+ getDebDistanceSummary,
+ getMetarConflictSummary,
+ getModelSupportSummary,
+} from "./opportunity-evidence-summary";
+export type { V4CityForecast, V4TradeDecision } from "./opportunity-v4-types";
+export {
+ getForecastContractFit,
+ getForecastRangeLabel,
+ getPaceDecisionTail,
+ getPaceDeviationRead,
+ getPaceSignalLabel,
+ getV4CityForecast,
+ median,
+} from "./opportunity-v4-forecast";
+export {
+ getForecastFitMeta,
+ getThresholdDecision,
+ getV4DecisionLabel,
+ getV4TradeDecision,
+} from "./opportunity-v4-decision";
+export {
+ getDecisionReasonItems,
+ getForecastRiskItems,
+} from "./opportunity-v4-risk";
+export {
+ getDetailForRow,
+ getDetailViewDate,
+ normalizeLookupKey,
+} from "./opportunity-detail";
+export {
+ bucketMatchesRow,
+ buildOpportunityGroups,
+ getBucketDisplayLabel,
+ getBucketText,
+ getDetailBucketEventProbability,
+ type OpportunityGroup,
+} from "./opportunity-groups";
+export {
+ formatModelClusterRange,
+ formatModelSources,
+ getModelSourceSummary,
+} from "./opportunity-model-summary";
+export {
+ formatAction,
+ formatMinuteSpan,
+ formatPercent,
+ formatQuoteCents,
+ formatTemperatureDelta,
+ formatThreshold,
+ formatTradeSide,
+ formatWindowMinutes,
+ normalizeProbability,
+} from "./opportunity-format";
+export {
+ extractNumbers,
+ getTargetRange,
+ normalizeBucketLabel,
+} from "./opportunity-target";
+export {
+ formatPeakWindowTiming,
+ getMetarGate,
+ getMetarObservationContext,
+ getObservationSortMinutes,
+ firstNonEmptyPoints,
+ normalizeObservationPoints,
+ type ObservationPoint,
+} from "./opportunity-observation";
+export {
+ decodeMetarWeatherToken,
+ decodeRawMetarCloud,
+ decodeRawMetarVisibility,
+ decodeRawMetarWeather,
+ formatAirportReportRead,
+ formatAirportWeatherRead,
+ getAirportWeatherInputs,
+} from "./opportunity-airport-read";
diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx
index 139b1aab..c18b2bb7 100644
--- a/frontend/components/dashboard/PanelSections.tsx
+++ b/frontend/components/dashboard/PanelSections.tsx
@@ -6,14 +6,8 @@ import { startTransition, useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useCityData, useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
-import {
- CityDetail,
- MarketScan,
- MarketTopBucket,
- ProbabilityBucket,
-} from "@/lib/dashboard-types";
+import { CityDetail } from "@/lib/dashboard-types";
import { getTemperatureChartData } from "@/lib/chart-utils";
-import { getModelView, getProbabilityView } from "@/lib/model-utils";
import {
normalizeObservationSourceCode,
normalizeObservationSourceLabel,
@@ -30,541 +24,6 @@ function EmptyState({ text }: { text: string }) {
);
}
-function toPercent(value?: number | null) {
- if (value == null) return null;
- const numeric = Number(value);
- if (!Number.isFinite(numeric)) return null;
- return `${(numeric * 100).toFixed(1)}%`;
-}
-
-function toPriceCents(value?: number | null) {
- if (value == null) return null;
- const numeric = Number(value);
- if (!Number.isFinite(numeric)) return null;
- const normalized = numeric > 1 ? numeric / 100 : numeric;
- const cents = normalized * 100;
- const rounded = Math.round(cents * 10) / 10;
- const text = Number.isInteger(rounded)
- ? String(rounded.toFixed(0))
- : String(rounded);
- return `${text}c`;
-}
-
-function parseTempFromText(value: unknown) {
- const text = String(value || "");
- const match = text.match(/(-?\d+(?:\.\d+)?)/);
- if (!match) return null;
- const numeric = Number(match[1]);
- return Number.isFinite(numeric) ? numeric : null;
-}
-
-function getBucketTemp(bucket: ProbabilityBucket) {
- if (bucket.value != null) {
- const byValue = Number(bucket.value);
- if (Number.isFinite(byValue)) return byValue;
- }
- return parseTempFromText(bucket.label || bucket.bucket || bucket.range);
-}
-
-function getMarketYesPrice(scan?: MarketScan | null) {
- if (scan?.market_price != null) {
- const preferred = Number(scan.market_price);
- if (Number.isFinite(preferred)) return preferred;
- }
- if (scan?.yes_token?.implied_probability != null) {
- const implied = Number(scan.yes_token.implied_probability);
- if (Number.isFinite(implied)) return implied;
- }
- return null;
-}
-
-function isFahrenheitSymbol(symbol?: string | null) {
- return String(symbol || "")
- .toUpperCase()
- .includes("F");
-}
-
-function displayTempToMarketCelsius(
- value: number | null,
- detail: Pick,
-) {
- if (value == null || !Number.isFinite(value)) return null;
- if (isFahrenheitSymbol(detail.temp_symbol)) {
- return ((value - 32) * 5) / 9;
- }
- return value;
-}
-
-function formatBucketDisplayLabel(
- bucket: ProbabilityBucket,
- detail: Pick,
-) {
- let bucketLabel = bucket.label || `${bucket.value}${detail.temp_symbol}`;
- if (!bucketLabel) return "";
- let str = String(bucketLabel).toUpperCase().replace(/\s+/g, "");
- const symbol = detail.temp_symbol || "°C";
- if (isFahrenheitSymbol(symbol)) {
- str = str.replace(/℃/g, "°F").replace(/°C/g, "°F");
- } else {
- str = str.replace(/℃/g, "°C").replace(/°F/g, "°C");
- }
- str = str.replace(/°?C($|\+|-)/g, "°C$1");
- str = str.replace(/°?F($|\+|-)/g, "°F$1");
- if (!/[°℃][CF]/.test(str) && /[0-9]/.test(str)) {
- str += symbol;
- }
- return str;
-}
-
-function getMarketBucketUnit(bucket?: MarketTopBucket | null) {
- return String(bucket?.unit || "").toUpperCase();
-}
-
-function isMarketBucketAbove(bucket?: MarketTopBucket | null) {
- const text =
- `${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
- .toLowerCase()
- .replace(/\s+/g, "");
- return (
- text.includes("+") ||
- text.includes("orhigher") ||
- text.includes("or-higher")
- );
-}
-
-function isMarketBucketBelow(bucket?: MarketTopBucket | null) {
- const text =
- `${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
- .toLowerCase()
- .replace(/\s+/g, "");
- return (
- text.includes("<=") || text.includes("orlower") || text.includes("or-lower")
- );
-}
-
-function findMarketBucketForDisplayTemp(
- buckets: MarketTopBucket[],
- displayTemp: number | null,
- detail: Pick,
-) {
- if (displayTemp == null || !Number.isFinite(displayTemp)) return null;
-
- let best: MarketTopBucket | null = null;
- let bestDelta = Number.POSITIVE_INFINITY;
- for (const bucket of buckets) {
- const bucketUnit = String(bucket.unit || "").toUpperCase();
- const compareTemp =
- bucketUnit === "F"
- ? displayTemp
- : displayTempToMarketCelsius(displayTemp, detail);
- if (compareTemp == null) continue;
-
- const lower = bucket.lower != null ? Number(bucket.lower) : null;
- const upper = bucket.upper != null ? Number(bucket.upper) : null;
- if (
- lower != null &&
- upper != null &&
- Number.isFinite(lower) &&
- Number.isFinite(upper) &&
- compareTemp >= lower - 0.01 &&
- compareTemp <= upper + 0.01
- ) {
- return bucket;
- }
-
- const rawTemp = bucket.temp ?? bucket.value ?? null;
- if (rawTemp == null) continue;
- const candidateTemp = Number(rawTemp);
- if (!Number.isFinite(candidateTemp)) continue;
- const delta = Math.abs(candidateTemp - compareTemp);
- if (delta < bestDelta) {
- best = bucket;
- bestDelta = delta;
- }
- }
- const tolerance = isFahrenheitSymbol(detail.temp_symbol) ? 0.56 : 0.26;
- return best && bestDelta <= tolerance ? best : null;
-}
-
-function marketBucketContainsDisplayTemp(
- bucket: MarketTopBucket | null,
- displayTemp: number | null,
- detail: Pick,
-) {
- if (!bucket || displayTemp == null || !Number.isFinite(displayTemp))
- return false;
-
- const bucketUnit = getMarketBucketUnit(bucket);
- const compareTemp =
- bucketUnit === "F"
- ? displayTemp
- : displayTempToMarketCelsius(displayTemp, detail);
- if (compareTemp == null) return false;
-
- const lower = bucket.lower != null ? Number(bucket.lower) : null;
- const upper = bucket.upper != null ? Number(bucket.upper) : null;
- if (lower != null && !Number.isFinite(lower)) return false;
- if (upper != null && !Number.isFinite(upper)) return false;
-
- if (lower != null && upper != null) {
- return compareTemp >= lower - 0.01 && compareTemp <= upper + 0.01;
- }
- if (lower != null && isMarketBucketAbove(bucket)) {
- return compareTemp >= lower - 0.01;
- }
- if (lower != null && isMarketBucketBelow(bucket)) {
- return compareTemp <= lower + 0.01;
- }
- const reference = bucket.temp ?? bucket.value ?? lower;
- const numeric = reference != null ? Number(reference) : null;
- if (numeric == null || !Number.isFinite(numeric)) return false;
- const tolerance = bucketUnit === "F" ? 0.56 : 0.26;
- return Math.abs(compareTemp - numeric) <= tolerance;
-}
-
-function getAggregatedModelProbabilityForMarketBucket(
- probabilities: ProbabilityBucket[],
- bucket: MarketTopBucket | null,
- detail: Pick,
-) {
- if (!bucket) return null;
-
- let total = 0;
- let matched = 0;
- for (const probabilityBucket of probabilities) {
- const temp = getBucketTemp(probabilityBucket);
- if (!marketBucketContainsDisplayTemp(bucket, temp, detail)) continue;
- const probability = Number(probabilityBucket.probability);
- if (!Number.isFinite(probability)) continue;
- total += probability;
- matched += 1;
- }
-
- return matched > 0 ? Math.max(0, Math.min(1, total)) : null;
-}
-
-type ProbabilityDisplayRow = {
- key: string;
- label: string;
- probability: number;
- marketBucket?: MarketTopBucket | null;
-};
-
-function formatMarketBucketDisplayLabel(
- bucket: MarketTopBucket,
- detail: Pick,
-) {
- const label = String(bucket.label || "").trim();
- if (label) {
- const unit = getMarketBucketUnit(bucket);
- let normalized = label.toUpperCase().replace(/\s+/g, "");
- if (unit === "F" || isFahrenheitSymbol(detail.temp_symbol)) {
- normalized = normalized
- .replace(/ORHIGHER/g, "+")
- .replace(/ORLOWER/g, "-")
- .replace(/℃/g, "°F")
- .replace(/°C/g, "°F")
- .replace(/(?<=\d)F/g, "°F");
- } else {
- normalized = normalized
- .replace(/ORHIGHER/g, "+")
- .replace(/ORLOWER/g, "-")
- .replace(/℃/g, "°C")
- .replace(/°F/g, "°C")
- .replace(/(?<=\d)C/g, "°C");
- }
- return normalized.replace(/\+/g, "+");
- }
-
- const unit =
- getMarketBucketUnit(bucket) === "F" ||
- isFahrenheitSymbol(detail.temp_symbol)
- ? "°F"
- : "°C";
- const lower = bucket.lower != null ? Number(bucket.lower) : null;
- const upper = bucket.upper != null ? Number(bucket.upper) : null;
- if (
- lower != null &&
- upper != null &&
- Number.isFinite(lower) &&
- Number.isFinite(upper)
- ) {
- return `${lower}-${upper}${unit}`;
- }
- const value = bucket.value ?? bucket.temp ?? lower;
- const numeric = value != null ? Number(value) : null;
- if (numeric != null && Number.isFinite(numeric)) {
- return isMarketBucketAbove(bucket)
- ? `${numeric}${unit}+`
- : `${numeric}${unit}`;
- }
- return "--";
-}
-
-type ModelMetadata = NonNullable<
- NonNullable["open_meteo_multi_model"]
->["model_metadata"];
-
-function getModelGroupMeta(
- name: string,
- metadata: ModelMetadata,
- locale: string,
-) {
- const meta = metadata?.[name] || {};
- const tier = String(meta.tier || "").toLowerCase();
- const upperName = String(name || "").toUpperCase();
-
- if (tier.includes("aifs") || upperName.includes("AIFS")) {
- return {
- key: "aifs",
- label: locale === "en-US" ? "AIFS model" : "AIFS 模型",
- order: 1,
- tone: "blue",
- };
- }
- if (
- tier.includes("europe") ||
- upperName.includes("ICON-EU") ||
- upperName.includes("ICON-D2")
- ) {
- return {
- key: "europe",
- label: locale === "en-US" ? "Europe high-resolution" : "欧洲高分辨率",
- order: 2,
- tone: "cyan",
- };
- }
- if (
- tier.includes("north_america") ||
- upperName === "RDPS" ||
- upperName === "HRDPS"
- ) {
- return {
- key: "north-america",
- label:
- locale === "en-US" ? "North America high-resolution" : "北美高分辨率",
- order: 3,
- tone: "amber",
- };
- }
- return {
- key: "global",
- label: locale === "en-US" ? "Global baseline" : "全球基准",
- order: 0,
- tone: "neutral",
- };
-}
-
-function formatModelMetaLine(
- name: string,
- metadata: ModelMetadata,
- locale: string,
-) {
- const meta = metadata?.[name] || {};
- const provider = String(meta.provider || "").trim();
- const model = String(meta.model || "").trim();
- const horizon = String(meta.horizon || "").trim();
- const resolution = Number(meta.resolution_km);
- const parts = [
- provider,
- model && model !== name ? model : "",
- Number.isFinite(resolution)
- ? `${resolution}${locale === "en-US" ? " km" : " 公里"}`
- : "",
- horizon,
- ].filter(Boolean);
- return parts.join(" · ");
-}
-
-function normalizeModelNameForVote(name: string) {
- return String(name || "")
- .trim()
- .toLowerCase()
- .replace(/[\s_/-]/g, "");
-}
-
-function getModelVoteFamily(name: string) {
- const normalized = normalizeModelNameForVote(name);
- if (["icon", "iconeu", "icond2"].includes(normalized)) return "dwd_icon";
- if (["gem", "gdps", "rdps", "hrdps"].includes(normalized)) return "eccc_gem";
- if (["ecmwfaifs", "aifs"].includes(normalized)) return "ecmwf_aifs";
- if (normalized === "ecmwf") return "ecmwf_ifs";
- return normalized || name;
-}
-
-function getModelVotePriority(name: string) {
- const normalized = normalizeModelNameForVote(name);
- return (
- {
- icond2: 40,
- iconeu: 30,
- icon: 20,
- hrdps: 40,
- rdps: 35,
- gdps: 30,
- gem: 20,
- ecmwfaifs: 30,
- ecmwf: 30,
- gfs: 30,
- jma: 30,
- mgm: 45,
- nws: 45,
- openmeteo: 15,
- }[normalized] || 10
- );
-}
-
-function getRoundedModelVoteDistribution(
- detail: CityDetail,
- targetDate?: string | null,
-) {
- const view = getModelView(detail, targetDate);
- const representatives = new Map<
- string,
- { name: string; priority: number; value: number }
- >();
-
- Object.entries(view.models || {}).forEach(([name, rawValue]) => {
- const normalized = normalizeModelNameForVote(name);
- if (normalized === "lgbm" || normalized.includes("meteoblue")) return;
- const value = Number(rawValue);
- if (!Number.isFinite(value)) return;
- const family = getModelVoteFamily(name);
- const priority = getModelVotePriority(name);
- const current = representatives.get(family);
- if (!current || priority > current.priority) {
- representatives.set(family, { name, priority, value });
- }
- });
-
- const bucketMap = new Map();
- representatives.forEach(({ name, value }) => {
- const rounded = Math.round(value);
- const row = bucketMap.get(rounded) || { count: 0, models: [] };
- row.count += 1;
- row.models.push(name);
- bucketMap.set(rounded, row);
- });
-
- const total = representatives.size;
- const rows = Array.from(bucketMap.entries())
- .map(([value, row]) => ({
- count: row.count,
- models: row.models,
- percent: total > 0 ? row.count / total : 0,
- value,
- }))
- .sort((a, b) => b.count - a.count || b.value - a.value);
-
- return {
- rows,
- total,
- };
-}
-
-function normalizeMarketProbability(value?: number | null) {
- if (value == null) return null;
- const numeric = Number(value);
- if (!Number.isFinite(numeric)) return null;
- if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
- return Math.max(0, Math.min(1, numeric));
-}
-
-function normalizeSignedProbability(value?: number | null) {
- if (value == null) return null;
- const numeric = Number(value);
- if (!Number.isFinite(numeric)) return null;
- if (Math.abs(numeric) > 1) return numeric / 100;
- return numeric;
-}
-
-function formatSignedPercent(value?: number | null, digits = 1) {
- const normalized = normalizeSignedProbability(value);
- if (normalized == null) return "--";
- const percent = normalized * 100;
- const sign = percent > 0 ? "+" : "";
- return `${sign}${percent.toFixed(digits)}%`;
-}
-
-function getMarketTopBuckets(scan?: MarketScan | null) {
- const buckets = Array.isArray(scan?.top_buckets) ? scan.top_buckets : [];
- if (!buckets.length) return [];
-
- return buckets
- .map((item) => ({
- ...item,
- probability: normalizeMarketProbability(item.probability),
- }))
- .filter(
- (item): item is MarketTopBucket & { probability: number } =>
- item.probability != null,
- );
-}
-
-function getMarketAllBuckets(scan?: MarketScan | null) {
- const buckets = Array.isArray(scan?.all_buckets)
- ? scan.all_buckets
- : Array.isArray(scan?.top_buckets)
- ? scan.top_buckets
- : [];
- if (!buckets.length) return [];
-
- return buckets
- .map((item) => ({
- ...item,
- probability: normalizeMarketProbability(item.probability),
- }))
- .filter(
- (item): item is MarketTopBucket & { probability: number } =>
- item.probability != null,
- );
-}
-
-function getMarketTopBucketKey(bucket: MarketTopBucket) {
- if (bucket?.value != null) {
- const valueNum = Number(bucket.value);
- if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`;
- }
-
- if (bucket?.temp != null) {
- const tempNum = Number(bucket.temp);
- if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`;
- }
-
- const parsed = parseTempFromText(bucket?.label);
- if (parsed != null) return `l:${parsed.toFixed(2)}`;
-
- return `s:${String(bucket?.slug || bucket?.question || bucket?.label || "")}`;
-}
-
-function hasLgbmModel(detail: CityDetail, targetDate?: string | null) {
- const view = getModelView(detail, targetDate);
- return Object.keys(view.models || {}).some((name) =>
- normalizeModelNameForVote(name).includes("lgbm"),
- );
-}
-
-function formatProbabilityEngineLabel(
- detail: CityDetail,
- targetDate: string | null | undefined,
- locale: string,
-) {
- const view = getProbabilityView(detail, targetDate);
- if (hasLgbmModel(detail, targetDate)) {
- return locale === "en-US" ? "LGBM-calibrated probability" : "LGBM 校准概率";
- }
- const engine = String(view.engine || "")
- .trim()
- .toLowerCase();
- const calibrationMode = String(view.calibrationMode || "")
- .trim()
- .toLowerCase();
- if (engine === "emos" || calibrationMode.includes("emos")) {
- return locale === "en-US" ? "EMOS-calibrated probability" : "EMOS 校准概率";
- }
- return locale === "en-US" ? "Model probability" : "模型概率";
-}
-
export function HeroSummary() {
const { data } = useCityData();
const { locale } = useI18n();
@@ -819,672 +278,9 @@ export function TemperatureChart() {
);
}
-export function ProbabilityDistribution({
- detail,
- hideTitle = false,
- targetDate,
- marketScan,
-}: {
- detail: CityDetail;
- hideTitle?: boolean;
- targetDate?: string | null;
- marketScan?: MarketScan | null;
-}) {
- const { locale, t } = useI18n();
- const view = getProbabilityView(detail, targetDate);
- const modelView = getModelView(detail, targetDate);
- const marketYesPrice = getMarketYesPrice(marketScan);
- const marketYesText = toPercent(marketYesPrice);
- const isToday = !targetDate || targetDate === detail.local_date;
- const probabilityEngineLabel = formatProbabilityEngineLabel(
- detail,
- targetDate,
- locale,
- );
- const hasLgbmProbability = hasLgbmModel(detail, targetDate);
- const modelVoteView = useMemo(
- () => getRoundedModelVoteDistribution(detail, targetDate),
- [detail, targetDate],
- );
- const modelVoteHint = modelVoteView.rows
- .slice(0, 2)
- .map(
- (row) =>
- `${row.value}${detail.temp_symbol} ${row.count}/${modelVoteView.total}`,
- )
- .join(" · ");
- const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
- const marketAllBuckets = isToday ? getMarketAllBuckets(marketScan) : [];
- const sortedMarketTopBuckets = useMemo(() => {
- const sorted = [...marketTopBuckets].sort(
- (a, b) => Number(b.probability || 0) - Number(a.probability || 0),
- );
- const deduped: Array = [];
- const seenKeys = new Set();
- for (const row of sorted) {
- const key = getMarketTopBucketKey(row);
- if (seenKeys.has(key)) continue;
- seenKeys.add(key);
- deduped.push(row);
- if (deduped.length >= 4) break;
- }
- return deduped;
- }, [marketTopBuckets]);
- const useMarketTopBuckets =
- marketScan?.available && sortedMarketTopBuckets.length >= 2;
- const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability);
- const topProbability = [...(view.probabilities || [])].sort(
- (a, b) => Number(b.probability || 0) - Number(a.probability || 0),
- )[0];
- const topProbabilityText = toPercent(topProbability?.probability);
- const topProbabilityLabel = topProbability
- ? formatBucketDisplayLabel(topProbability, detail)
- : null;
- const topProbabilityTemp = topProbability
- ? getBucketTemp(topProbability)
- : null;
- const probabilitiesForMarketContracts =
- view.probabilitiesAll?.length > 0
- ? view.probabilitiesAll
- : view.probabilities || [];
- const marketContractRows = useMemo(() => {
- if (!isToday || !marketScan?.available || marketAllBuckets.length === 0) {
- return [];
- }
+export { ProbabilityDistribution } from "./ProbabilityDistribution";
- const rows: ProbabilityDisplayRow[] = [];
- const seenKeys = new Set();
- for (const marketBucket of marketAllBuckets) {
- const probability = getAggregatedModelProbabilityForMarketBucket(
- probabilitiesForMarketContracts,
- marketBucket,
- detail,
- );
-
- const key =
- marketBucket.slug ||
- marketBucket.label ||
- `${marketBucket.lower ?? marketBucket.value ?? marketBucket.temp}-${marketBucket.upper ?? ""}`;
- if (seenKeys.has(key)) continue;
- seenKeys.add(key);
- rows.push({
- key,
- label: formatMarketBucketDisplayLabel(marketBucket, detail),
- probability: probability ?? 0,
- marketBucket,
- });
- }
- return rows;
- }, [
- detail,
- isToday,
- marketAllBuckets,
- marketScan?.available,
- probabilitiesForMarketContracts,
- ]);
- const modelProbabilityRows = useMemo(
- () =>
- (view.probabilities || []).slice(0, 6).map((bucket, index) => {
- const bucketTemp = getBucketTemp(bucket);
- return {
- key: `${bucket.label || bucket.value || index}`,
- label: formatBucketDisplayLabel(bucket, detail),
- probability: Number(bucket.probability || 0),
- marketBucket: findMarketBucketForDisplayTemp(
- marketAllBuckets,
- bucketTemp,
- detail,
- ),
- };
- }),
- [detail, marketAllBuckets, view.probabilities],
- );
- const probabilityRows =
- marketContractRows.length > 0
- ? marketContractRows.slice(0, 8)
- : modelProbabilityRows;
- const topContractRow =
- marketContractRows.length > 0
- ? marketContractRows.reduce((best, row) =>
- row.probability > best.probability ? row : best,
- )
- : null;
- const displayTopLabel = topContractRow?.label || topProbabilityLabel || null;
- const displayTopProbability =
- topContractRow?.probability ??
- (topProbability?.probability != null
- ? Number(topProbability.probability)
- : null);
- const displayTopProbabilityText = toPercent(displayTopProbability);
- const displayUsesMarketBuckets = marketContractRows.length > 0;
- const linkedMarketBucket = useMemo(() => {
- if (topContractRow?.marketBucket) return topContractRow.marketBucket;
- if (topProbabilityTemp == null) return null;
- return findMarketBucketForDisplayTemp(
- marketAllBuckets,
- topProbabilityTemp,
- detail,
- );
- }, [detail, marketAllBuckets, topContractRow, topProbabilityTemp]);
- const priceAnalysis = marketScan?.price_analysis;
- const yesPriceView = priceAnalysis?.yes;
- const noPriceView = priceAnalysis?.no;
- const linkedMarketAsk =
- linkedMarketBucket?.yes_buy ??
- linkedMarketBucket?.market_price ??
- yesPriceView?.ask ??
- null;
- const linkedNoAsk = linkedMarketBucket?.no_buy ?? noPriceView?.ask ?? null;
- const linkedContractLabel =
- topContractRow?.label ||
- (linkedMarketBucket
- ? formatMarketBucketDisplayLabel(linkedMarketBucket, detail)
- : null) ||
- topProbabilityLabel ||
- null;
- const aggregatedMarketProbability =
- getAggregatedModelProbabilityForMarketBucket(
- probabilitiesForMarketContracts,
- linkedMarketBucket,
- detail,
- );
- const linkedMarketProbability =
- topContractRow?.probability ??
- aggregatedMarketProbability ??
- (topProbability?.probability != null
- ? Number(topProbability.probability)
- : null);
- const linkedMarketProbabilityText = toPercent(linkedMarketProbability);
- const linkedMarketEdge =
- linkedMarketProbability != null && linkedMarketAsk != null
- ? linkedMarketProbability - Number(linkedMarketAsk)
- : null;
- const linkedNoEdge =
- linkedMarketProbability != null && linkedNoAsk != null
- ? 1 - linkedMarketProbability - Number(linkedNoAsk)
- : null;
- const linkedBestSide =
- linkedMarketBucket && linkedNoEdge != null && linkedMarketEdge != null
- ? linkedNoEdge > linkedMarketEdge
- ? "no"
- : "yes"
- : null;
- const linkedBestAsk = linkedBestSide === "no" ? linkedNoAsk : linkedMarketAsk;
- const linkedBestEdge =
- linkedBestSide === "no" ? linkedNoEdge : linkedMarketEdge;
- const preferredPriceView = linkedMarketBucket
- ? {
- ask: linkedBestAsk,
- edge: linkedBestEdge,
- }
- : priceAnalysis?.best_side === "no"
- ? noPriceView
- : yesPriceView;
- const preferredSideLabel = linkedMarketBucket
- ? linkedBestSide === "no"
- ? "NO"
- : "YES"
- : priceAnalysis?.best_side === "no"
- ? locale === "en-US"
- ? "NO"
- : "NO"
- : locale === "en-US"
- ? "YES"
- : "YES";
- const yesDisplayPrice = linkedMarketBucket
- ? linkedMarketAsk
- : yesPriceView?.ask;
- const noDisplayPrice = linkedMarketBucket ? linkedNoAsk : noPriceView?.ask;
- const yesDisplayEdge = linkedMarketBucket
- ? linkedMarketEdge
- : yesPriceView?.edge;
- const noDisplayEdge = linkedMarketBucket ? linkedNoEdge : noPriceView?.edge;
- const hasPriceAnalysis =
- isToday &&
- (Boolean(priceAnalysis?.available) ||
- Boolean(marketScan) ||
- Boolean(topProbability));
- const lockEdge = normalizeSignedProbability(priceAnalysis?.lock?.edge);
- const lockAvailable = Boolean(
- priceAnalysis?.lock?.available && lockEdge != null,
- );
- const quoteSource =
- linkedMarketBucket?.quote_source ||
- marketScan?.yes_token?.quote_source ||
- marketScan?.no_token?.quote_source ||
- null;
- const quoteAgeMs =
- linkedMarketBucket?.quote_age_ms ??
- marketScan?.yes_token?.quote_age_ms ??
- marketScan?.no_token?.quote_age_ms;
- const quoteSourceLabel =
- quoteSource === "polymarket_ws"
- ? locale === "en-US"
- ? `WS live${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}s` : ""}`
- : `WS 实时${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}秒` : ""}`
- : locale === "en-US"
- ? "CLOB fallback"
- : "CLOB 兜底";
- const actionableEdge = normalizeSignedProbability(preferredPriceView?.edge);
- const linkedContractOverpriced =
- Boolean(linkedMarketBucket) &&
- linkedBestSide === "no" &&
- linkedMarketProbability != null &&
- linkedMarketAsk != null &&
- linkedMarketEdge != null &&
- linkedMarketEdge < 0 &&
- linkedNoEdge != null &&
- linkedNoEdge > 0;
- const linkedContractOverpay =
- linkedContractOverpriced &&
- linkedMarketProbability != null &&
- linkedMarketAsk != null
- ? Number(linkedMarketAsk) - linkedMarketProbability
- : null;
- const actionText = !marketScan
- ? locale === "en-US"
- ? "Waiting"
- : "等待"
- : !marketScan.available
- ? locale === "en-US"
- ? "No market"
- : "无盘口"
- : actionableEdge == null
- ? locale === "en-US"
- ? "No quote"
- : "无报价"
- : actionableEdge >= 0.02
- ? linkedContractOverpriced
- ? locale === "en-US"
- ? "Overpriced"
- : "市场偏贵"
- : locale === "en-US"
- ? `Watch ${preferredSideLabel}`
- : `可关注 ${preferredSideLabel}`
- : actionableEdge > 0
- ? linkedContractOverpriced
- ? locale === "en-US"
- ? "Slightly overpriced"
- : "略偏贵"
- : locale === "en-US"
- ? `Small ${preferredSideLabel}`
- : `${preferredSideLabel} 优势较小`
- : locale === "en-US"
- ? "No clear edge"
- : "暂无优势";
- const actionNote =
- linkedContractOverpriced && linkedContractOverpay != null
- ? locale === "en-US"
- ? `YES above model by ${formatSignedPercent(linkedContractOverpay)}`
- : `YES 高于模型 ${formatSignedPercent(linkedContractOverpay)}`
- : actionableEdge != null && actionableEdge >= 0.02
- ? locale === "en-US"
- ? `${formatSignedPercent(actionableEdge)} vs ask`
- : `相对买价 ${formatSignedPercent(actionableEdge)}`
- : locale === "en-US"
- ? `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`
- : `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`;
-
- return (
-
- {!hideTitle && {t("section.probability")} }
-
-
-
- {probabilityEngineLabel}
-
- {displayTopLabel && displayTopProbabilityText
- ? locale === "en-US"
- ? displayUsesMarketBuckets
- ? `${displayTopLabel} is the top displayed contract bucket at ${displayTopProbabilityText}`
- : `${displayTopLabel} is the top single bucket at ${displayTopProbabilityText}`
- : displayUsesMarketBuckets
- ? `${displayTopLabel} 为当前显示分布最高,${displayTopProbabilityText}`
- : `${displayTopLabel} 单点最高,${displayTopProbabilityText}`
- : locale === "en-US"
- ? "Awaiting calibrated buckets"
- : "等待校准概率桶"}
-
-
-
- {hasLgbmProbability
- ? locale === "en-US"
- ? "LGBM is the learned intraday adjustment; raw model points below are only diagnostic."
- : "LGBM 作为日内学习校准项;下方原始模型落点仅用于诊断。"
- : locale === "en-US"
- ? "Using the calibrated probability distribution; raw model points below are not probabilities."
- : "使用校准后的概率分布;下方原始模型落点不是概率。"}
-
-
- {marketScan?.available && (topMarketBucketText || marketYesText) && (
-
- {useMarketTopBuckets
- ? locale === "en-US"
- ? `Market reference only: top traded bucket ${topMarketBucketText}`
- : `市场仅作参考:最高交易温度桶 ${topMarketBucketText}`
- : locale === "en-US"
- ? `Market reference only: this bucket ${marketYesText}`
- : `市场仅作参考:该温度桶 ${marketYesText}`}
-
- )}
-
-
-
- {locale === "en-US"
- ? "EMOS probability distribution"
- : "EMOS 概率分布"}
-
-
- {marketContractRows.length > 0
- ? locale === "en-US"
- ? "market buckets are aggregated from single-degree EMOS buckets"
- : "市场合约桶由单点 EMOS 概率聚合"
- : locale === "en-US"
- ? "calibrated temperature buckets"
- : "校准后的温度桶"}
-
-
- {probabilityRows.length === 0 ? (
-
- ) : (
- probabilityRows.map((row, index) => {
- const probability = Math.round(
- Number(row.probability || 0) * 100,
- );
-
- return (
-
- );
- })
- )}
-
- {hasPriceAnalysis && (
-
-
-
- {locale === "en-US" ? "Win-rate reference" : "胜率参考"}
-
-
- {!marketScan
- ? locale === "en-US"
- ? "Waiting for market context"
- : "等待市场参照"
- : !marketScan.available
- ? locale === "en-US"
- ? "No matched active market"
- : "未匹配到活跃盘口"
- : locale === "en-US"
- ? `${linkedContractLabel || topProbabilityLabel || "Temperature bucket"} · model ${linkedMarketProbabilityText || topProbabilityText || "--"}`
- : `${linkedContractLabel || topProbabilityLabel || "温度桶"} · 模型 ${linkedMarketProbabilityText || topProbabilityText || "--"}`}
-
-
-
-
-
- {locale === "en-US" ? "Bucket" : "温度桶"}
-
-
- {linkedContractLabel || topProbabilityLabel || "--"}
-
-
- {linkedMarketProbabilityText || topProbabilityText || "--"}
-
-
-
- {locale === "en-US" ? "DEB" : "DEB"}
-
- {modelView.deb != null && Number.isFinite(Number(modelView.deb))
- ? `${Number(modelView.deb).toFixed(1)}${detail.temp_symbol}`
- : "--"}
-
- {locale === "en-US" ? "final fused forecast" : "最终融合预测"}
-
-
- {locale === "en-US" ? "Model support" : "模型支持"}
- {modelVoteHint || "--"}
- {locale === "en-US" ? "raw model agreement" : "原始模型一致性"}
-
-
- {locale === "en-US" ? "Market role" : "盘口角色"}
- {locale === "en-US" ? "Reference only" : "仅作参考"}
- {quoteSourceLabel}
-
-
-
- {locale === "en-US"
- ? "This card follows the same rule as AI forecast: DEB first, model agreement second, METAR conflict check before settlement."
- : "该卡片与 AI 预测口径一致:先看 DEB,再看模型支持,最后检查 METAR 是否冲突。"}
-
-
- )}
- {modelVoteHint && (
-
-
- {locale === "en-US" ? "Raw model points" : "原始模型落点"}
-
- {modelVoteHint}
-
- {locale === "en-US"
- ? "diagnostic only; EMOS and contract rows use calibrated probabilities"
- : "仅作诊断;EMOS 与合约行使用校准概率"}
-
-
- )}
-
-
- );
-}
-
-export function ModelForecast({
- detail,
- hideTitle = false,
- targetDate,
-}: {
- detail: CityDetail;
- hideTitle?: boolean;
- targetDate?: string | null;
-}) {
- const { locale, t } = useI18n();
- const view = getModelView(detail, targetDate);
- const modelsMap = { ...view.models };
- const modelMetadata =
- detail.source_forecasts?.open_meteo_multi_model?.model_metadata || {};
-
- const modelEntries = Object.entries(modelsMap).filter(
- ([, value]) =>
- value !== null && value !== undefined && Number.isFinite(Number(value)),
- );
- const hasSingleModelOnly = modelEntries.length === 1;
-
- // 如果没有任何数值,给出提示
- if (modelEntries.length === 0) {
- return (
-
- {!hideTitle && {t("section.models")} }
-
-
-
-
- );
- }
-
- const numericValues = modelEntries.map(([, value]) => Number(value));
- const comparisonValues =
- view.deb != null ? [...numericValues, Number(view.deb)] : numericValues;
- const minValue = comparisonValues.length
- ? Math.min(...comparisonValues) - 1
- : 0;
- const maxValue = comparisonValues.length
- ? Math.max(...comparisonValues) + 1
- : 1;
- const range = Math.max(maxValue - minValue, 1);
- const sortedEntries = modelEntries.sort(
- (a, b) => Number(b[1] || 0) - Number(a[1] || 0),
- );
- const groupedEntries = sortedEntries
- .reduce(
- (acc, [name, value]) => {
- const group = getModelGroupMeta(name, modelMetadata, locale);
- const existing = acc.find((item) => item.key === group.key);
- const entry = {
- metaLine: formatModelMetaLine(name, modelMetadata, locale),
- name,
- value: Number(value),
- };
- if (existing) {
- existing.entries.push(entry);
- } else {
- acc.push({ ...group, entries: [entry] });
- }
- return acc;
- },
- [] as Array<{
- entries: Array<{ metaLine: string; name: string; value: number }>;
- key: string;
- label: string;
- order: number;
- tone: string;
- }>,
- )
- .sort((a, b) => a.order - b.order);
- const spread =
- numericValues.length >= 2
- ? Math.max(...numericValues) - Math.min(...numericValues)
- : null;
- const metadataSource =
- detail.source_forecasts?.open_meteo_multi_model?.provider === "open-meteo"
- ? "Open-Meteo"
- : null;
-
- return (
-
- {!hideTitle && {t("section.models")} }
-
-
-
- {locale === "en-US" ? "Available models" : "可用模型"} ·{" "}
- {modelEntries.length}
-
-
- {locale === "en-US" ? "Spread" : "分歧"} ·{" "}
-
- {spread != null
- ? `${spread.toFixed(1)}${detail.temp_symbol}`
- : "--"}
-
-
- {metadataSource && (
-
- {locale === "en-US" ? "API" : "接口"} ·{" "}
- {metadataSource}
-
- )}
-
- {hasSingleModelOnly && (
-
- {locale === "en-US"
- ? "Single-model fallback: waiting for the rest of the model cluster."
- : "当前处于单模型回退,其他模型结果还没回传。"}
-
- )}
- {groupedEntries.map((group) => (
-
-
- {group.label}
- {group.entries.length}
-
- {group.entries.map(({ metaLine, name, value }) => {
- const width = ((value - minValue) / range) * 100;
- const debLine =
- view.deb != null
- ? ((Number(view.deb) - minValue) / range) * 100
- : null;
-
- return (
-
-
- {name}
- {metaLine && {metaLine} }
-
-
-
-
- {value}
- {detail.temp_symbol}
-
- {debLine != null && (
-
- )}
-
-
- );
- })}
-
- ))}
- {view.deb != null && (
-
-
- DEB
-
-
-
-
- {Number(view.deb)}
- {detail.temp_symbol}
-
-
-
- )}
-
-
- );
-}
+export { ModelForecast } from "./ModelForecast";
export function ForecastTable() {
const store = useDashboardStore();
diff --git a/frontend/components/dashboard/ProbabilityDistribution.tsx b/frontend/components/dashboard/ProbabilityDistribution.tsx
new file mode 100644
index 00000000..ad5eba5c
--- /dev/null
+++ b/frontend/components/dashboard/ProbabilityDistribution.tsx
@@ -0,0 +1,956 @@
+"use client";
+
+import clsx from "clsx";
+import { useMemo } from "react";
+import { useI18n } from "@/hooks/useI18n";
+import {
+ CityDetail,
+ MarketScan,
+ MarketTopBucket,
+ ProbabilityBucket,
+} from "@/lib/dashboard-types";
+import { getModelView, getProbabilityView } from "@/lib/model-utils";
+
+function EmptyState({ text }: { text: string }) {
+ return (
+ {text}
+ );
+}
+
+function toPercent(value?: number | null) {
+ if (value == null) return null;
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) return null;
+ return `${(numeric * 100).toFixed(1)}%`;
+}
+
+function toPriceCents(value?: number | null) {
+ if (value == null) return null;
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) return null;
+ const normalized = numeric > 1 ? numeric / 100 : numeric;
+ const cents = normalized * 100;
+ const rounded = Math.round(cents * 10) / 10;
+ const text = Number.isInteger(rounded)
+ ? String(rounded.toFixed(0))
+ : String(rounded);
+ return `${text}c`;
+}
+
+function parseTempFromText(value: unknown) {
+ const text = String(value || "");
+ const match = text.match(/(-?\d+(?:\.\d+)?)/);
+ if (!match) return null;
+ const numeric = Number(match[1]);
+ return Number.isFinite(numeric) ? numeric : null;
+}
+
+function getBucketTemp(bucket: ProbabilityBucket) {
+ if (bucket.value != null) {
+ const byValue = Number(bucket.value);
+ if (Number.isFinite(byValue)) return byValue;
+ }
+ return parseTempFromText(bucket.label || bucket.bucket || bucket.range);
+}
+
+function getMarketYesPrice(scan?: MarketScan | null) {
+ if (scan?.market_price != null) {
+ const preferred = Number(scan.market_price);
+ if (Number.isFinite(preferred)) return preferred;
+ }
+ if (scan?.yes_token?.implied_probability != null) {
+ const implied = Number(scan.yes_token.implied_probability);
+ if (Number.isFinite(implied)) return implied;
+ }
+ return null;
+}
+
+function isFahrenheitSymbol(symbol?: string | null) {
+ return String(symbol || "")
+ .toUpperCase()
+ .includes("F");
+}
+
+function displayTempToMarketCelsius(
+ value: number | null,
+ detail: Pick,
+) {
+ if (value == null || !Number.isFinite(value)) return null;
+ if (isFahrenheitSymbol(detail.temp_symbol)) {
+ return ((value - 32) * 5) / 9;
+ }
+ return value;
+}
+
+function formatBucketDisplayLabel(
+ bucket: ProbabilityBucket,
+ detail: Pick,
+) {
+ let bucketLabel = bucket.label || `${bucket.value}${detail.temp_symbol}`;
+ if (!bucketLabel) return "";
+ let str = String(bucketLabel).toUpperCase().replace(/\s+/g, "");
+ const symbol = detail.temp_symbol || "°C";
+ if (isFahrenheitSymbol(symbol)) {
+ str = str.replace(/℃/g, "°F").replace(/°C/g, "°F");
+ } else {
+ str = str.replace(/℃/g, "°C").replace(/°F/g, "°C");
+ }
+ str = str.replace(/°?C($|\+|-)/g, "°C$1");
+ str = str.replace(/°?F($|\+|-)/g, "°F$1");
+ if (!/[°℃][CF]/.test(str) && /[0-9]/.test(str)) {
+ str += symbol;
+ }
+ return str;
+}
+
+function getMarketBucketUnit(bucket?: MarketTopBucket | null) {
+ return String(bucket?.unit || "").toUpperCase();
+}
+
+function isMarketBucketAbove(bucket?: MarketTopBucket | null) {
+ const text =
+ `${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
+ .toLowerCase()
+ .replace(/\s+/g, "");
+ return (
+ text.includes("+") ||
+ text.includes("orhigher") ||
+ text.includes("or-higher")
+ );
+}
+
+function isMarketBucketBelow(bucket?: MarketTopBucket | null) {
+ const text =
+ `${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
+ .toLowerCase()
+ .replace(/\s+/g, "");
+ return (
+ text.includes("<=") || text.includes("orlower") || text.includes("or-lower")
+ );
+}
+
+function findMarketBucketForDisplayTemp(
+ buckets: MarketTopBucket[],
+ displayTemp: number | null,
+ detail: Pick,
+) {
+ if (displayTemp == null || !Number.isFinite(displayTemp)) return null;
+
+ let best: MarketTopBucket | null = null;
+ let bestDelta = Number.POSITIVE_INFINITY;
+ for (const bucket of buckets) {
+ const bucketUnit = String(bucket.unit || "").toUpperCase();
+ const compareTemp =
+ bucketUnit === "F"
+ ? displayTemp
+ : displayTempToMarketCelsius(displayTemp, detail);
+ if (compareTemp == null) continue;
+
+ const lower = bucket.lower != null ? Number(bucket.lower) : null;
+ const upper = bucket.upper != null ? Number(bucket.upper) : null;
+ if (
+ lower != null &&
+ upper != null &&
+ Number.isFinite(lower) &&
+ Number.isFinite(upper) &&
+ compareTemp >= lower - 0.01 &&
+ compareTemp <= upper + 0.01
+ ) {
+ return bucket;
+ }
+
+ const rawTemp = bucket.temp ?? bucket.value ?? null;
+ if (rawTemp == null) continue;
+ const candidateTemp = Number(rawTemp);
+ if (!Number.isFinite(candidateTemp)) continue;
+ const delta = Math.abs(candidateTemp - compareTemp);
+ if (delta < bestDelta) {
+ best = bucket;
+ bestDelta = delta;
+ }
+ }
+ const tolerance = isFahrenheitSymbol(detail.temp_symbol) ? 0.56 : 0.26;
+ return best && bestDelta <= tolerance ? best : null;
+}
+
+function marketBucketContainsDisplayTemp(
+ bucket: MarketTopBucket | null,
+ displayTemp: number | null,
+ detail: Pick,
+) {
+ if (!bucket || displayTemp == null || !Number.isFinite(displayTemp))
+ return false;
+
+ const bucketUnit = getMarketBucketUnit(bucket);
+ const compareTemp =
+ bucketUnit === "F"
+ ? displayTemp
+ : displayTempToMarketCelsius(displayTemp, detail);
+ if (compareTemp == null) return false;
+
+ const lower = bucket.lower != null ? Number(bucket.lower) : null;
+ const upper = bucket.upper != null ? Number(bucket.upper) : null;
+ if (lower != null && !Number.isFinite(lower)) return false;
+ if (upper != null && !Number.isFinite(upper)) return false;
+
+ if (lower != null && upper != null) {
+ return compareTemp >= lower - 0.01 && compareTemp <= upper + 0.01;
+ }
+ if (lower != null && isMarketBucketAbove(bucket)) {
+ return compareTemp >= lower - 0.01;
+ }
+ if (lower != null && isMarketBucketBelow(bucket)) {
+ return compareTemp <= lower + 0.01;
+ }
+ const reference = bucket.temp ?? bucket.value ?? lower;
+ const numeric = reference != null ? Number(reference) : null;
+ if (numeric == null || !Number.isFinite(numeric)) return false;
+ const tolerance = bucketUnit === "F" ? 0.56 : 0.26;
+ return Math.abs(compareTemp - numeric) <= tolerance;
+}
+
+function getAggregatedModelProbabilityForMarketBucket(
+ probabilities: ProbabilityBucket[],
+ bucket: MarketTopBucket | null,
+ detail: Pick,
+) {
+ if (!bucket) return null;
+
+ let total = 0;
+ let matched = 0;
+ for (const probabilityBucket of probabilities) {
+ const temp = getBucketTemp(probabilityBucket);
+ if (!marketBucketContainsDisplayTemp(bucket, temp, detail)) continue;
+ const probability = Number(probabilityBucket.probability);
+ if (!Number.isFinite(probability)) continue;
+ total += probability;
+ matched += 1;
+ }
+
+ return matched > 0 ? Math.max(0, Math.min(1, total)) : null;
+}
+
+type ProbabilityDisplayRow = {
+ key: string;
+ label: string;
+ probability: number;
+ marketBucket?: MarketTopBucket | null;
+};
+
+function formatMarketBucketDisplayLabel(
+ bucket: MarketTopBucket,
+ detail: Pick,
+) {
+ const label = String(bucket.label || "").trim();
+ if (label) {
+ const unit = getMarketBucketUnit(bucket);
+ let normalized = label.toUpperCase().replace(/\s+/g, "");
+ if (unit === "F" || isFahrenheitSymbol(detail.temp_symbol)) {
+ normalized = normalized
+ .replace(/ORHIGHER/g, "+")
+ .replace(/ORLOWER/g, "-")
+ .replace(/℃/g, "°F")
+ .replace(/°C/g, "°F")
+ .replace(/(?<=\d)F/g, "°F");
+ } else {
+ normalized = normalized
+ .replace(/ORHIGHER/g, "+")
+ .replace(/ORLOWER/g, "-")
+ .replace(/℃/g, "°C")
+ .replace(/°F/g, "°C")
+ .replace(/(?<=\d)C/g, "°C");
+ }
+ return normalized.replace(/\+/g, "+");
+ }
+
+ const unit =
+ getMarketBucketUnit(bucket) === "F" ||
+ isFahrenheitSymbol(detail.temp_symbol)
+ ? "°F"
+ : "°C";
+ const lower = bucket.lower != null ? Number(bucket.lower) : null;
+ const upper = bucket.upper != null ? Number(bucket.upper) : null;
+ if (
+ lower != null &&
+ upper != null &&
+ Number.isFinite(lower) &&
+ Number.isFinite(upper)
+ ) {
+ return `${lower}-${upper}${unit}`;
+ }
+ const value = bucket.value ?? bucket.temp ?? lower;
+ const numeric = value != null ? Number(value) : null;
+ if (numeric != null && Number.isFinite(numeric)) {
+ return isMarketBucketAbove(bucket)
+ ? `${numeric}${unit}+`
+ : `${numeric}${unit}`;
+ }
+ return "--";
+}
+
+type ModelMetadata = NonNullable<
+ NonNullable["open_meteo_multi_model"]
+>["model_metadata"];
+
+
+function normalizeModelNameForVote(name: string) {
+ return String(name || "")
+ .trim()
+ .toLowerCase()
+ .replace(/[\s_/-]/g, "");
+}
+
+function getModelVoteFamily(name: string) {
+ const normalized = normalizeModelNameForVote(name);
+ if (["icon", "iconeu", "icond2"].includes(normalized)) return "dwd_icon";
+ if (["gem", "gdps", "rdps", "hrdps"].includes(normalized)) return "eccc_gem";
+ if (["ecmwfaifs", "aifs"].includes(normalized)) return "ecmwf_aifs";
+ if (normalized === "ecmwf") return "ecmwf_ifs";
+ return normalized || name;
+}
+
+function getModelVotePriority(name: string) {
+ const normalized = normalizeModelNameForVote(name);
+ return (
+ {
+ icond2: 40,
+ iconeu: 30,
+ icon: 20,
+ hrdps: 40,
+ rdps: 35,
+ gdps: 30,
+ gem: 20,
+ ecmwfaifs: 30,
+ ecmwf: 30,
+ gfs: 30,
+ jma: 30,
+ mgm: 45,
+ nws: 45,
+ openmeteo: 15,
+ }[normalized] || 10
+ );
+}
+
+function getRoundedModelVoteDistribution(
+ detail: CityDetail,
+ targetDate?: string | null,
+) {
+ const view = getModelView(detail, targetDate);
+ const representatives = new Map<
+ string,
+ { name: string; priority: number; value: number }
+ >();
+
+ Object.entries(view.models || {}).forEach(([name, rawValue]) => {
+ const normalized = normalizeModelNameForVote(name);
+ if (normalized === "lgbm" || normalized.includes("meteoblue")) return;
+ const value = Number(rawValue);
+ if (!Number.isFinite(value)) return;
+ const family = getModelVoteFamily(name);
+ const priority = getModelVotePriority(name);
+ const current = representatives.get(family);
+ if (!current || priority > current.priority) {
+ representatives.set(family, { name, priority, value });
+ }
+ });
+
+ const bucketMap = new Map();
+ representatives.forEach(({ name, value }) => {
+ const rounded = Math.round(value);
+ const row = bucketMap.get(rounded) || { count: 0, models: [] };
+ row.count += 1;
+ row.models.push(name);
+ bucketMap.set(rounded, row);
+ });
+
+ const total = representatives.size;
+ const rows = Array.from(bucketMap.entries())
+ .map(([value, row]) => ({
+ count: row.count,
+ models: row.models,
+ percent: total > 0 ? row.count / total : 0,
+ value,
+ }))
+ .sort((a, b) => b.count - a.count || b.value - a.value);
+
+ return {
+ rows,
+ total,
+ };
+}
+
+function normalizeMarketProbability(value?: number | null) {
+ if (value == null) return null;
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) return null;
+ if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
+ return Math.max(0, Math.min(1, numeric));
+}
+
+function normalizeSignedProbability(value?: number | null) {
+ if (value == null) return null;
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) return null;
+ if (Math.abs(numeric) > 1) return numeric / 100;
+ return numeric;
+}
+
+function formatSignedPercent(value?: number | null, digits = 1) {
+ const normalized = normalizeSignedProbability(value);
+ if (normalized == null) return "--";
+ const percent = normalized * 100;
+ const sign = percent > 0 ? "+" : "";
+ return `${sign}${percent.toFixed(digits)}%`;
+}
+
+function getMarketTopBuckets(scan?: MarketScan | null) {
+ const buckets = Array.isArray(scan?.top_buckets) ? scan.top_buckets : [];
+ if (!buckets.length) return [];
+
+ return buckets
+ .map((item) => ({
+ ...item,
+ probability: normalizeMarketProbability(item.probability),
+ }))
+ .filter(
+ (item): item is MarketTopBucket & { probability: number } =>
+ item.probability != null,
+ );
+}
+
+function getMarketAllBuckets(scan?: MarketScan | null) {
+ const buckets = Array.isArray(scan?.all_buckets)
+ ? scan.all_buckets
+ : Array.isArray(scan?.top_buckets)
+ ? scan.top_buckets
+ : [];
+ if (!buckets.length) return [];
+
+ return buckets
+ .map((item) => ({
+ ...item,
+ probability: normalizeMarketProbability(item.probability),
+ }))
+ .filter(
+ (item): item is MarketTopBucket & { probability: number } =>
+ item.probability != null,
+ );
+}
+
+function getMarketTopBucketKey(bucket: MarketTopBucket) {
+ if (bucket?.value != null) {
+ const valueNum = Number(bucket.value);
+ if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`;
+ }
+
+ if (bucket?.temp != null) {
+ const tempNum = Number(bucket.temp);
+ if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`;
+ }
+
+ const parsed = parseTempFromText(bucket?.label);
+ if (parsed != null) return `l:${parsed.toFixed(2)}`;
+
+ return `s:${String(bucket?.slug || bucket?.question || bucket?.label || "")}`;
+}
+
+function hasLgbmModel(detail: CityDetail, targetDate?: string | null) {
+ const view = getModelView(detail, targetDate);
+ return Object.keys(view.models || {}).some((name) =>
+ normalizeModelNameForVote(name).includes("lgbm"),
+ );
+}
+
+function formatProbabilityEngineLabel(
+ detail: CityDetail,
+ targetDate: string | null | undefined,
+ locale: string,
+) {
+ const view = getProbabilityView(detail, targetDate);
+ if (hasLgbmModel(detail, targetDate)) {
+ return locale === "en-US" ? "LGBM-calibrated probability" : "LGBM 校准概率";
+ }
+ const engine = String(view.engine || "")
+ .trim()
+ .toLowerCase();
+ const calibrationMode = String(view.calibrationMode || "")
+ .trim()
+ .toLowerCase();
+ if (engine === "emos" || calibrationMode.includes("emos")) {
+ return locale === "en-US" ? "EMOS-calibrated probability" : "EMOS 校准概率";
+ }
+ return locale === "en-US" ? "Model probability" : "模型概率";
+}
+
+
+export function ProbabilityDistribution({
+ detail,
+ hideTitle = false,
+ targetDate,
+ marketScan,
+}: {
+ detail: CityDetail;
+ hideTitle?: boolean;
+ targetDate?: string | null;
+ marketScan?: MarketScan | null;
+}) {
+ const { locale, t } = useI18n();
+ const view = getProbabilityView(detail, targetDate);
+ const modelView = getModelView(detail, targetDate);
+ const marketYesPrice = getMarketYesPrice(marketScan);
+ const marketYesText = toPercent(marketYesPrice);
+ const isToday = !targetDate || targetDate === detail.local_date;
+ const probabilityEngineLabel = formatProbabilityEngineLabel(
+ detail,
+ targetDate,
+ locale,
+ );
+ const hasLgbmProbability = hasLgbmModel(detail, targetDate);
+ const modelVoteView = useMemo(
+ () => getRoundedModelVoteDistribution(detail, targetDate),
+ [detail, targetDate],
+ );
+ const modelVoteHint = modelVoteView.rows
+ .slice(0, 2)
+ .map(
+ (row) =>
+ `${row.value}${detail.temp_symbol} ${row.count}/${modelVoteView.total}`,
+ )
+ .join(" · ");
+ const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
+ const marketAllBuckets = isToday ? getMarketAllBuckets(marketScan) : [];
+ const sortedMarketTopBuckets = useMemo(() => {
+ const sorted = [...marketTopBuckets].sort(
+ (a, b) => Number(b.probability || 0) - Number(a.probability || 0),
+ );
+ const deduped: Array = [];
+ const seenKeys = new Set();
+ for (const row of sorted) {
+ const key = getMarketTopBucketKey(row);
+ if (seenKeys.has(key)) continue;
+ seenKeys.add(key);
+ deduped.push(row);
+ if (deduped.length >= 4) break;
+ }
+ return deduped;
+ }, [marketTopBuckets]);
+ const useMarketTopBuckets =
+ marketScan?.available && sortedMarketTopBuckets.length >= 2;
+ const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability);
+ const topProbability = [...(view.probabilities || [])].sort(
+ (a, b) => Number(b.probability || 0) - Number(a.probability || 0),
+ )[0];
+ const topProbabilityText = toPercent(topProbability?.probability);
+ const topProbabilityLabel = topProbability
+ ? formatBucketDisplayLabel(topProbability, detail)
+ : null;
+ const topProbabilityTemp = topProbability
+ ? getBucketTemp(topProbability)
+ : null;
+ const probabilitiesForMarketContracts =
+ view.probabilitiesAll?.length > 0
+ ? view.probabilitiesAll
+ : view.probabilities || [];
+ const marketContractRows = useMemo(() => {
+ if (!isToday || !marketScan?.available || marketAllBuckets.length === 0) {
+ return [];
+ }
+
+ const rows: ProbabilityDisplayRow[] = [];
+ const seenKeys = new Set();
+ for (const marketBucket of marketAllBuckets) {
+ const probability = getAggregatedModelProbabilityForMarketBucket(
+ probabilitiesForMarketContracts,
+ marketBucket,
+ detail,
+ );
+
+ const key =
+ marketBucket.slug ||
+ marketBucket.label ||
+ `${marketBucket.lower ?? marketBucket.value ?? marketBucket.temp}-${marketBucket.upper ?? ""}`;
+ if (seenKeys.has(key)) continue;
+ seenKeys.add(key);
+ rows.push({
+ key,
+ label: formatMarketBucketDisplayLabel(marketBucket, detail),
+ probability: probability ?? 0,
+ marketBucket,
+ });
+ }
+ return rows;
+ }, [
+ detail,
+ isToday,
+ marketAllBuckets,
+ marketScan?.available,
+ probabilitiesForMarketContracts,
+ ]);
+ const modelProbabilityRows = useMemo(
+ () =>
+ (view.probabilities || []).slice(0, 6).map((bucket, index) => {
+ const bucketTemp = getBucketTemp(bucket);
+ return {
+ key: `${bucket.label || bucket.value || index}`,
+ label: formatBucketDisplayLabel(bucket, detail),
+ probability: Number(bucket.probability || 0),
+ marketBucket: findMarketBucketForDisplayTemp(
+ marketAllBuckets,
+ bucketTemp,
+ detail,
+ ),
+ };
+ }),
+ [detail, marketAllBuckets, view.probabilities],
+ );
+ const probabilityRows =
+ marketContractRows.length > 0
+ ? marketContractRows.slice(0, 8)
+ : modelProbabilityRows;
+ const topContractRow =
+ marketContractRows.length > 0
+ ? marketContractRows.reduce((best, row) =>
+ row.probability > best.probability ? row : best,
+ )
+ : null;
+ const displayTopLabel = topContractRow?.label || topProbabilityLabel || null;
+ const displayTopProbability =
+ topContractRow?.probability ??
+ (topProbability?.probability != null
+ ? Number(topProbability.probability)
+ : null);
+ const displayTopProbabilityText = toPercent(displayTopProbability);
+ const displayUsesMarketBuckets = marketContractRows.length > 0;
+ const linkedMarketBucket = useMemo(() => {
+ if (topContractRow?.marketBucket) return topContractRow.marketBucket;
+ if (topProbabilityTemp == null) return null;
+ return findMarketBucketForDisplayTemp(
+ marketAllBuckets,
+ topProbabilityTemp,
+ detail,
+ );
+ }, [detail, marketAllBuckets, topContractRow, topProbabilityTemp]);
+ const priceAnalysis = marketScan?.price_analysis;
+ const yesPriceView = priceAnalysis?.yes;
+ const noPriceView = priceAnalysis?.no;
+ const linkedMarketAsk =
+ linkedMarketBucket?.yes_buy ??
+ linkedMarketBucket?.market_price ??
+ yesPriceView?.ask ??
+ null;
+ const linkedNoAsk = linkedMarketBucket?.no_buy ?? noPriceView?.ask ?? null;
+ const linkedContractLabel =
+ topContractRow?.label ||
+ (linkedMarketBucket
+ ? formatMarketBucketDisplayLabel(linkedMarketBucket, detail)
+ : null) ||
+ topProbabilityLabel ||
+ null;
+ const aggregatedMarketProbability =
+ getAggregatedModelProbabilityForMarketBucket(
+ probabilitiesForMarketContracts,
+ linkedMarketBucket,
+ detail,
+ );
+ const linkedMarketProbability =
+ topContractRow?.probability ??
+ aggregatedMarketProbability ??
+ (topProbability?.probability != null
+ ? Number(topProbability.probability)
+ : null);
+ const linkedMarketProbabilityText = toPercent(linkedMarketProbability);
+ const linkedMarketEdge =
+ linkedMarketProbability != null && linkedMarketAsk != null
+ ? linkedMarketProbability - Number(linkedMarketAsk)
+ : null;
+ const linkedNoEdge =
+ linkedMarketProbability != null && linkedNoAsk != null
+ ? 1 - linkedMarketProbability - Number(linkedNoAsk)
+ : null;
+ const linkedBestSide =
+ linkedMarketBucket && linkedNoEdge != null && linkedMarketEdge != null
+ ? linkedNoEdge > linkedMarketEdge
+ ? "no"
+ : "yes"
+ : null;
+ const linkedBestAsk = linkedBestSide === "no" ? linkedNoAsk : linkedMarketAsk;
+ const linkedBestEdge =
+ linkedBestSide === "no" ? linkedNoEdge : linkedMarketEdge;
+ const preferredPriceView = linkedMarketBucket
+ ? {
+ ask: linkedBestAsk,
+ edge: linkedBestEdge,
+ }
+ : priceAnalysis?.best_side === "no"
+ ? noPriceView
+ : yesPriceView;
+ const preferredSideLabel = linkedMarketBucket
+ ? linkedBestSide === "no"
+ ? "NO"
+ : "YES"
+ : priceAnalysis?.best_side === "no"
+ ? locale === "en-US"
+ ? "NO"
+ : "NO"
+ : locale === "en-US"
+ ? "YES"
+ : "YES";
+ const yesDisplayPrice = linkedMarketBucket
+ ? linkedMarketAsk
+ : yesPriceView?.ask;
+ const noDisplayPrice = linkedMarketBucket ? linkedNoAsk : noPriceView?.ask;
+ const yesDisplayEdge = linkedMarketBucket
+ ? linkedMarketEdge
+ : yesPriceView?.edge;
+ const noDisplayEdge = linkedMarketBucket ? linkedNoEdge : noPriceView?.edge;
+ const hasPriceAnalysis =
+ isToday &&
+ (Boolean(priceAnalysis?.available) ||
+ Boolean(marketScan) ||
+ Boolean(topProbability));
+ const lockEdge = normalizeSignedProbability(priceAnalysis?.lock?.edge);
+ const lockAvailable = Boolean(
+ priceAnalysis?.lock?.available && lockEdge != null,
+ );
+ const quoteSource =
+ linkedMarketBucket?.quote_source ||
+ marketScan?.yes_token?.quote_source ||
+ marketScan?.no_token?.quote_source ||
+ null;
+ const quoteAgeMs =
+ linkedMarketBucket?.quote_age_ms ??
+ marketScan?.yes_token?.quote_age_ms ??
+ marketScan?.no_token?.quote_age_ms;
+ const quoteSourceLabel =
+ quoteSource === "polymarket_ws"
+ ? locale === "en-US"
+ ? `WS live${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}s` : ""}`
+ : `WS 实时${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}秒` : ""}`
+ : locale === "en-US"
+ ? "CLOB fallback"
+ : "CLOB 兜底";
+ const actionableEdge = normalizeSignedProbability(preferredPriceView?.edge);
+ const linkedContractOverpriced =
+ Boolean(linkedMarketBucket) &&
+ linkedBestSide === "no" &&
+ linkedMarketProbability != null &&
+ linkedMarketAsk != null &&
+ linkedMarketEdge != null &&
+ linkedMarketEdge < 0 &&
+ linkedNoEdge != null &&
+ linkedNoEdge > 0;
+ const linkedContractOverpay =
+ linkedContractOverpriced &&
+ linkedMarketProbability != null &&
+ linkedMarketAsk != null
+ ? Number(linkedMarketAsk) - linkedMarketProbability
+ : null;
+ const actionText = !marketScan
+ ? locale === "en-US"
+ ? "Waiting"
+ : "等待"
+ : !marketScan.available
+ ? locale === "en-US"
+ ? "No market"
+ : "无盘口"
+ : actionableEdge == null
+ ? locale === "en-US"
+ ? "No quote"
+ : "无报价"
+ : actionableEdge >= 0.02
+ ? linkedContractOverpriced
+ ? locale === "en-US"
+ ? "Overpriced"
+ : "市场偏贵"
+ : locale === "en-US"
+ ? `Watch ${preferredSideLabel}`
+ : `可关注 ${preferredSideLabel}`
+ : actionableEdge > 0
+ ? linkedContractOverpriced
+ ? locale === "en-US"
+ ? "Slightly overpriced"
+ : "略偏贵"
+ : locale === "en-US"
+ ? `Small ${preferredSideLabel}`
+ : `${preferredSideLabel} 优势较小`
+ : locale === "en-US"
+ ? "No clear edge"
+ : "暂无优势";
+ const actionNote =
+ linkedContractOverpriced && linkedContractOverpay != null
+ ? locale === "en-US"
+ ? `YES above model by ${formatSignedPercent(linkedContractOverpay)}`
+ : `YES 高于模型 ${formatSignedPercent(linkedContractOverpay)}`
+ : actionableEdge != null && actionableEdge >= 0.02
+ ? locale === "en-US"
+ ? `${formatSignedPercent(actionableEdge)} vs ask`
+ : `相对买价 ${formatSignedPercent(actionableEdge)}`
+ : locale === "en-US"
+ ? `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`
+ : `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`;
+
+ return (
+
+ {!hideTitle && {t("section.probability")} }
+
+
+
+ {probabilityEngineLabel}
+
+ {displayTopLabel && displayTopProbabilityText
+ ? locale === "en-US"
+ ? displayUsesMarketBuckets
+ ? `${displayTopLabel} is the top displayed contract bucket at ${displayTopProbabilityText}`
+ : `${displayTopLabel} is the top single bucket at ${displayTopProbabilityText}`
+ : displayUsesMarketBuckets
+ ? `${displayTopLabel} 为当前显示分布最高,${displayTopProbabilityText}`
+ : `${displayTopLabel} 单点最高,${displayTopProbabilityText}`
+ : locale === "en-US"
+ ? "Awaiting calibrated buckets"
+ : "等待校准概率桶"}
+
+
+
+ {hasLgbmProbability
+ ? locale === "en-US"
+ ? "LGBM is the learned intraday adjustment; raw model points below are only diagnostic."
+ : "LGBM 作为日内学习校准项;下方原始模型落点仅用于诊断。"
+ : locale === "en-US"
+ ? "Using the calibrated probability distribution; raw model points below are not probabilities."
+ : "使用校准后的概率分布;下方原始模型落点不是概率。"}
+
+
+ {marketScan?.available && (topMarketBucketText || marketYesText) && (
+
+ {useMarketTopBuckets
+ ? locale === "en-US"
+ ? `Market reference only: top traded bucket ${topMarketBucketText}`
+ : `市场仅作参考:最高交易温度桶 ${topMarketBucketText}`
+ : locale === "en-US"
+ ? `Market reference only: this bucket ${marketYesText}`
+ : `市场仅作参考:该温度桶 ${marketYesText}`}
+
+ )}
+
+
+
+ {locale === "en-US"
+ ? "EMOS probability distribution"
+ : "EMOS 概率分布"}
+
+
+ {marketContractRows.length > 0
+ ? locale === "en-US"
+ ? "market buckets are aggregated from single-degree EMOS buckets"
+ : "市场合约桶由单点 EMOS 概率聚合"
+ : locale === "en-US"
+ ? "calibrated temperature buckets"
+ : "校准后的温度桶"}
+
+
+ {probabilityRows.length === 0 ? (
+
+ ) : (
+ probabilityRows.map((row, index) => {
+ const probability = Math.round(
+ Number(row.probability || 0) * 100,
+ );
+
+ return (
+
+ );
+ })
+ )}
+
+ {hasPriceAnalysis && (
+
+
+
+ {locale === "en-US" ? "Win-rate reference" : "胜率参考"}
+
+
+ {!marketScan
+ ? locale === "en-US"
+ ? "Waiting for market context"
+ : "等待市场参照"
+ : !marketScan.available
+ ? locale === "en-US"
+ ? "No matched active market"
+ : "未匹配到活跃盘口"
+ : locale === "en-US"
+ ? `${linkedContractLabel || topProbabilityLabel || "Temperature bucket"} · model ${linkedMarketProbabilityText || topProbabilityText || "--"}`
+ : `${linkedContractLabel || topProbabilityLabel || "温度桶"} · 模型 ${linkedMarketProbabilityText || topProbabilityText || "--"}`}
+
+
+
+
+
+ {locale === "en-US" ? "Bucket" : "温度桶"}
+
+
+ {linkedContractLabel || topProbabilityLabel || "--"}
+
+
+ {linkedMarketProbabilityText || topProbabilityText || "--"}
+
+
+
+ {locale === "en-US" ? "DEB" : "DEB"}
+
+ {modelView.deb != null && Number.isFinite(Number(modelView.deb))
+ ? `${Number(modelView.deb).toFixed(1)}${detail.temp_symbol}`
+ : "--"}
+
+ {locale === "en-US" ? "final fused forecast" : "最终融合预测"}
+
+
+ {locale === "en-US" ? "Model support" : "模型支持"}
+ {modelVoteHint || "--"}
+ {locale === "en-US" ? "raw model agreement" : "原始模型一致性"}
+
+
+ {locale === "en-US" ? "Market role" : "盘口角色"}
+ {locale === "en-US" ? "Reference only" : "仅作参考"}
+ {quoteSourceLabel}
+
+
+
+ {locale === "en-US"
+ ? "This card follows the same rule as AI forecast: DEB first, model agreement second, METAR conflict check before settlement."
+ : "该卡片与 AI 预测口径一致:先看 DEB,再看模型支持,最后检查 METAR 是否冲突。"}
+
+
+ )}
+ {modelVoteHint && (
+
+
+ {locale === "en-US" ? "Raw model points" : "原始模型落点"}
+
+ {modelVoteHint}
+
+ {locale === "en-US"
+ ? "diagnostic only; EMOS and contract rows use calibrated probabilities"
+ : "仅作诊断;EMOS 与合约行使用校准概率"}
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/components/dashboard/ScanTerminal.module.css b/frontend/components/dashboard/ScanTerminal.module.css
index e4011297..891bef8c 100644
--- a/frontend/components/dashboard/ScanTerminal.module.css
+++ b/frontend/components/dashboard/ScanTerminal.module.css
@@ -638,245 +638,6 @@
backdrop-filter: blur(14px);
}
-.root :global(.scan-filter-panel) {
- width: auto;
- min-width: 0;
- border-right: 1px solid rgba(159, 178, 199, 0.14);
- padding: 18px 14px;
- gap: 18px;
- overflow: hidden auto;
-}
-
-.root :global(.scan-sidebar-brand) {
- display: flex;
- align-items: center;
- gap: 0;
- padding: 8px 4px 18px;
- border-bottom: 1px solid rgba(118, 146, 188, 0.12);
-}
-
-.root :global(.scan-sidebar-brand-mark) {
- width: 42px;
- height: 42px;
- border-radius: 14px;
- display: grid;
- place-items: center;
- color: #042113;
- background: radial-gradient(circle at 30% 30%, #29f2ab, #0da46a 60%, #073c28 100%);
- box-shadow: 0 0 24px rgba(23, 217, 139, 0.3);
-}
-
-.root :global(.scan-sidebar-brand-name) {
- font-size: 30px;
- font-weight: 800;
- letter-spacing: 0;
- color: #eef5ff;
-}
-
-.root :global(.scan-filter-heading) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin: 6px 8px 8px;
- font-size: 14px;
- font-weight: 600;
- color: #b8c7dd;
-}
-
-.root :global(.scan-mode-tabs) {
- gap: 10px;
-}
-
-.root :global(.scan-mode-tab) {
- align-items: flex-start;
- gap: 12px;
- padding: 14px;
- border-radius: 14px;
- border: 1px solid rgba(87, 119, 166, 0.14);
- background: rgba(11, 24, 42, 0.95);
- color: #dbe7f8;
-}
-
-.root :global(.scan-mode-tab:hover) {
- background: rgba(13, 28, 48, 0.98);
- border-color: rgba(87, 119, 166, 0.24);
-}
-
-.root :global(.scan-mode-tab.active) {
- background: linear-gradient(180deg, rgba(15, 34, 59, 1), rgba(9, 19, 34, 1));
- border-color: rgba(78, 222, 151, 0.7);
- color: #f3fbff;
- box-shadow: inset 0 0 0 1px rgba(78, 222, 151, 0.35), 0 0 0 2px rgba(7, 199, 119, 0.08);
-}
-
-.root :global(.scan-mode-tab.active::before) {
- display: none;
-}
-
-.root :global(.scan-mode-icon) {
- width: 28px;
- height: 28px;
- border-radius: 10px;
- display: grid;
- place-items: center;
- flex-shrink: 0;
- background: rgba(63, 140, 255, 0.12);
- color: #6cb1ff;
-}
-
-.root :global(.scan-mode-tab.active .scan-mode-icon) {
- background: rgba(23, 217, 139, 0.14);
- color: #2df3a5;
-}
-
-.root :global(.scan-mode-copy) {
- display: flex;
- flex-direction: column;
- gap: 4px;
- min-width: 0;
-}
-
-.root :global(.scan-mode-tab-label) {
- font-size: 15px;
- font-weight: 700;
- color: inherit;
-}
-
-.root :global(.scan-mode-tab-sub) {
- font-size: 12px;
- line-height: 1.4;
- color: #8fa4c3;
-}
-
-.root :global(.scan-range-card),
-.root :global(.scan-filter-row) {
- background: rgba(11, 24, 42, 0.92);
- border: 1px solid rgba(87, 119, 166, 0.12);
- border-radius: 12px;
- padding: 12px 14px;
-}
-
-.root :global(.scan-range-card) {
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-.root :global(.scan-filter-row-title),
-.root :global(.scan-filter-row-label) {
- font-size: 14px;
- color: #d9e7f8;
-}
-
-.root :global(.scan-filter-row) {
- gap: 10px;
-}
-
-.root :global(.scan-filter-row.inline) {
- flex-direction: row;
- align-items: center;
- justify-content: space-between;
-}
-
-.root :global(.scan-range-track-wrap) {
- position: relative;
- height: 20px;
-}
-
-.root :global(.scan-range-track-wrap::before) {
- content: "";
- position: absolute;
- inset: 7px 0;
- border-radius: 999px;
- background: linear-gradient(90deg, #1c2f4e, #2d4f84);
-}
-
-.root :global(.scan-range-slider) {
- position: absolute;
- inset: 0;
- width: 100%;
- height: 20px;
- background: transparent;
- pointer-events: none;
-}
-
-.root :global(.scan-range-slider::-webkit-slider-thumb) {
- width: 16px;
- height: 16px;
- border-radius: 50%;
- border: none;
- background: #4d93ff;
- box-shadow: 0 0 0 4px rgba(77, 147, 255, 0.16);
- pointer-events: auto;
-}
-
-.root :global(.scan-range-slider::-moz-range-thumb) {
- width: 16px;
- height: 16px;
- border-radius: 50%;
- border: none;
- background: #4d93ff;
- box-shadow: 0 0 0 4px rgba(77, 147, 255, 0.16);
- pointer-events: auto;
-}
-
-.root :global(.scan-range-slider::-webkit-slider-runnable-track),
-.root :global(.scan-range-slider::-moz-range-track) {
- background: transparent;
-}
-
-.root :global(.scan-range-labels) {
- display: flex;
- justify-content: space-between;
- font-size: 12px;
- color: #aec0d9;
-}
-
-.root :global(.scan-select) {
- border-radius: 10px;
- padding: 10px 12px;
- border-color: rgba(87, 119, 166, 0.12);
- background: rgba(10, 22, 38, 0.95);
- color: #d9e8fb;
-}
-
-.root :global(.scan-toggle) {
- width: 42px;
- height: 24px;
- border-radius: 999px;
- border: 1px solid rgba(255, 255, 255, 0.06);
- background: linear-gradient(90deg, #0e2840, #12324d);
-}
-
-.root :global(.scan-toggle.active) {
- background: linear-gradient(90deg, #0e2840, #1f7f57);
- border-color: rgba(39, 234, 152, 0.28);
-}
-
-.root :global(.scan-toggle-knob) {
- top: 2px;
- left: 3px;
- width: 18px;
- height: 18px;
- background: #8ca5c8;
-}
-
-.root :global(.scan-toggle.active .scan-toggle-knob) {
- left: 21px;
- background: #1bf1a2;
- box-shadow: 0 0 10px rgba(27, 241, 162, 0.45);
-}
-
-.root :global(.scan-cta-button) {
- border-radius: 14px;
- padding: 15px;
- margin-top: auto;
- background: linear-gradient(180deg, #1ddb8d, #0eb96d);
- box-shadow: 0 8px 22px rgba(14, 185, 109, 0.22);
- font-size: 16px;
- font-weight: 800;
-}
-
.root :global(.scan-data-grid) {
padding: 18px;
gap: 18px;
@@ -884,2042 +645,3 @@
min-height: calc(100vh - 32px);
max-height: calc(100vh - 32px);
}
-
-.root :global(.scan-topbar) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 6px 0 18px;
- border-bottom: 1px solid rgba(118, 146, 188, 0.1);
-}
-
-.root :global(.scan-topbar-title) {
- display: flex;
- flex-direction: column;
- gap: 6px;
- min-width: 0;
-}
-
-.root :global(.scan-topbar-title strong) {
- margin: 0;
- font-size: 28px;
- line-height: 1.08;
- letter-spacing: -0.04em;
- color: #f3f8ff;
-}
-
-.root :global(.scan-topbar-title span) {
- color: #8fa4c3;
- font-size: 14px;
- line-height: 1.5;
-}
-
-.root :global(.scan-topbar-actions) {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- justify-content: flex-end;
- gap: 12px;
- color: #b4c8e2;
- font-size: 14px;
-}
-
-.root :global(.scan-locale-switch) {
- height: 36px;
- padding: 3px;
- border-radius: 12px;
- border: 1px solid rgba(99, 132, 180, 0.18);
- background: rgba(8, 19, 34, 0.9);
- color: #a8bdd8;
- display: inline-flex;
- align-items: center;
- gap: 2px;
- font-size: 12px;
- font-weight: 800;
- cursor: pointer;
-}
-
-.root :global(.scan-locale-switch span) {
- min-width: 34px;
- height: 28px;
- padding: 0 8px;
- border-radius: 8px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
-}
-
-.root :global(.scan-locale-switch span.active) {
- color: #eef7ff;
- background: rgba(63, 140, 255, 0.16);
- box-shadow: inset 0 0 0 1px rgba(63, 140, 255, 0.22);
-}
-
-.root :global(.scan-topbar-time) {
- color: #b4c8e2;
- font-variant-numeric: tabular-nums;
-}
-
-.root :global(.scan-upgrade-announcement) {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- gap: 16px;
- align-items: center;
- margin: -4px 0 6px;
- border: 1px solid rgba(77, 163, 255, 0.2);
- border-radius: 18px;
- background:
- radial-gradient(circle at top left, rgba(77, 163, 255, 0.18), transparent 36%),
- linear-gradient(135deg, rgba(18, 33, 58, 0.96), rgba(15, 23, 42, 0.92));
- box-shadow: 0 18px 48px rgba(0, 0, 0, 0.2);
- padding: 16px 18px;
-}
-
-.root :global(.scan-upgrade-announcement-copy) {
- min-width: 0;
-}
-
-.root :global(.scan-upgrade-announcement-copy span) {
- color: #86efac;
- font-size: 12px;
- font-weight: 900;
- letter-spacing: 0.04em;
- text-transform: uppercase;
-}
-
-.root :global(.scan-upgrade-announcement-copy strong) {
- display: block;
- margin-top: 5px;
- color: #f3f8ff;
- font-size: 18px;
- line-height: 1.25;
-}
-
-.root :global(.scan-upgrade-announcement-copy p) {
- margin: 6px 0 0;
- color: #b4c8e2;
- font-size: 13px;
- line-height: 1.55;
-}
-
-.root :global(.scan-upgrade-announcement ul) {
- display: flex;
- flex-wrap: wrap;
- justify-content: flex-end;
- gap: 8px;
- margin: 0;
- padding: 0;
- list-style: none;
-}
-
-.root :global(.scan-upgrade-announcement li) {
- border: 1px solid rgba(77, 163, 255, 0.22);
- border-radius: 999px;
- background: rgba(77, 163, 255, 0.1);
- color: #d7e5f7;
- font-size: 12px;
- font-weight: 850;
- padding: 7px 10px;
-}
-
-.root :global(.scan-ghost-button),
-.root :global(.scan-cta-ghost),
-.root :global(.scan-theme-button),
-.root :global(.scan-detail-action-button),
-.root :global(.scan-detail-icon-button),
-.root :global(.scan-view-all-button) {
- border: 1px solid rgba(99, 132, 180, 0.18);
- background: rgba(8, 19, 34, 0.9);
- color: #d7e5f7;
- border-radius: 12px;
-}
-
-.root :global(.scan-ghost-button),
-.root :global(.scan-cta-ghost) {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- padding: 11px 14px;
- font-weight: 700;
-}
-
-.root :global(.scan-primary-button) {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- min-height: 42px;
- padding: 11px 16px;
- border: 1px solid rgba(77, 163, 255, 0.42);
- border-radius: 12px;
- background: linear-gradient(180deg, #4DA3FF, #3B82F6);
- color: #FFFFFF;
- font-size: 14px;
- font-weight: 900;
- text-decoration: none;
- box-shadow: 0 10px 24px rgba(59, 130, 246, 0.22);
- cursor: pointer;
-}
-
-.root :global(.scan-primary-button:hover:not(:disabled)) {
- filter: brightness(1.06);
-}
-
-.root :global(.scan-primary-button:disabled) {
- cursor: not-allowed;
- opacity: 0.68;
-}
-
-.root :global(.scan-ai-button) {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- min-height: 42px;
- padding: 11px 14px;
- border: 1px solid rgba(77, 163, 255, 0.3);
- border-radius: 12px;
- 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(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) {
- cursor: not-allowed;
- opacity: 0.55;
-}
-
-.root :global(.scan-cta-ghost) {
- color: #6FB7FF;
- border-color: rgba(23, 217, 139, 0.3);
- background: rgba(23, 217, 139, 0.12);
-}
-
-.root :global(.scan-account-button) {
- width: 42px;
- height: 42px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- color: #d7e5f7;
- text-decoration: none;
- border: 1px solid rgba(99, 132, 180, 0.18);
- background: rgba(8, 19, 34, 0.9);
- border-radius: 12px;
-}
-
-.root :global(.scan-account-button:hover) {
- border-color: rgba(63, 140, 255, 0.28);
- background: rgba(12, 26, 44, 0.96);
-}
-
-.root :global(.scan-theme-button) {
- width: 36px;
- height: 36px;
- display: inline-grid;
- place-items: center;
- color: #d7e5f7;
- cursor: pointer;
-}
-
-.root :global(.scan-ghost-button .spin) {
- animation: spin 1s linear infinite;
-}
-
-.root :global(.scan-kpi-bar) {
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 14px;
-}
-
-.root :global(.scan-kpi-card) {
- min-height: 124px;
- padding: 18px;
- border-radius: 16px;
- border: 1px solid rgba(92, 124, 170, 0.12);
- background: linear-gradient(180deg, rgba(14, 28, 48, 0.92), rgba(11, 21, 37, 0.94));
- transform: none;
-}
-
-.root :global(.scan-kpi-card:hover) {
- transform: translateY(-2px);
-}
-
-.root :global(.scan-kpi-card::after) {
- display: none;
-}
-
-.root :global(.scan-kpi-card.green .scan-kpi-label) {
- color: #17d98b;
-}
-
-.root :global(.scan-kpi-card.cyan .scan-kpi-label) {
- color: #37d6ff;
-}
-
-.root :global(.scan-kpi-card.purple .scan-kpi-label) {
- color: #a35cff;
-}
-
-.root :global(.scan-kpi-card.blue .scan-kpi-label) {
- color: #49a3ff;
-}
-
-.root :global(.scan-kpi-card.red .scan-kpi-label) {
- color: #ff647c;
-}
-
-.root :global(.scan-kpi-card.amber .scan-kpi-label) {
- color: #ffb84f;
-}
-
-.root :global(.scan-kpi-card.orange .scan-kpi-label) {
- color: #F59E0B;
-}
-
-.root :global(.scan-kpi-label) {
- font-size: 13px;
- text-transform: none;
- letter-spacing: 0;
-}
-
-.root :global(.scan-kpi-value) {
- margin-top: 10px;
- font-size: 28px;
-}
-
-.root :global(.scan-kpi-note) {
- margin-top: 8px;
- font-size: 14px;
- color: #a8bedb;
-}
-
-.root :global(.scan-list-section) {
- display: flex;
- flex-direction: column;
- flex: 1;
- min-height: 0;
- overflow: hidden;
-}
-
-.root :global(.scan-list-header) {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 12px;
- margin-bottom: 10px;
-}
-
-.root :global(.scan-list-tabs) {
- display: flex;
- gap: 24px;
-}
-
-.root :global(.scan-list-tabs button) {
- border: none;
- background: transparent;
- font-size: 16px;
- font-weight: 700;
- color: #91a7c4;
- cursor: pointer;
-}
-
-.root :global(.scan-list-tabs button:disabled) {
- opacity: 0.42;
- cursor: not-allowed;
-}
-
-.root :global(.scan-list-tabs button.active) {
- color: #fff;
-}
-
-.root :global(.scan-list-status) {
- display: flex;
- gap: 10px;
- align-items: center;
-}
-
-.root :global(.scan-status-chip) {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- height: 34px;
- padding: 0 12px;
- border-radius: 999px;
- border: 1px solid rgba(98, 126, 167, 0.22);
- background: rgba(10, 22, 38, 0.92);
- color: #c4d4e9;
- font-size: 12px;
- font-weight: 800;
- letter-spacing: 0.02em;
-}
-
-.root :global(.scan-status-chip.live) {
- border-color: rgba(55, 214, 255, 0.28);
- background: rgba(55, 214, 255, 0.1);
- color: #71e6ff;
-}
-
-.root :global(.scan-status-chip.stale) {
- border-color: rgba(255, 184, 79, 0.24);
- background: rgba(255, 184, 79, 0.1);
- color: #ffc765;
-}
-
-.root :global(.scan-status-chip.ai) {
- border-color: rgba(94, 234, 212, 0.28);
- background: rgba(20, 184, 166, 0.12);
- color: #87fff1;
-}
-
-.root :global(.scan-status-chip.focus) {
- border-color: rgba(255, 176, 32, 0.3);
- background: rgba(255, 176, 32, 0.12);
- color: #ffd078;
-}
-
-.root :global(button.scan-status-chip.refresh) {
- cursor: pointer;
-}
-
-.root :global(button.scan-status-chip.refresh:disabled) {
- cursor: wait;
- opacity: 0.68;
-}
-
-.root :global(.scan-status-chip .spin) {
- animation: spin 1s linear infinite;
-}
-
-.root :global(.scan-table-shell) {
- display: flex;
- flex-direction: column;
- flex: 1;
- min-height: 0;
- border: 1px solid rgba(90, 123, 166, 0.12);
- border-radius: 18px;
- background: rgba(8, 17, 30, 0.8);
- overflow: hidden;
-}
-
-.root :global(.scan-table-shell.empty) {
- min-height: 340px;
-}
-
-.root :global(.scan-table-banner) {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- align-items: center;
- padding: 12px 16px;
- border-bottom: 1px solid rgba(95, 124, 168, 0.12);
- background: linear-gradient(180deg, rgba(255, 184, 79, 0.08), rgba(255, 184, 79, 0.03));
-}
-
-.root :global(.scan-table-banner strong) {
- font-size: 13px;
- font-weight: 800;
- color: #ffd27f;
-}
-
-.root :global(.scan-table-banner span) {
- font-size: 13px;
- color: #c7d4e7;
-}
-
-.root :global(.scan-table-header) {
- display: grid;
- grid-template-columns: minmax(220px, 1.1fr) minmax(128px, 0.68fr) minmax(230px, 1.08fr) minmax(150px, 0.78fr) 92px;
- gap: 10px;
- align-items: center;
- padding: 14px 18px;
- background: rgba(11, 23, 40, 0.98);
- border-bottom: 1px solid rgba(90, 123, 166, 0.12);
- color: #92a8c5;
- font-size: 13px;
- font-weight: 600;
- text-transform: none;
- letter-spacing: 0;
-}
-
-.root :global(.scan-table-body) {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- overflow-x: hidden;
- scrollbar-width: none;
- -ms-overflow-style: none;
-}
-
-.root :global(.scan-v4-analysis) {
- grid-column: auto;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 12px;
- margin: 0 14px 14px;
- padding: 14px;
- border-color: rgba(56, 189, 248, 0.18);
- border-radius: 14px;
- background:
- linear-gradient(180deg, rgba(7, 20, 36, 0.96), rgba(5, 15, 28, 0.98)),
- radial-gradient(circle at 4% 0%, rgba(45, 212, 191, 0.11), transparent 28%);
-}
-
-.root :global(.scan-v4-analysis section) {
- padding: 13px;
- border-color: rgba(92, 132, 188, 0.14);
- background: rgba(10, 25, 43, 0.7);
-}
-
-.root :global(.scan-v4-analysis p),
-.root :global(.scan-v4-analysis ul) {
- color: #b8c9e3;
-}
-
-.root :global(.scan-v4-analysis .scan-v4-evidence) {
- grid-column: 1 / -1;
-}
-
-.root :global(.scan-v4-analysis .scan-v4-decision) {
- grid-column: 1 / -1;
- border-color: rgba(45, 212, 191, 0.24);
- background:
- linear-gradient(180deg, rgba(6, 31, 43, 0.86), rgba(8, 23, 38, 0.86)),
- radial-gradient(circle at 0 0, rgba(20, 184, 166, 0.12), transparent 32%);
-}
-
-.root :global(.scan-v4-analysis .scan-v4-decision.downgrade),
-.root :global(.scan-v4-analysis .scan-v4-decision.watchlist) {
- border-color: rgba(245, 158, 11, 0.24);
-}
-
-.root :global(.scan-v4-analysis .scan-v4-decision.veto) {
- border-color: rgba(248, 113, 113, 0.28);
-}
-
-.root :global(.scan-v4-decision p b) {
- color: #ecfeff;
- font-weight: 950;
-}
-
-.root :global(.scan-v4-metar-summary) {
- margin-top: 9px;
- color: #8fb3d8;
- font-size: 12px;
- font-weight: 800;
- line-height: 1.45;
-}
-
-.root :global(.scan-v4-evidence > div),
-.root :global(.scan-v4-model-sources) {
- gap: 8px;
-}
-
-.root :global(.scan-v4-analysis) {
- display: flex;
- flex-direction: column;
- gap: 13px;
- margin: 0 14px 14px;
- padding: 15px 16px 16px;
- border: 1px solid rgba(56, 189, 248, 0.16);
- border-radius: 12px;
- background:
- linear-gradient(180deg, rgba(7, 20, 36, 0.96), rgba(5, 15, 28, 0.98)),
- radial-gradient(circle at 4% 0%, rgba(45, 212, 191, 0.08), transparent 30%);
-}
-
-.root :global(.scan-v4-analysis section),
-.root :global(.scan-v4-analysis section:first-child),
-.root :global(.scan-v4-analysis .scan-v4-evidence) {
- grid-column: auto;
- min-width: 0;
- padding: 0;
- border: 0;
- border-radius: 0;
- background: transparent;
-}
-
-.root :global(.scan-v4-heading) {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 18px;
- padding-bottom: 12px;
- border-bottom: 1px solid rgba(90, 123, 166, 0.14);
-}
-
-.root :global(.scan-v4-heading > div) {
- display: grid;
- gap: 7px;
- min-width: 0;
-}
-
-.root :global(.scan-v4-heading strong),
-.root :global(.scan-v4-current b),
-.root :global(.scan-v4-brief-grid strong) {
- color: #6FB7FF;
- font-size: 12px;
- font-weight: 950;
- letter-spacing: 0.02em;
-}
-
-.root :global(.scan-v4-heading p) {
- margin: 0;
- color: #d2e1f4;
- font-size: 14px;
- font-weight: 800;
- line-height: 1.55;
-}
-
-.root :global(.scan-v4-decision-pill) {
- display: inline-flex;
- flex: 0 0 auto;
- align-items: center;
- justify-content: center;
- min-height: 30px;
- padding: 0 12px;
- border: 1px solid rgba(45, 212, 191, 0.3);
- border-radius: 999px;
- background: rgba(20, 184, 166, 0.12);
- color: #7dfbe7;
- font-size: 12px;
- font-weight: 950;
- white-space: nowrap;
-}
-
-.root :global(.scan-v4-decision-pill.downgrade),
-.root :global(.scan-v4-decision-pill.watchlist) {
- border-color: rgba(245, 158, 11, 0.34);
- background: rgba(245, 158, 11, 0.12);
- color: #ffd166;
-}
-
-.root :global(.scan-v4-decision-pill.veto) {
- border-color: rgba(248, 113, 113, 0.34);
- background: rgba(248, 113, 113, 0.12);
- color: #ff8585;
-}
-
-.root :global(.scan-v4-current) {
- display: grid;
- gap: 6px;
- padding-bottom: 12px;
- border-bottom: 1px solid rgba(90, 123, 166, 0.12);
-}
-
-.root :global(.scan-v4-current span) {
- color: #c8d8ec;
- font-size: 13px;
- font-weight: 850;
- line-height: 1.5;
-}
-
-.root :global(.scan-v4-current small) {
- color: #8fb3d8;
- font-size: 12px;
- font-weight: 800;
- line-height: 1.45;
-}
-
-.root :global(.scan-v4-brief-grid) {
- display: grid;
- grid-template-columns: minmax(0, 1.45fr) minmax(260px, 0.85fr);
- gap: 26px;
-}
-
-.root :global(.scan-v4-brief-grid section:first-child) {
- grid-column: auto;
-}
-
-.root :global(.scan-v4-brief-grid ul) {
- display: grid;
- gap: 8px;
- margin: 8px 0 0;
- padding: 0;
- list-style: none;
-}
-
-.root :global(.scan-v4-brief-grid li) {
- position: relative;
- padding-left: 16px;
- color: #aebfd8;
- font-size: 12px;
- font-weight: 800;
- line-height: 1.48;
-}
-
-.root :global(.scan-v4-brief-grid li::before) {
- content: "";
- position: absolute;
- top: 0.65em;
- left: 0;
- width: 5px;
- height: 5px;
- border-radius: 999px;
- background: #4DA3FF;
- box-shadow: 0 0 10px rgba(45, 212, 191, 0.45);
-}
-
-.root :global(.scan-v4-brief-grid section:nth-child(2) li::before) {
- background: #f59e0b;
- box-shadow: 0 0 10px rgba(245, 158, 11, 0.35);
-}
-
-.root :global(details.scan-v4-evidence) {
- padding-top: 11px;
- border-top: 1px solid rgba(90, 123, 166, 0.12);
-}
-
-.root :global(details.scan-v4-evidence summary) {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- min-height: 30px;
- padding: 0 11px;
- border: 1px solid rgba(65, 118, 216, 0.28);
- border-radius: 8px;
- background: rgba(10, 30, 58, 0.52);
- color: #8fbdff;
- font-size: 12px;
- font-weight: 950;
- cursor: pointer;
- list-style: none;
-}
-
-.root :global(details.scan-v4-evidence summary::-webkit-details-marker) {
- display: none;
-}
-
-.root :global(details.scan-v4-evidence summary::after) {
- content: "v";
- color: #8fbdff;
- font-size: 12px;
-}
-
-.root :global(details.scan-v4-evidence[open] summary::after) {
- content: "^";
-}
-
-.root :global(details.scan-v4-evidence > div) {
- margin-top: 10px;
-}
-
-.root :global(.scan-forecast-desk) {
- gap: 16px;
- padding: 16px 18px 20px;
-}
-
-
-.root :global(.scan-ai-workspace) {
- flex: 1;
- min-height: 0;
- max-height: 100%;
- overflow-y: auto;
- overscroll-behavior: contain;
- border: 1px solid rgba(77, 163, 255, 0.16);
- border-radius: 18px;
- background: #0b1220;
- padding: 18px;
-}
-
-.root :global(.scan-ai-workspace::-webkit-scrollbar) {
- width: 8px;
-}
-
-.root :global(.scan-ai-workspace::-webkit-scrollbar-thumb) {
- border-radius: 999px;
- background: rgba(77, 163, 255, 0.28);
-}
-
-.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-table-body::-webkit-scrollbar),
-.root :global(.scan-calendar-view::-webkit-scrollbar),
-.root :global(.scan-detail-panel::-webkit-scrollbar) {
- display: none;
-}
-
-.root :global(.scan-calendar-view),
-.root :global(.scan-detail-panel) {
- scrollbar-width: none;
- -ms-overflow-style: none;
-}
-
-.root :global(.scan-map-view),
-.root :global(.scan-calendar-view),
-.root :global(.scan-settings-view) {
- flex: 1;
- min-height: 0;
-}
-
-.root :global(.scan-map-view) {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.root :global(.scan-map-shell) {
- position: relative;
- flex: 1;
- min-height: 520px;
- border: 1px solid rgba(90, 123, 166, 0.12);
- border-radius: 18px;
- overflow: hidden;
- background: rgba(8, 17, 30, 0.8);
-}
-
-.root :global(.scan-map-shell .map) {
- position: absolute;
- inset: 0;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
- width: 100%;
- height: 100%;
- border: 0;
- border-radius: 18px;
- box-shadow: none;
-}
-
-.root :global(.scan-map-shell .leaflet-container) {
- width: 100%;
- height: 100%;
- background: #060d18;
-}
-
-.root :global(.scan-map-caption) {
- color: #8fa4c3;
- font-size: 13px;
-}
-
-.root :global(.scan-summary-card.wide) {
- grid-column: span 1;
-}
-
-.root :global(.scan-summary-label) {
- font-size: 13px;
- color: #8fa4c3;
-}
-
-.root :global(.scan-summary-value) {
- margin-top: 10px;
- font-size: 28px;
- font-weight: 800;
- color: #eef7ff;
-}
-
-.root :global(.scan-settings-view) {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 14px;
-}
-
-.root :global(.scan-settings-copy) {
- margin-top: 10px;
- color: #c8d8ee;
- font-size: 14px;
- line-height: 1.6;
-}
-
-.root :global(.scan-table-row) {
- display: grid;
- grid-template-columns: minmax(220px, 1.1fr) minmax(128px, 0.68fr) minmax(230px, 1.08fr) minmax(150px, 0.78fr) 92px;
- gap: 10px;
- align-items: center;
- min-height: 132px;
- padding: 14px 18px;
- border: none;
- border-bottom: 1px solid rgba(90, 123, 166, 0.08);
- border-radius: 0;
- background: linear-gradient(180deg, rgba(10, 20, 35, 0.92), rgba(8, 17, 29, 0.92));
- text-align: left;
-}
-
-.root :global(.scan-table-row > *),
-.root :global(.scan-table-header > *) {
- min-width: 0;
-}
-
-.root :global(.scan-table-row:hover) {
- transform: none;
- background: linear-gradient(180deg, rgba(11, 24, 40, 0.96), rgba(8, 18, 31, 0.96));
-}
-
-.root :global(.scan-table-row.selected) {
- background: linear-gradient(180deg, rgba(7, 34, 35, 0.92), rgba(8, 19, 29, 0.92));
- box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.48);
-}
-
-.root :global(.scan-edge-cell) {
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.root :global(.scan-city-cell) {
- display: block;
-}
-
-.root :global(.scan-city-copy) {
- display: flex;
- flex-direction: column;
- gap: 4px;
- min-width: 0;
-}
-
-.root :global(.scan-city-name) {
- font-size: 18px;
- font-weight: 800;
-}
-
-.root :global(.scan-city-sub) {
- font-size: 14px;
- color: #a5bad5;
- overflow: hidden;
- display: -webkit-box;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 2;
-}
-
-.root :global(.scan-time-cell) {
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-.root :global(.scan-time-main) {
- font-size: 16px;
- font-weight: 700;
-}
-
-.root :global(.scan-time-remaining) {
- font-size: 14px;
- color: #9eb3ce;
-}
-
-.root :global(.scan-phase-badge),
-.root :global(.scan-status-badge) {
- width: fit-content;
- padding: 6px 10px;
- border-radius: 999px;
- font-size: 12px;
- font-weight: 700;
- border: 1px solid rgba(255, 255, 255, 0.12);
- text-transform: none;
-}
-
-.root :global(.scan-phase-badge.red) {
- color: #ff7f7f;
- background: rgba(255, 93, 93, 0.1);
-}
-
-.root :global(.scan-phase-badge.green) {
- color: #34e49f;
- background: rgba(23, 217, 139, 0.12);
-}
-
-.root :global(.scan-phase-badge.blue) {
- color: #7cb5ff;
- background: rgba(63, 140, 255, 0.12);
-}
-
-.root :global(.scan-phase-badge.amber) {
- color: #ffc14b;
- background: rgba(255, 176, 32, 0.12);
-}
-
-.root :global(.scan-distribution-preview) {
- display: block;
- min-width: 0;
-}
-
-.root :global(.scan-model-compare) {
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 10px 12px;
- border: 1px solid rgba(86, 120, 168, 0.16);
- border-radius: 12px;
- background: rgba(13, 27, 45, 0.7);
-}
-
-.root :global(.scan-model-primary) {
- display: grid;
- grid-template-columns: minmax(76px, 1fr) auto auto;
- gap: 8px;
- align-items: baseline;
-}
-
-.root :global(.scan-model-primary span),
-.root :global(.scan-model-rows b) {
- color: rgba(146, 168, 197, 0.92);
- font-size: 11px;
- font-weight: 800;
-}
-
-.root :global(.scan-model-primary strong) {
- color: #22eb98;
- font-size: 18px;
- font-weight: 900;
- line-height: 1;
-}
-
-.root :global(.scan-model-primary em) {
- color: #d7e7fb;
- font-size: 13px;
- font-style: normal;
- font-weight: 800;
- white-space: nowrap;
-}
-
-.root :global(.scan-model-rows) {
- display: flex;
- flex-direction: column;
- gap: 5px;
-}
-
-.root :global(.scan-model-rows span) {
- display: grid;
- grid-template-columns: 42px minmax(54px, auto) minmax(0, 1fr);
- gap: 8px;
- align-items: center;
- min-width: 0;
-}
-
-.root :global(.scan-model-rows strong) {
- color: #eef7ff;
- font-size: 13px;
- font-weight: 800;
- white-space: nowrap;
-}
-
-.root :global(.scan-model-rows em) {
- overflow: hidden;
- color: #9fb4cf;
- font-size: 12px;
- font-style: normal;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.root :global(.scan-trade-cell) {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.root :global(.scan-trade-main) {
- font-size: 17px;
- font-weight: 800;
- overflow-wrap: anywhere;
-}
-
-.root :global(.scan-trade-main.buy) {
- color: #26e896;
-}
-
-.root :global(.scan-trade-main.sell) {
- color: #ff6969;
-}
-
-.root :global(.scan-trade-sub),
-.root :global(.scan-trade-note) {
- font-size: 14px;
- color: #a5bad5;
-}
-
-.root :global(.scan-edge-cell) {
- font-size: 18px;
- font-weight: 800;
-}
-
-.root :global(.scan-edge-cell.positive) {
- color: #1de28f;
-}
-
-.root :global(.scan-edge-cell.negative) {
- color: #ff6868;
-}
-
-.root :global(.scan-row-fav) {
- display: none;
-}
-
-.root :global(.scan-view-all-wrap) {
- display: flex;
- justify-content: center;
- padding: 16px;
-}
-
-.root :global(.scan-view-all-button) {
- padding: 12px 18px;
- font-weight: 700;
-}
-
-.root :global(.scan-ai-log-panel) {
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: 12px 14px;
- border: 1px solid rgba(68, 100, 150, 0.18);
- border-radius: 16px;
- background:
- linear-gradient(180deg, rgba(9, 23, 38, 0.92), rgba(7, 17, 30, 0.92)),
- radial-gradient(circle at 0 0, rgba(34, 211, 238, 0.12), transparent 34%);
-}
-
-.root :global(.scan-ai-log-panel.compact) {
- flex: 0 0 auto;
- display: block;
- margin-top: 10px;
- padding: 0;
- overflow: hidden;
- border-radius: 14px;
- background: rgba(7, 17, 30, 0.72);
-}
-
-.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- min-height: 42px;
- padding: 0 14px;
- color: #9fb4cf;
- font-size: 12px;
- font-weight: 900;
- cursor: pointer;
- list-style: none;
-}
-
-.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary::-webkit-details-marker) {
- display: none;
-}
-
-.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary span) {
- color: #c7d8ef;
-}
-
-.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary strong) {
- color: #7fb4ff;
- font-size: 12px;
- font-weight: 900;
-}
-
-.root :global(.scan-ai-log-panel.compact[open] .scan-ai-log-list) {
- padding: 0 12px 12px;
-}
-
-.root :global(.scan-ai-analysis-view) {
- display: flex;
- flex-direction: column;
- gap: 14px;
- min-height: 520px;
-}
-
-.root :global(.scan-ai-analysis-view.empty) {
- min-height: 620px;
-}
-
-.root :global(.scan-ai-summary-card) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
- padding: 16px 18px;
- border: 1px solid rgba(34, 211, 238, 0.22);
- border-radius: 18px;
- background:
- linear-gradient(135deg, rgba(8, 32, 52, 0.94), rgba(8, 21, 38, 0.94)),
- radial-gradient(circle at 0 0, rgba(45, 212, 191, 0.15), transparent 32%);
-}
-
-.root :global(.scan-ai-summary-card strong) {
- color: #ecf8ff;
- font-size: 16px;
- font-weight: 900;
-}
-
-.root :global(.scan-ai-summary-card p) {
- margin: 6px 0 0;
- color: #a6bbd7;
- font-size: 13px;
- font-weight: 700;
- line-height: 1.55;
-}
-
-.root :global(.scan-ai-inline-button) {
- min-height: 42px;
- display: inline-flex;
- align-items: center;
- gap: 8px;
- padding: 0 16px;
- border: 1px solid rgba(45, 212, 191, 0.3);
- border-radius: 12px;
- color: #d9fff6;
- background: linear-gradient(135deg, rgba(13, 148, 136, 0.36), rgba(8, 47, 73, 0.4));
- font-size: 13px;
- font-weight: 900;
- cursor: pointer;
-}
-
-.root :global(.scan-ai-inline-button:disabled) {
- cursor: not-allowed;
- opacity: 0.48;
-}
-
-.root :global(.scan-ai-log-panel.in-tab) {
- margin: 0;
-}
-
-.root :global(.scan-ai-city-list) {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-.root :global(.scan-ai-city-card) {
- border: 1px solid rgba(68, 100, 150, 0.2);
- border-radius: 18px;
- overflow: hidden;
- background: rgba(10, 24, 42, 0.82);
-}
-
-.root :global(.scan-ai-city-head) {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 14px;
- padding: 16px 18px;
- background: rgba(15, 34, 57, 0.9);
-}
-
-.root :global(.scan-ai-city-head strong) {
- color: #f2f8ff;
- font-size: 17px;
- font-weight: 900;
-}
-
-.root :global(.scan-ai-city-head p) {
- margin: 6px 0 0;
- color: #9fb4d2;
- font-size: 13px;
- font-weight: 700;
- line-height: 1.45;
-}
-
-.root :global(.scan-ai-city-head span) {
- flex: 0 0 auto;
- padding: 5px 9px;
- border-radius: 999px;
- color: #68f3c8;
- background: rgba(16, 185, 129, 0.12);
- border: 1px solid rgba(16, 185, 129, 0.2);
- font-size: 11px;
- font-weight: 900;
-}
-
-.root :global(.scan-ai-cluster-note) {
- margin: 0 18px 14px;
- padding: 10px 12px;
- border: 1px solid rgba(34, 211, 238, 0.18);
- border-radius: 12px;
- color: #b7cbe6;
- background: rgba(6, 18, 32, 0.5);
- font-size: 12px;
- font-weight: 800;
- line-height: 1.45;
-}
-
-.root :global(.scan-ai-contracts) {
- display: flex;
- flex-direction: column;
- padding: 0 12px 12px;
-}
-
-.root :global(.scan-ai-contract) {
- display: grid;
- grid-template-columns: minmax(220px, 1fr) auto;
- gap: 10px 16px;
- align-items: center;
- padding: 12px;
- border-top: 1px solid rgba(68, 100, 150, 0.16);
-}
-
-.root :global(.scan-ai-contract b) {
- display: block;
- color: #edf7ff;
- font-size: 13px;
- font-weight: 900;
-}
-
-.root :global(.scan-ai-contract small) {
- display: block;
- margin-top: 3px;
- color: #8fa8c7;
- font-size: 11px;
- font-weight: 800;
-}
-
-.root :global(.scan-ai-contract > span) {
- justify-self: end;
- padding: 5px 9px;
- border-radius: 999px;
- color: #a8bedc;
- background: rgba(68, 100, 150, 0.16);
- font-size: 11px;
- font-weight: 900;
-}
-
-.root :global(.scan-ai-contract.approve > span) {
- color: #23f0a8;
- background: rgba(16, 185, 129, 0.14);
-}
-
-.root :global(.scan-ai-contract.veto > span) {
- color: #ff8a8a;
- background: rgba(248, 113, 113, 0.12);
-}
-
-.root :global(.scan-ai-contract.downgrade > span),
-.root :global(.scan-ai-contract.watchlist > span) {
- color: #ffd166;
- background: rgba(245, 158, 11, 0.12);
-}
-
-.root :global(.scan-ai-contract p) {
- grid-column: 1 / -1;
- margin: 0;
- color: #9fb4d2;
- font-size: 12px;
- font-weight: 700;
- line-height: 1.45;
-}
-
-.root :global(.scan-ai-log-head) {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
-}
-
-.root :global(.scan-ai-log-head strong) {
- color: #e9f5ff;
- font-size: 13px;
- font-weight: 900;
-}
-
-.root :global(.scan-ai-log-head span) {
- color: #8fa8c7;
- font-size: 12px;
- font-weight: 800;
-}
-
-.root :global(.scan-ai-log-list) {
- display: flex;
- gap: 8px;
- overflow-x: auto;
- padding-bottom: 2px;
-}
-
-.root :global(.scan-ai-log-list::-webkit-scrollbar) {
- height: 5px;
-}
-
-.root :global(.scan-ai-log-list::-webkit-scrollbar-thumb) {
- background: rgba(88, 125, 180, 0.26);
- border-radius: 999px;
-}
-
-.root :global(.scan-ai-log-item) {
- display: grid;
- grid-template-columns: auto minmax(180px, 1fr);
- gap: 8px;
- min-width: 260px;
- padding: 9px 10px;
- border: 1px solid rgba(68, 100, 150, 0.16);
- border-radius: 12px;
- background: rgba(14, 29, 48, 0.76);
-}
-
-.root :global(.scan-ai-log-item b) {
- display: block;
- color: #edf7ff;
- font-size: 12px;
- font-weight: 900;
- line-height: 1.25;
-}
-
-.root :global(.scan-ai-log-item small) {
- display: block;
- margin-top: 3px;
- color: #9fb4d2;
- font-size: 11px;
- font-weight: 700;
- line-height: 1.35;
-}
-
-.root :global(.scan-ai-log-time) {
- color: #7892b1;
- font-size: 11px;
- font-weight: 900;
- font-variant-numeric: tabular-nums;
-}
-
-.root :global(.scan-ai-log-item.info) {
- border-color: rgba(56, 189, 248, 0.22);
-}
-
-.root :global(.scan-ai-log-item.success) {
- border-color: rgba(16, 185, 129, 0.32);
-}
-
-.root :global(.scan-ai-log-item.warning) {
- border-color: rgba(245, 158, 11, 0.34);
-}
-
-.root :global(.scan-ai-log-item.error) {
- border-color: rgba(248, 113, 113, 0.34);
-}
-
-.root :global(.scan-ai-log-empty) {
- color: #8fa8c7;
- font-size: 12px;
- font-weight: 800;
-}
-
-.root :global(.scan-detail-panel) {
- width: auto;
- min-width: 0;
- padding: 18px 16px;
- border-left: 1px solid rgba(82, 114, 161, 0.18);
-}
-
-.root :global(.scan-detail-header) {
- padding: 0 0 16px;
- justify-content: space-between;
- border-bottom: none;
- margin-bottom: 10px;
-}
-
-.root :global(.scan-detail-top) {
- display: flex;
- gap: 0;
- align-items: center;
-}
-
-.root :global(.scan-detail-title-wrap) {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.root :global(.scan-detail-icon-button) {
- width: 40px;
- height: 40px;
- display: grid;
- place-items: center;
-}
-
-.root :global(.scan-detail-hero-placeholder) {
- width: 66px;
- height: 66px;
- border-radius: 14px;
- background: linear-gradient(135deg, #314d7b, #0d2038 60%, #08131f);
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
-}
-
-.root :global(.scan-detail-city-name) {
- font-size: 22px;
- font-weight: 800;
-}
-
-.root :global(.scan-detail-city-sub) {
- font-size: 14px;
- color: #a4bad6;
-}
-
-.root :global(.scan-detail-volume-row) {
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 12px;
- margin-bottom: 16px;
-}
-
-.root :global(.scan-detail-volume-big) {
- font-size: 28px;
- font-weight: 800;
-}
-
-.root :global(.scan-detail-volume-caption) {
- margin-top: 4px;
- font-size: 14px;
- color: #90a7c4;
-}
-
-.root :global(.scan-detail-action-button) {
- padding: 11px 14px;
- font-weight: 700;
-}
-
-.root :global(.scan-detail-primary-actions) {
- display: flex;
- margin-bottom: 16px;
-}
-
-.root :global(.scan-detail-analysis-button) {
- width: 100%;
- border: 1px solid rgba(23, 217, 139, 0.3);
- border-radius: 14px;
- background: rgba(23, 217, 139, 0.12);
- color: #27ea98;
- padding: 12px 14px;
- font-size: 14px;
- font-weight: 800;
- text-align: center;
-}
-
-.root :global(.scan-detail-analysis-button:hover) {
- background: rgba(23, 217, 139, 0.18);
- border-color: rgba(23, 217, 139, 0.4);
-}
-
-.root :global(.scan-detail-section) {
- padding: 16px 0;
- border-bottom: 1px solid rgba(90, 123, 166, 0.12);
-}
-
-.root :global(.scan-detail-section-title) {
- font-size: 16px;
- font-weight: 800;
- color: #e8f2ff;
- text-transform: none;
- letter-spacing: 0;
- margin-bottom: 12px;
-}
-
-.root :global(.scan-kv-list) {
- display: flex;
- flex-direction: column;
- gap: 8px;
-}
-
-.root :global(.scan-kv) {
- display: flex;
- justify-content: space-between;
- gap: 14px;
- padding: 4px 0;
- color: #dbe7f8;
-}
-
-.root :global(.scan-kv span:first-child) {
- color: #91a8c6;
-}
-
-.root :global(.scan-kv strong) {
- font-size: 15px;
-}
-
-.root :global(.scan-kv strong.warn) {
- color: #ffbf39;
-}
-
-.root :global(.scan-kv strong.danger) {
- color: #ff7f7f;
-}
-
-.root :global(.scan-timeline-head) {
- display: flex;
- justify-content: space-between;
- color: #a1b6d2;
- font-size: 13px;
-}
-
-.root :global(.scan-timeline-bar) {
- position: relative;
- height: 8px;
- margin-top: 8px;
- border-radius: 999px;
- background: linear-gradient(90deg, #e83c3c 0 72%, rgba(255, 255, 255, 0.18) 72% 100%);
-}
-
-.root :global(.scan-timeline-knob) {
- position: absolute;
- top: 50%;
- width: 16px;
- height: 16px;
- border-radius: 50%;
- background: #dfe8f6;
- box-shadow: 0 0 0 4px rgba(223, 232, 246, 0.12);
- transform: translate(-50%, -50%);
-}
-
-.root :global(.scan-timeline-caption) {
- margin-top: 8px;
- color: #a1b6d2;
- font-size: 13px;
- text-align: right;
-}
-
-.root :global(.scan-chart-legend) {
- display: flex;
- gap: 16px;
- margin-bottom: 14px;
- color: #a8bdd8;
- font-size: 13px;
-}
-
-.root :global(.scan-chart-legend .dot) {
- display: inline-block;
- width: 10px;
- height: 10px;
- border-radius: 50%;
- margin-right: 6px;
- vertical-align: middle;
-}
-
-.root :global(.scan-chart-legend .dot.green) {
- background: #1ce393;
-}
-
-.root :global(.scan-chart-legend .dot.blue) {
- background: #4ca2ff;
-}
-
-.root :global(.scan-chart-bars) {
- display: flex;
- align-items: flex-end;
- justify-content: space-between;
- gap: 8px;
- height: 170px;
- padding: 8px 4px 0;
-}
-
-.root :global(.scan-chart-group) {
- flex: 1;
- display: flex;
- justify-content: center;
- align-items: flex-end;
- gap: 6px;
- position: relative;
-}
-
-.root :global(.scan-chart-group.highlighted .scan-chart-label) {
- color: #22eb98;
- font-weight: 800;
-}
-
-.root :global(.scan-chart-col) {
- width: 20px;
- border-radius: 8px 8px 0 0;
-}
-
-.root :global(.scan-chart-col.model) {
- background: linear-gradient(180deg, #1ce393, #0b8d5a);
-}
-
-.root :global(.scan-chart-col.market) {
- background: linear-gradient(180deg, #4ca2ff, #2263cf);
-}
-
-.root :global(.scan-chart-label) {
- position: absolute;
- bottom: -24px;
- left: 50%;
- transform: translateX(-50%);
- color: #93a8c4;
- font-size: 13px;
-}
-
-.root :global(.scan-trade-cards) {
- grid-template-columns: 1fr 1fr;
- gap: 12px;
-}
-
-.root :global(.scan-trade-card) {
- padding: 16px;
- border-radius: 16px;
- background: rgba(11, 24, 42, 0.92);
- border: 1px solid rgba(255, 255, 255, 0.08);
-}
-
-.root :global(.scan-trade-card.buy) {
- box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.22);
-}
-
-.root :global(.scan-trade-card.sell) {
- box-shadow: inset 0 0 0 1px rgba(255, 93, 93, 0.22);
-}
-
-.root :global(.scan-trade-card-title) {
- font-size: 16px;
- font-weight: 800;
-}
-
-.root :global(.scan-trade-card.buy .scan-trade-card-title) {
- color: #20e391;
-}
-
-.root :global(.scan-trade-card.sell .scan-trade-card-title) {
- color: #ff7070;
-}
-
-.root :global(.scan-trade-card p) {
- margin: 10px 0 0;
- color: #d5e1f5;
- font-size: 14px;
-}
-
-.root :global(.scan-trade-card p.positive) {
- color: #20e391;
-}
-
-.root :global(.scan-trade-card p.negative) {
- color: #ff7070;
-}
-
-.root :global(.scan-detail-score-block) {
- margin-top: 18px;
- padding-top: 16px;
-}
-
-.root :global(.scan-detail-score-head) {
- display: flex;
- justify-content: space-between;
- align-items: flex-end;
- gap: 12px;
-}
-
-.root :global(.scan-detail-score-label-text) {
- font-size: 16px;
- font-weight: 800;
-}
-
-.root :global(.scan-detail-score-meta) {
- margin-top: 6px;
- color: #89a0bf;
- font-size: 13px;
-}
-
-.root :global(.scan-detail-score-value) {
- font-size: 40px;
- font-weight: 800;
-}
-
-.root :global(.scan-detail-score-value.green) {
- color: #23e694;
-}
-
-.root :global(.scan-detail-score-value.yellow) {
- color: #ffbe26;
-}
-
-.root :global(.scan-detail-score-value.red) {
- color: #ff6f86;
-}
-
-.root :global(.scan-detail-score-value span) {
- font-size: 22px;
- color: #9fb5d2;
-}
-
-.root :global(.scan-detail-score-line) {
- height: 6px;
- margin-top: 10px;
- overflow: hidden;
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.08);
-}
-
-.root :global(.scan-detail-score-line span) {
- display: block;
- height: 100%;
- border-radius: 999px;
- background: linear-gradient(90deg, #1be392, #12b46d);
- box-shadow: 0 0 18px rgba(27, 227, 146, 0.22);
-}
-
-.root :global(.scan-empty-state) {
- flex: 1;
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- padding: 48px 32px;
- text-align: center;
-}
-
-.root :global(.scan-empty-title) {
- font-size: 20px;
- font-weight: 800;
- color: #e8f2ff;
-}
-
-.root :global(.scan-empty-copy) {
- margin-top: 10px;
- font-size: 14px;
- color: #8fa4c3;
- line-height: 1.6;
-}
-
-.root :global(.scan-loading-state) {
- flex: 1;
- min-height: 520px;
- display: grid;
- place-items: center;
- padding: 48px 32px;
- text-align: center;
-}
-
-.root :global(.scan-loading-signal) {
- position: relative;
- width: min(100%, 420px);
- display: grid;
- justify-items: center;
- gap: 18px;
- padding: 28px 30px;
- border: 1px solid rgba(125, 211, 252, 0.14);
- border-radius: 28px;
- background:
- radial-gradient(circle at 18% 18%, rgba(77, 163, 255, 0.18), transparent 34%),
- linear-gradient(145deg, rgba(12, 20, 36, 0.92), rgba(5, 11, 22, 0.74));
- box-shadow:
- 0 22px 70px rgba(0, 0, 0, 0.3),
- inset 0 1px 0 rgba(255, 255, 255, 0.05);
- overflow: hidden;
-}
-
-.root :global(.scan-loading-signal::before) {
- content: "";
- position: absolute;
- inset: 0;
- background: linear-gradient(
- 100deg,
- transparent 0%,
- rgba(125, 211, 252, 0.08) 42%,
- rgba(34, 197, 94, 0.1) 50%,
- transparent 58%
- );
- transform: translateX(-120%);
- animation: scan-loading-sweep 2.6s ease-in-out infinite;
- pointer-events: none;
-}
-
-.root :global(.scan-loading-signal.compact) {
- width: min(100%, 340px);
- gap: 12px;
- padding: 18px 20px;
- border-radius: 22px;
- background:
- radial-gradient(circle at 20% 20%, rgba(77, 163, 255, 0.14), transparent 36%),
- rgba(10, 18, 32, 0.62);
- box-shadow: none;
-}
-
-.root :global(.scan-loading-decision-flow) {
- position: relative;
- z-index: 1;
- display: grid;
- grid-template-columns: 16px minmax(70px, 1fr) 16px minmax(70px, 1fr) 16px;
- align-items: center;
- width: min(100%, 300px);
-}
-
-.root :global(.scan-loading-node) {
- width: 16px;
- height: 16px;
- border: 1px solid rgba(226, 232, 240, 0.16);
- border-radius: 999px;
- background: #0b1220;
- box-shadow: 0 0 0 5px rgba(77, 163, 255, 0.05);
-}
-
-.root :global(.scan-loading-node.hot) {
- background: linear-gradient(135deg, #f97316, #fde047);
- box-shadow: 0 0 24px rgba(251, 146, 60, 0.26);
-}
-
-.root :global(.scan-loading-node.market) {
- background: linear-gradient(135deg, #38bdf8, #4da3ff);
- box-shadow: 0 0 24px rgba(56, 189, 248, 0.26);
-}
-
-.root :global(.scan-loading-node.action) {
- background: linear-gradient(135deg, #22c55e, #1be392);
- box-shadow: 0 0 24px rgba(34, 197, 94, 0.3);
- animation: scan-loading-node-breathe 1.8s ease-in-out infinite;
-}
-
-.root :global(.scan-loading-rail) {
- position: relative;
- height: 2px;
- overflow: hidden;
- background: rgba(148, 163, 184, 0.16);
-}
-
-.root :global(.scan-loading-rail i) {
- position: absolute;
- inset: 0;
- width: 48%;
- border-radius: 999px;
- background: linear-gradient(90deg, transparent, #7dd3fc, #22c55e);
- transform: translateX(-110%);
- animation: scan-loading-rail-flow 1.55s ease-in-out infinite;
-}
-
-.root :global(.scan-loading-rail:nth-of-type(4) i) {
- animation-delay: 0.45s;
-}
-
-.root :global(.scan-loading-copy-block) {
- position: relative;
- z-index: 1;
- display: grid;
- gap: 7px;
- justify-items: center;
-}
-
-.root :global(.scan-loading-copy-block strong) {
- color: #e6edf3;
- font-size: 18px;
- font-weight: 900;
- letter-spacing: -0.02em;
-}
-
-.root :global(.scan-loading-copy-block span) {
- max-width: 320px;
- color: #9fb2c7;
- font-size: 13px;
- font-weight: 650;
- line-height: 1.55;
-}
-
-.root :global(.scan-loading-signal-bars) {
- position: relative;
- z-index: 1;
- display: flex;
- align-items: end;
- gap: 5px;
- height: 28px;
-}
-
-.root :global(.scan-loading-signal-bars span) {
- width: 7px;
- min-height: 8px;
- border-radius: 999px 999px 4px 4px;
- background: linear-gradient(180deg, #7dd3fc, #4da3ff 52%, #1be392);
- opacity: 0.42;
- transform-origin: center bottom;
- animation: scan-loading-bars 1.35s ease-in-out infinite;
-}
-
-.root :global(.scan-loading-signal-bars span:nth-child(1)) {
- height: 12px;
-}
-
-.root :global(.scan-loading-signal-bars span:nth-child(2)) {
- height: 22px;
- animation-delay: 0.16s;
-}
-
-.root :global(.scan-loading-signal-bars span:nth-child(3)) {
- height: 16px;
- animation-delay: 0.32s;
-}
-
-.root :global(.scan-loading-signal-bars span:nth-child(4)) {
- height: 25px;
- animation-delay: 0.48s;
-}
-
-@keyframes scan-loading-sweep {
- 0% {
- transform: translateX(-130%);
- }
- 46%,
- 100% {
- transform: translateX(130%);
- }
-}
-
-@keyframes scan-loading-rail-flow {
- 0% {
- transform: translateX(-110%);
- opacity: 0.15;
- }
- 45% {
- opacity: 1;
- }
- 100% {
- transform: translateX(220%);
- opacity: 0.15;
- }
-}
-
-@keyframes scan-loading-node-breathe {
- 0%,
- 100% {
- transform: scale(0.92);
- filter: saturate(0.9);
- }
- 50% {
- transform: scale(1.08);
- filter: saturate(1.25);
- }
-}
-
-@keyframes scan-loading-bars {
- 0%,
- 100% {
- opacity: 0.38;
- transform: scaleY(0.72);
- }
- 50% {
- opacity: 0.95;
- transform: scaleY(1);
- }
-}
diff --git a/frontend/components/dashboard/ScanTerminalBoard.module.css b/frontend/components/dashboard/ScanTerminalBoard.module.css
new file mode 100644
index 00000000..7ca9acd8
--- /dev/null
+++ b/frontend/components/dashboard/ScanTerminalBoard.module.css
@@ -0,0 +1,651 @@
+/* Scan terminal scroll containers, map/calendar/settings views, table rows, and AI analysis workspace. */
+
+.root :global(.scan-detail-panel) {
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.root :global(.scan-map-view),
+.root :global(.scan-calendar-view),
+.root :global(.scan-settings-view) {
+ flex: 1;
+ min-height: 0;
+}
+
+.root :global(.scan-map-view) {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.root :global(.scan-map-shell) {
+ position: relative;
+ flex: 1;
+ min-height: 520px;
+ border: 1px solid rgba(90, 123, 166, 0.12);
+ border-radius: 18px;
+ overflow: hidden;
+ background: rgba(8, 17, 30, 0.8);
+}
+
+.root :global(.scan-map-shell .map) {
+ position: absolute;
+ inset: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ border-radius: 18px;
+ box-shadow: none;
+}
+
+.root :global(.scan-map-shell .leaflet-container) {
+ width: 100%;
+ height: 100%;
+ background: #060d18;
+}
+
+.root :global(.scan-map-caption) {
+ color: #8fa4c3;
+ font-size: 13px;
+}
+
+.root :global(.scan-summary-card.wide) {
+ grid-column: span 1;
+}
+
+.root :global(.scan-summary-label) {
+ font-size: 13px;
+ color: #8fa4c3;
+}
+
+.root :global(.scan-summary-value) {
+ margin-top: 10px;
+ font-size: 28px;
+ font-weight: 800;
+ color: #eef7ff;
+}
+
+.root :global(.scan-settings-view) {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 14px;
+}
+
+.root :global(.scan-settings-copy) {
+ margin-top: 10px;
+ color: #c8d8ee;
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+.root :global(.scan-table-row) {
+ display: grid;
+ grid-template-columns: minmax(220px, 1.1fr) minmax(128px, 0.68fr) minmax(230px, 1.08fr) minmax(150px, 0.78fr) 92px;
+ gap: 10px;
+ align-items: center;
+ min-height: 132px;
+ padding: 14px 18px;
+ border: none;
+ border-bottom: 1px solid rgba(90, 123, 166, 0.08);
+ border-radius: 0;
+ background: linear-gradient(180deg, rgba(10, 20, 35, 0.92), rgba(8, 17, 29, 0.92));
+ text-align: left;
+}
+
+.root :global(.scan-table-row > *),
+.root :global(.scan-table-header > *) {
+ min-width: 0;
+}
+
+.root :global(.scan-table-row:hover) {
+ transform: none;
+ background: linear-gradient(180deg, rgba(11, 24, 40, 0.96), rgba(8, 18, 31, 0.96));
+}
+
+.root :global(.scan-table-row.selected) {
+ background: linear-gradient(180deg, rgba(7, 34, 35, 0.92), rgba(8, 19, 29, 0.92));
+ box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.48);
+}
+
+.root :global(.scan-edge-cell) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.root :global(.scan-city-cell) {
+ display: block;
+}
+
+.root :global(.scan-city-copy) {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+}
+
+.root :global(.scan-city-name) {
+ font-size: 18px;
+ font-weight: 800;
+}
+
+.root :global(.scan-city-sub) {
+ font-size: 14px;
+ color: #a5bad5;
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+}
+
+.root :global(.scan-time-cell) {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.root :global(.scan-time-main) {
+ font-size: 16px;
+ font-weight: 700;
+}
+
+.root :global(.scan-time-remaining) {
+ font-size: 14px;
+ color: #9eb3ce;
+}
+
+.root :global(.scan-phase-badge),
+.root :global(.scan-status-badge) {
+ width: fit-content;
+ padding: 6px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 700;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ text-transform: none;
+}
+
+.root :global(.scan-phase-badge.red) {
+ color: #ff7f7f;
+ background: rgba(255, 93, 93, 0.1);
+}
+
+.root :global(.scan-phase-badge.green) {
+ color: #34e49f;
+ background: rgba(23, 217, 139, 0.12);
+}
+
+.root :global(.scan-phase-badge.blue) {
+ color: #7cb5ff;
+ background: rgba(63, 140, 255, 0.12);
+}
+
+.root :global(.scan-phase-badge.amber) {
+ color: #ffc14b;
+ background: rgba(255, 176, 32, 0.12);
+}
+
+.root :global(.scan-distribution-preview) {
+ display: block;
+ min-width: 0;
+}
+
+.root :global(.scan-model-compare) {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 10px 12px;
+ border: 1px solid rgba(86, 120, 168, 0.16);
+ border-radius: 12px;
+ background: rgba(13, 27, 45, 0.7);
+}
+
+.root :global(.scan-model-primary) {
+ display: grid;
+ grid-template-columns: minmax(76px, 1fr) auto auto;
+ gap: 8px;
+ align-items: baseline;
+}
+
+.root :global(.scan-model-primary span),
+.root :global(.scan-model-rows b) {
+ color: rgba(146, 168, 197, 0.92);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.root :global(.scan-model-primary strong) {
+ color: #22eb98;
+ font-size: 18px;
+ font-weight: 900;
+ line-height: 1;
+}
+
+.root :global(.scan-model-primary em) {
+ color: #d7e7fb;
+ font-size: 13px;
+ font-style: normal;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.root :global(.scan-model-rows) {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+.root :global(.scan-model-rows span) {
+ display: grid;
+ grid-template-columns: 42px minmax(54px, auto) minmax(0, 1fr);
+ gap: 8px;
+ align-items: center;
+ min-width: 0;
+}
+
+.root :global(.scan-model-rows strong) {
+ color: #eef7ff;
+ font-size: 13px;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.root :global(.scan-model-rows em) {
+ overflow: hidden;
+ color: #9fb4cf;
+ font-size: 12px;
+ font-style: normal;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.root :global(.scan-trade-cell) {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.root :global(.scan-trade-main) {
+ font-size: 17px;
+ font-weight: 800;
+ overflow-wrap: anywhere;
+}
+
+.root :global(.scan-trade-main.buy) {
+ color: #26e896;
+}
+
+.root :global(.scan-trade-main.sell) {
+ color: #ff6969;
+}
+
+.root :global(.scan-trade-sub),
+.root :global(.scan-trade-note) {
+ font-size: 14px;
+ color: #a5bad5;
+}
+
+.root :global(.scan-edge-cell) {
+ font-size: 18px;
+ font-weight: 800;
+}
+
+.root :global(.scan-edge-cell.positive) {
+ color: #1de28f;
+}
+
+.root :global(.scan-edge-cell.negative) {
+ color: #ff6868;
+}
+
+.root :global(.scan-row-fav) {
+ display: none;
+}
+
+.root :global(.scan-view-all-wrap) {
+ display: flex;
+ justify-content: center;
+ padding: 16px;
+}
+
+.root :global(.scan-view-all-button) {
+ padding: 12px 18px;
+ font-weight: 700;
+}
+
+.root :global(.scan-ai-log-panel) {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 12px 14px;
+ border: 1px solid rgba(68, 100, 150, 0.18);
+ border-radius: 16px;
+ background:
+ linear-gradient(180deg, rgba(9, 23, 38, 0.92), rgba(7, 17, 30, 0.92)),
+ radial-gradient(circle at 0 0, rgba(34, 211, 238, 0.12), transparent 34%);
+}
+
+.root :global(.scan-ai-log-panel.compact) {
+ flex: 0 0 auto;
+ display: block;
+ margin-top: 10px;
+ padding: 0;
+ overflow: hidden;
+ border-radius: 14px;
+ background: rgba(7, 17, 30, 0.72);
+}
+
+.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ min-height: 42px;
+ padding: 0 14px;
+ color: #9fb4cf;
+ font-size: 12px;
+ font-weight: 900;
+ cursor: pointer;
+ list-style: none;
+}
+
+.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary::-webkit-details-marker) {
+ display: none;
+}
+
+.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary span) {
+ color: #c7d8ef;
+}
+
+.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary strong) {
+ color: #7fb4ff;
+ font-size: 12px;
+ font-weight: 900;
+}
+
+.root :global(.scan-ai-log-panel.compact[open] .scan-ai-log-list) {
+ padding: 0 12px 12px;
+}
+
+.root :global(.scan-ai-analysis-view) {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ min-height: 520px;
+}
+
+.root :global(.scan-ai-analysis-view.empty) {
+ min-height: 620px;
+}
+
+.root :global(.scan-ai-summary-card) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 16px 18px;
+ border: 1px solid rgba(34, 211, 238, 0.22);
+ border-radius: 18px;
+ background:
+ linear-gradient(135deg, rgba(8, 32, 52, 0.94), rgba(8, 21, 38, 0.94)),
+ radial-gradient(circle at 0 0, rgba(45, 212, 191, 0.15), transparent 32%);
+}
+
+.root :global(.scan-ai-summary-card strong) {
+ color: #ecf8ff;
+ font-size: 16px;
+ font-weight: 900;
+}
+
+.root :global(.scan-ai-summary-card p) {
+ margin: 6px 0 0;
+ color: #a6bbd7;
+ font-size: 13px;
+ font-weight: 700;
+ line-height: 1.55;
+}
+
+.root :global(.scan-ai-inline-button) {
+ min-height: 42px;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 16px;
+ border: 1px solid rgba(45, 212, 191, 0.3);
+ border-radius: 12px;
+ color: #d9fff6;
+ background: linear-gradient(135deg, rgba(13, 148, 136, 0.36), rgba(8, 47, 73, 0.4));
+ font-size: 13px;
+ font-weight: 900;
+ cursor: pointer;
+}
+
+.root :global(.scan-ai-inline-button:disabled) {
+ cursor: not-allowed;
+ opacity: 0.48;
+}
+
+.root :global(.scan-ai-log-panel.in-tab) {
+ margin: 0;
+}
+
+.root :global(.scan-ai-city-list) {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.root :global(.scan-ai-city-card) {
+ border: 1px solid rgba(68, 100, 150, 0.2);
+ border-radius: 18px;
+ overflow: hidden;
+ background: rgba(10, 24, 42, 0.82);
+}
+
+.root :global(.scan-ai-city-head) {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 16px 18px;
+ background: rgba(15, 34, 57, 0.9);
+}
+
+.root :global(.scan-ai-city-head strong) {
+ color: #f2f8ff;
+ font-size: 17px;
+ font-weight: 900;
+}
+
+.root :global(.scan-ai-city-head p) {
+ margin: 6px 0 0;
+ color: #9fb4d2;
+ font-size: 13px;
+ font-weight: 700;
+ line-height: 1.45;
+}
+
+.root :global(.scan-ai-city-head span) {
+ flex: 0 0 auto;
+ padding: 5px 9px;
+ border-radius: 999px;
+ color: #68f3c8;
+ background: rgba(16, 185, 129, 0.12);
+ border: 1px solid rgba(16, 185, 129, 0.2);
+ font-size: 11px;
+ font-weight: 900;
+}
+
+.root :global(.scan-ai-cluster-note) {
+ margin: 0 18px 14px;
+ padding: 10px 12px;
+ border: 1px solid rgba(34, 211, 238, 0.18);
+ border-radius: 12px;
+ color: #b7cbe6;
+ background: rgba(6, 18, 32, 0.5);
+ font-size: 12px;
+ font-weight: 800;
+ line-height: 1.45;
+}
+
+.root :global(.scan-ai-contracts) {
+ display: flex;
+ flex-direction: column;
+ padding: 0 12px 12px;
+}
+
+.root :global(.scan-ai-contract) {
+ display: grid;
+ grid-template-columns: minmax(220px, 1fr) auto;
+ gap: 10px 16px;
+ align-items: center;
+ padding: 12px;
+ border-top: 1px solid rgba(68, 100, 150, 0.16);
+}
+
+.root :global(.scan-ai-contract b) {
+ display: block;
+ color: #edf7ff;
+ font-size: 13px;
+ font-weight: 900;
+}
+
+.root :global(.scan-ai-contract small) {
+ display: block;
+ margin-top: 3px;
+ color: #8fa8c7;
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.root :global(.scan-ai-contract > span) {
+ justify-self: end;
+ padding: 5px 9px;
+ border-radius: 999px;
+ color: #a8bedc;
+ background: rgba(68, 100, 150, 0.16);
+ font-size: 11px;
+ font-weight: 900;
+}
+
+.root :global(.scan-ai-contract.approve > span) {
+ color: #23f0a8;
+ background: rgba(16, 185, 129, 0.14);
+}
+
+.root :global(.scan-ai-contract.veto > span) {
+ color: #ff8a8a;
+ background: rgba(248, 113, 113, 0.12);
+}
+
+.root :global(.scan-ai-contract.downgrade > span),
+.root :global(.scan-ai-contract.watchlist > span) {
+ color: #ffd166;
+ background: rgba(245, 158, 11, 0.12);
+}
+
+.root :global(.scan-ai-contract p) {
+ grid-column: 1 / -1;
+ margin: 0;
+ color: #9fb4d2;
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 1.45;
+}
+
+.root :global(.scan-ai-log-head) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.root :global(.scan-ai-log-head strong) {
+ color: #e9f5ff;
+ font-size: 13px;
+ font-weight: 900;
+}
+
+.root :global(.scan-ai-log-head span) {
+ color: #8fa8c7;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.root :global(.scan-ai-log-list) {
+ display: flex;
+ gap: 8px;
+ overflow-x: auto;
+ padding-bottom: 2px;
+}
+
+.root :global(.scan-ai-log-list::-webkit-scrollbar) {
+ height: 5px;
+}
+
+.root :global(.scan-ai-log-list::-webkit-scrollbar-thumb) {
+ background: rgba(88, 125, 180, 0.26);
+ border-radius: 999px;
+}
+
+.root :global(.scan-ai-log-item) {
+ display: grid;
+ grid-template-columns: auto minmax(180px, 1fr);
+ gap: 8px;
+ min-width: 260px;
+ padding: 9px 10px;
+ border: 1px solid rgba(68, 100, 150, 0.16);
+ border-radius: 12px;
+ background: rgba(14, 29, 48, 0.76);
+}
+
+.root :global(.scan-ai-log-item b) {
+ display: block;
+ color: #edf7ff;
+ font-size: 12px;
+ font-weight: 900;
+ line-height: 1.25;
+}
+
+.root :global(.scan-ai-log-item small) {
+ display: block;
+ margin-top: 3px;
+ color: #9fb4d2;
+ font-size: 11px;
+ font-weight: 700;
+ line-height: 1.35;
+}
+
+.root :global(.scan-ai-log-time) {
+ color: #7892b1;
+ font-size: 11px;
+ font-weight: 900;
+ font-variant-numeric: tabular-nums;
+}
+
+.root :global(.scan-ai-log-item.info) {
+ border-color: rgba(56, 189, 248, 0.22);
+}
+
+.root :global(.scan-ai-log-item.success) {
+ border-color: rgba(16, 185, 129, 0.32);
+}
+
+.root :global(.scan-ai-log-item.warning) {
+ border-color: rgba(245, 158, 11, 0.34);
+}
+
+.root :global(.scan-ai-log-item.error) {
+ border-color: rgba(248, 113, 113, 0.34);
+}
+
+.root :global(.scan-ai-log-empty) {
+ color: #8fa8c7;
+ font-size: 12px;
+ font-weight: 800;
+}
diff --git a/frontend/components/dashboard/ScanTerminalCalendar.module.css b/frontend/components/dashboard/ScanTerminalCalendar.module.css
index 73d4acda..4d73657f 100644
--- a/frontend/components/dashboard/ScanTerminalCalendar.module.css
+++ b/frontend/components/dashboard/ScanTerminalCalendar.module.css
@@ -13,6 +13,8 @@
border-radius: 18px;
background: rgba(8, 17, 30, 0.8);
overflow: hidden;
+ content-visibility: auto;
+ contain-intrinsic-size: 460px;
}
.root :global(.scan-calendar-group-head) {
diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx
index b434140e..f8ff185c 100644
--- a/frontend/components/dashboard/ScanTerminalDashboard.tsx
+++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx
@@ -1,6 +1,7 @@
"use client";
import clsx from "clsx";
+import dynamic from "next/dynamic";
import Link from "next/link";
import {
LogIn,
@@ -17,7 +18,15 @@ import {
useState,
} from "react";
import styles from "./Dashboard.module.css";
+import dashboardHomeStyles from "./DashboardHomeIntelligence.module.css";
+import dashboardMapStyles from "./DashboardMap.module.css";
+import dashboardModalGuideStyles from "./DashboardModalGuide.module.css";
+import dashboardShellStyles from "./DashboardShell.module.css";
import detailChromeStyles from "./DetailPanelChrome.module.css";
+import detailContentStyles from "./DetailPanelContent.module.css";
+import detailSectionsStyles from "./DetailPanelSections.module.css";
+import futureForecastModalStyles from "./FutureForecastModal.module.css";
+import historyModalStyles from "./HistoryModal.module.css";
import modalChromeStyles from "./ModalChrome.module.css";
import scanTerminalCalendarStyles from "./ScanTerminalCalendar.module.css";
import scanTerminalCardStyles from "./ScanTerminalCard.module.css";
@@ -25,9 +34,12 @@ import scanTerminalLightThemeStyles from "./ScanTerminalLightTheme.module.css";
import scanTerminalMobileStyles from "./ScanTerminalMobile.module.css";
import scanTerminalOpportunityStyles from "./ScanTerminalOpportunity.module.css";
import scanTerminalStyles from "./ScanTerminal.module.css";
-import { DetailPanel as CityDetailPanel } from "@/components/dashboard/DetailPanel";
-import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal";
-import { MapCanvas } from "@/components/dashboard/MapCanvas";
+import scanTerminalBoardStyles from "./ScanTerminalBoard.module.css";
+import scanTerminalDetailStyles from "./ScanTerminalDetail.module.css";
+import scanTerminalFiltersStyles from "./ScanTerminalFilters.module.css";
+import scanTerminalListStyles from "./ScanTerminalList.module.css";
+import scanTerminalShellStyles from "./ScanTerminalShell.module.css";
+import scanTerminalStateStyles from "./ScanTerminalState.module.css";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import {
DashboardStoreProvider,
@@ -59,6 +71,30 @@ import {
type ContentView = "opportunities" | "analysis" | "map" | "calendar";
+const CityDetailPanel = dynamic(
+ () =>
+ import("@/components/dashboard/DetailPanel").then(
+ (module) => module.DetailPanel,
+ ),
+ { ssr: false },
+);
+
+const FutureForecastModal = dynamic(
+ () =>
+ import("@/components/dashboard/FutureForecastModal").then(
+ (module) => module.FutureForecastModal,
+ ),
+ { ssr: false },
+);
+
+const MapCanvas = dynamic(
+ () =>
+ import("@/components/dashboard/MapCanvas").then(
+ (module) => module.MapCanvas,
+ ),
+ { ssr: false },
+);
+
function ScanTerminalScreen() {
const store = useDashboardStore();
const { locale, toggleLocale } = useI18n();
@@ -85,14 +121,28 @@ function ScanTerminalScreen() {
const lastMapSelectedCityRef = useRef("");
const scanTerminalRootClassName = clsx(
styles.root,
+ dashboardHomeStyles.root,
+ dashboardMapStyles.root,
+ dashboardShellStyles.root,
+ dashboardModalGuideStyles.root,
scanTerminalStyles.root,
+ scanTerminalShellStyles.root,
+ scanTerminalFiltersStyles.root,
+ scanTerminalListStyles.root,
+ scanTerminalBoardStyles.root,
+ scanTerminalDetailStyles.root,
+ scanTerminalStateStyles.root,
scanTerminalOpportunityStyles.root,
scanTerminalCardStyles.root,
scanTerminalCalendarStyles.root,
scanTerminalMobileStyles.root,
scanTerminalLightThemeStyles.root,
detailChromeStyles.root,
+ detailContentStyles.root,
+ detailSectionsStyles.root,
modalChromeStyles.root,
+ futureForecastModalStyles.root,
+ historyModalStyles.root,
themeMode === "light" && "light",
);
diff --git a/frontend/components/dashboard/ScanTerminalDetail.module.css b/frontend/components/dashboard/ScanTerminalDetail.module.css
new file mode 100644
index 00000000..0703d194
--- /dev/null
+++ b/frontend/components/dashboard/ScanTerminalDetail.module.css
@@ -0,0 +1,353 @@
+/* Scan terminal right-side detail panel styles for the current decision desk. */
+
+.root :global(.scan-detail-panel) {
+ width: auto;
+ min-width: 0;
+ padding: 18px 16px;
+ border-left: 1px solid rgba(82, 114, 161, 0.18);
+}
+
+.root :global(.scan-detail-header) {
+ padding: 0 0 16px;
+ justify-content: space-between;
+ border-bottom: none;
+ margin-bottom: 10px;
+}
+
+.root :global(.scan-detail-top) {
+ display: flex;
+ gap: 0;
+ align-items: center;
+}
+
+.root :global(.scan-detail-title-wrap) {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.root :global(.scan-detail-icon-button) {
+ width: 40px;
+ height: 40px;
+ display: grid;
+ place-items: center;
+}
+
+.root :global(.scan-detail-hero-placeholder) {
+ width: 66px;
+ height: 66px;
+ border-radius: 14px;
+ background: linear-gradient(135deg, #314d7b, #0d2038 60%, #08131f);
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
+}
+
+.root :global(.scan-detail-city-name) {
+ font-size: 22px;
+ font-weight: 800;
+}
+
+.root :global(.scan-detail-city-sub) {
+ font-size: 14px;
+ color: #a4bad6;
+}
+
+.root :global(.scan-detail-volume-row) {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.root :global(.scan-detail-volume-big) {
+ font-size: 28px;
+ font-weight: 800;
+}
+
+.root :global(.scan-detail-volume-caption) {
+ margin-top: 4px;
+ font-size: 14px;
+ color: #90a7c4;
+}
+
+.root :global(.scan-detail-action-button) {
+ padding: 11px 14px;
+ font-weight: 700;
+}
+
+.root :global(.scan-detail-primary-actions) {
+ display: flex;
+ margin-bottom: 16px;
+}
+
+.root :global(.scan-detail-analysis-button) {
+ width: 100%;
+ border: 1px solid rgba(23, 217, 139, 0.3);
+ border-radius: 14px;
+ background: rgba(23, 217, 139, 0.12);
+ color: #27ea98;
+ padding: 12px 14px;
+ font-size: 14px;
+ font-weight: 800;
+ text-align: center;
+}
+
+.root :global(.scan-detail-analysis-button:hover) {
+ background: rgba(23, 217, 139, 0.18);
+ border-color: rgba(23, 217, 139, 0.4);
+}
+
+.root :global(.scan-detail-section) {
+ padding: 16px 0;
+ border-bottom: 1px solid rgba(90, 123, 166, 0.12);
+}
+
+.root :global(.scan-detail-section-title) {
+ font-size: 16px;
+ font-weight: 800;
+ color: #e8f2ff;
+ text-transform: none;
+ letter-spacing: 0;
+ margin-bottom: 12px;
+}
+
+.root :global(.scan-kv-list) {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.root :global(.scan-kv) {
+ display: flex;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 4px 0;
+ color: #dbe7f8;
+}
+
+.root :global(.scan-kv span:first-child) {
+ color: #91a8c6;
+}
+
+.root :global(.scan-kv strong) {
+ font-size: 15px;
+}
+
+.root :global(.scan-kv strong.warn) {
+ color: #ffbf39;
+}
+
+.root :global(.scan-kv strong.danger) {
+ color: #ff7f7f;
+}
+
+.root :global(.scan-timeline-head) {
+ display: flex;
+ justify-content: space-between;
+ color: #a1b6d2;
+ font-size: 13px;
+}
+
+.root :global(.scan-timeline-bar) {
+ position: relative;
+ height: 8px;
+ margin-top: 8px;
+ border-radius: 999px;
+ background: linear-gradient(90deg, #e83c3c 0 72%, rgba(255, 255, 255, 0.18) 72% 100%);
+}
+
+.root :global(.scan-timeline-knob) {
+ position: absolute;
+ top: 50%;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: #dfe8f6;
+ box-shadow: 0 0 0 4px rgba(223, 232, 246, 0.12);
+ transform: translate(-50%, -50%);
+}
+
+.root :global(.scan-timeline-caption) {
+ margin-top: 8px;
+ color: #a1b6d2;
+ font-size: 13px;
+ text-align: right;
+}
+
+.root :global(.scan-chart-legend) {
+ display: flex;
+ gap: 16px;
+ margin-bottom: 14px;
+ color: #a8bdd8;
+ font-size: 13px;
+}
+
+.root :global(.scan-chart-legend .dot) {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ margin-right: 6px;
+ vertical-align: middle;
+}
+
+.root :global(.scan-chart-legend .dot.green) {
+ background: #1ce393;
+}
+
+.root :global(.scan-chart-legend .dot.blue) {
+ background: #4ca2ff;
+}
+
+.root :global(.scan-chart-bars) {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 8px;
+ height: 170px;
+ padding: 8px 4px 0;
+}
+
+.root :global(.scan-chart-group) {
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ align-items: flex-end;
+ gap: 6px;
+ position: relative;
+}
+
+.root :global(.scan-chart-group.highlighted .scan-chart-label) {
+ color: #22eb98;
+ font-weight: 800;
+}
+
+.root :global(.scan-chart-col) {
+ width: 20px;
+ border-radius: 8px 8px 0 0;
+}
+
+.root :global(.scan-chart-col.model) {
+ background: linear-gradient(180deg, #1ce393, #0b8d5a);
+}
+
+.root :global(.scan-chart-col.market) {
+ background: linear-gradient(180deg, #4ca2ff, #2263cf);
+}
+
+.root :global(.scan-chart-label) {
+ position: absolute;
+ bottom: -24px;
+ left: 50%;
+ transform: translateX(-50%);
+ color: #93a8c4;
+ font-size: 13px;
+}
+
+.root :global(.scan-trade-cards) {
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.root :global(.scan-trade-card) {
+ padding: 16px;
+ border-radius: 16px;
+ background: rgba(11, 24, 42, 0.92);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.root :global(.scan-trade-card.buy) {
+ box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.22);
+}
+
+.root :global(.scan-trade-card.sell) {
+ box-shadow: inset 0 0 0 1px rgba(255, 93, 93, 0.22);
+}
+
+.root :global(.scan-trade-card-title) {
+ font-size: 16px;
+ font-weight: 800;
+}
+
+.root :global(.scan-trade-card.buy .scan-trade-card-title) {
+ color: #20e391;
+}
+
+.root :global(.scan-trade-card.sell .scan-trade-card-title) {
+ color: #ff7070;
+}
+
+.root :global(.scan-trade-card p) {
+ margin: 10px 0 0;
+ color: #d5e1f5;
+ font-size: 14px;
+}
+
+.root :global(.scan-trade-card p.positive) {
+ color: #20e391;
+}
+
+.root :global(.scan-trade-card p.negative) {
+ color: #ff7070;
+}
+
+.root :global(.scan-detail-score-block) {
+ margin-top: 18px;
+ padding-top: 16px;
+}
+
+.root :global(.scan-detail-score-head) {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: 12px;
+}
+
+.root :global(.scan-detail-score-label-text) {
+ font-size: 16px;
+ font-weight: 800;
+}
+
+.root :global(.scan-detail-score-meta) {
+ margin-top: 6px;
+ color: #89a0bf;
+ font-size: 13px;
+}
+
+.root :global(.scan-detail-score-value) {
+ font-size: 40px;
+ font-weight: 800;
+}
+
+.root :global(.scan-detail-score-value.green) {
+ color: #23e694;
+}
+
+.root :global(.scan-detail-score-value.yellow) {
+ color: #ffbe26;
+}
+
+.root :global(.scan-detail-score-value.red) {
+ color: #ff6f86;
+}
+
+.root :global(.scan-detail-score-value span) {
+ font-size: 22px;
+ color: #9fb5d2;
+}
+
+.root :global(.scan-detail-score-line) {
+ height: 6px;
+ margin-top: 10px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.root :global(.scan-detail-score-line span) {
+ display: block;
+ height: 100%;
+ border-radius: 999px;
+ background: linear-gradient(90deg, #1be392, #12b46d);
+ box-shadow: 0 0 18px rgba(27, 227, 146, 0.22);
+}
diff --git a/frontend/components/dashboard/ScanTerminalFilters.module.css b/frontend/components/dashboard/ScanTerminalFilters.module.css
new file mode 100644
index 00000000..aa299d73
--- /dev/null
+++ b/frontend/components/dashboard/ScanTerminalFilters.module.css
@@ -0,0 +1,241 @@
+/* Scan terminal sidebar filters, mode tabs, range controls, toggles, and CTA.
+ Imported after ScanTerminal.module.css so these current-desk overrides win over legacy base rules. */
+
+.root :global(.scan-filter-panel) {
+ width: auto;
+ min-width: 0;
+ border-right: 1px solid rgba(159, 178, 199, 0.14);
+ padding: 18px 14px;
+ gap: 18px;
+ overflow: hidden auto;
+}
+
+.root :global(.scan-sidebar-brand) {
+ display: flex;
+ align-items: center;
+ gap: 0;
+ padding: 8px 4px 18px;
+ border-bottom: 1px solid rgba(118, 146, 188, 0.12);
+}
+
+.root :global(.scan-sidebar-brand-mark) {
+ width: 42px;
+ height: 42px;
+ border-radius: 14px;
+ display: grid;
+ place-items: center;
+ color: #042113;
+ background: radial-gradient(circle at 30% 30%, #29f2ab, #0da46a 60%, #073c28 100%);
+ box-shadow: 0 0 24px rgba(23, 217, 139, 0.3);
+}
+
+.root :global(.scan-sidebar-brand-name) {
+ font-size: 30px;
+ font-weight: 800;
+ letter-spacing: 0;
+ color: #eef5ff;
+}
+
+.root :global(.scan-filter-heading) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin: 6px 8px 8px;
+ font-size: 14px;
+ font-weight: 600;
+ color: #b8c7dd;
+}
+
+.root :global(.scan-mode-tabs) {
+ gap: 10px;
+}
+
+.root :global(.scan-mode-tab) {
+ align-items: flex-start;
+ gap: 12px;
+ padding: 14px;
+ border-radius: 14px;
+ border: 1px solid rgba(87, 119, 166, 0.14);
+ background: rgba(11, 24, 42, 0.95);
+ color: #dbe7f8;
+}
+
+.root :global(.scan-mode-tab:hover) {
+ background: rgba(13, 28, 48, 0.98);
+ border-color: rgba(87, 119, 166, 0.24);
+}
+
+.root :global(.scan-mode-tab.active) {
+ background: linear-gradient(180deg, rgba(15, 34, 59, 1), rgba(9, 19, 34, 1));
+ border-color: rgba(78, 222, 151, 0.7);
+ color: #f3fbff;
+ box-shadow: inset 0 0 0 1px rgba(78, 222, 151, 0.35), 0 0 0 2px rgba(7, 199, 119, 0.08);
+}
+
+.root :global(.scan-mode-tab.active::before) {
+ display: none;
+}
+
+.root :global(.scan-mode-icon) {
+ width: 28px;
+ height: 28px;
+ border-radius: 10px;
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+ background: rgba(63, 140, 255, 0.12);
+ color: #6cb1ff;
+}
+
+.root :global(.scan-mode-tab.active .scan-mode-icon) {
+ background: rgba(23, 217, 139, 0.14);
+ color: #2df3a5;
+}
+
+.root :global(.scan-mode-copy) {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+}
+
+.root :global(.scan-mode-tab-label) {
+ font-size: 15px;
+ font-weight: 700;
+ color: inherit;
+}
+
+.root :global(.scan-mode-tab-sub) {
+ font-size: 12px;
+ line-height: 1.4;
+ color: #8fa4c3;
+}
+
+.root :global(.scan-range-card),
+.root :global(.scan-filter-row) {
+ background: rgba(11, 24, 42, 0.92);
+ border: 1px solid rgba(87, 119, 166, 0.12);
+ border-radius: 12px;
+ padding: 12px 14px;
+}
+
+.root :global(.scan-range-card) {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.root :global(.scan-filter-row-title),
+.root :global(.scan-filter-row-label) {
+ font-size: 14px;
+ color: #d9e7f8;
+}
+
+.root :global(.scan-filter-row) {
+ gap: 10px;
+}
+
+.root :global(.scan-filter-row.inline) {
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.root :global(.scan-range-track-wrap) {
+ position: relative;
+ height: 20px;
+}
+
+.root :global(.scan-range-track-wrap::before) {
+ content: "";
+ position: absolute;
+ inset: 7px 0;
+ border-radius: 999px;
+ background: linear-gradient(90deg, #1c2f4e, #2d4f84);
+}
+
+.root :global(.scan-range-slider) {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 20px;
+ background: transparent;
+ pointer-events: none;
+}
+
+.root :global(.scan-range-slider::-webkit-slider-thumb) {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ border: none;
+ background: #4d93ff;
+ box-shadow: 0 0 0 4px rgba(77, 147, 255, 0.16);
+ pointer-events: auto;
+}
+
+.root :global(.scan-range-slider::-moz-range-thumb) {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ border: none;
+ background: #4d93ff;
+ box-shadow: 0 0 0 4px rgba(77, 147, 255, 0.16);
+ pointer-events: auto;
+}
+
+.root :global(.scan-range-slider::-webkit-slider-runnable-track),
+.root :global(.scan-range-slider::-moz-range-track) {
+ background: transparent;
+}
+
+.root :global(.scan-range-labels) {
+ display: flex;
+ justify-content: space-between;
+ font-size: 12px;
+ color: #aec0d9;
+}
+
+.root :global(.scan-select) {
+ border-radius: 10px;
+ padding: 10px 12px;
+ border-color: rgba(87, 119, 166, 0.12);
+ background: rgba(10, 22, 38, 0.95);
+ color: #d9e8fb;
+}
+
+.root :global(.scan-toggle) {
+ width: 42px;
+ height: 24px;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ background: linear-gradient(90deg, #0e2840, #12324d);
+}
+
+.root :global(.scan-toggle.active) {
+ background: linear-gradient(90deg, #0e2840, #1f7f57);
+ border-color: rgba(39, 234, 152, 0.28);
+}
+
+.root :global(.scan-toggle-knob) {
+ top: 2px;
+ left: 3px;
+ width: 18px;
+ height: 18px;
+ background: #8ca5c8;
+}
+
+.root :global(.scan-toggle.active .scan-toggle-knob) {
+ left: 21px;
+ background: #1bf1a2;
+ box-shadow: 0 0 10px rgba(27, 241, 162, 0.45);
+}
+
+.root :global(.scan-cta-button) {
+ border-radius: 14px;
+ padding: 15px;
+ margin-top: auto;
+ background: linear-gradient(180deg, #1ddb8d, #0eb96d);
+ box-shadow: 0 8px 22px rgba(14, 185, 109, 0.22);
+ font-size: 16px;
+ font-weight: 800;
+}
diff --git a/frontend/components/dashboard/ScanTerminalList.module.css b/frontend/components/dashboard/ScanTerminalList.module.css
new file mode 100644
index 00000000..d5e81a81
--- /dev/null
+++ b/frontend/components/dashboard/ScanTerminalList.module.css
@@ -0,0 +1,544 @@
+/* Scan terminal KPI, list tabs, table rows, v4 analysis, and AI workspace.
+ Kept separate from the shell/filter CSS so dense decision-board styles can evolve independently. */
+
+.root :global(.scan-kpi-bar) {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 14px;
+}
+
+.root :global(.scan-kpi-card) {
+ min-height: 124px;
+ padding: 18px;
+ border-radius: 16px;
+ border: 1px solid rgba(92, 124, 170, 0.12);
+ background: linear-gradient(180deg, rgba(14, 28, 48, 0.92), rgba(11, 21, 37, 0.94));
+ transform: none;
+}
+
+.root :global(.scan-kpi-card:hover) {
+ transform: translateY(-2px);
+}
+
+.root :global(.scan-kpi-card::after) {
+ display: none;
+}
+
+.root :global(.scan-kpi-card.green .scan-kpi-label) {
+ color: #17d98b;
+}
+
+.root :global(.scan-kpi-card.cyan .scan-kpi-label) {
+ color: #37d6ff;
+}
+
+.root :global(.scan-kpi-card.purple .scan-kpi-label) {
+ color: #a35cff;
+}
+
+.root :global(.scan-kpi-card.blue .scan-kpi-label) {
+ color: #49a3ff;
+}
+
+.root :global(.scan-kpi-card.red .scan-kpi-label) {
+ color: #ff647c;
+}
+
+.root :global(.scan-kpi-card.amber .scan-kpi-label) {
+ color: #ffb84f;
+}
+
+.root :global(.scan-kpi-card.orange .scan-kpi-label) {
+ color: #F59E0B;
+}
+
+.root :global(.scan-kpi-label) {
+ font-size: 13px;
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.root :global(.scan-kpi-value) {
+ margin-top: 10px;
+ font-size: 28px;
+}
+
+.root :global(.scan-kpi-note) {
+ margin-top: 8px;
+ font-size: 14px;
+ color: #a8bedb;
+}
+
+.root :global(.scan-list-section) {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.root :global(.scan-list-header) {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 10px;
+}
+
+.root :global(.scan-list-tabs) {
+ display: flex;
+ gap: 24px;
+}
+
+.root :global(.scan-list-tabs button) {
+ border: none;
+ background: transparent;
+ font-size: 16px;
+ font-weight: 700;
+ color: #91a7c4;
+ cursor: pointer;
+}
+
+.root :global(.scan-list-tabs button:disabled) {
+ opacity: 0.42;
+ cursor: not-allowed;
+}
+
+.root :global(.scan-list-tabs button.active) {
+ color: #fff;
+}
+
+.root :global(.scan-list-status) {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+}
+
+.root :global(.scan-status-chip) {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ height: 34px;
+ padding: 0 12px;
+ border-radius: 999px;
+ border: 1px solid rgba(98, 126, 167, 0.22);
+ background: rgba(10, 22, 38, 0.92);
+ color: #c4d4e9;
+ font-size: 12px;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+}
+
+.root :global(.scan-status-chip.live) {
+ border-color: rgba(55, 214, 255, 0.28);
+ background: rgba(55, 214, 255, 0.1);
+ color: #71e6ff;
+}
+
+.root :global(.scan-status-chip.stale) {
+ border-color: rgba(255, 184, 79, 0.24);
+ background: rgba(255, 184, 79, 0.1);
+ color: #ffc765;
+}
+
+.root :global(.scan-status-chip.ai) {
+ border-color: rgba(94, 234, 212, 0.28);
+ background: rgba(20, 184, 166, 0.12);
+ color: #87fff1;
+}
+
+.root :global(.scan-status-chip.focus) {
+ border-color: rgba(255, 176, 32, 0.3);
+ background: rgba(255, 176, 32, 0.12);
+ color: #ffd078;
+}
+
+.root :global(button.scan-status-chip.refresh) {
+ cursor: pointer;
+}
+
+.root :global(button.scan-status-chip.refresh:disabled) {
+ cursor: wait;
+ opacity: 0.68;
+}
+
+.root :global(.scan-status-chip .spin) {
+ animation: spin 1s linear infinite;
+}
+
+.root :global(.scan-table-shell) {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+ border: 1px solid rgba(90, 123, 166, 0.12);
+ border-radius: 18px;
+ background: rgba(8, 17, 30, 0.8);
+ overflow: hidden;
+}
+
+.root :global(.scan-table-shell.empty) {
+ min-height: 340px;
+}
+
+.root :global(.scan-table-banner) {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+ padding: 12px 16px;
+ border-bottom: 1px solid rgba(95, 124, 168, 0.12);
+ background: linear-gradient(180deg, rgba(255, 184, 79, 0.08), rgba(255, 184, 79, 0.03));
+}
+
+.root :global(.scan-table-banner strong) {
+ font-size: 13px;
+ font-weight: 800;
+ color: #ffd27f;
+}
+
+.root :global(.scan-table-banner span) {
+ font-size: 13px;
+ color: #c7d4e7;
+}
+
+.root :global(.scan-table-header) {
+ display: grid;
+ grid-template-columns: minmax(220px, 1.1fr) minmax(128px, 0.68fr) minmax(230px, 1.08fr) minmax(150px, 0.78fr) 92px;
+ gap: 10px;
+ align-items: center;
+ padding: 14px 18px;
+ background: rgba(11, 23, 40, 0.98);
+ border-bottom: 1px solid rgba(90, 123, 166, 0.12);
+ color: #92a8c5;
+ font-size: 13px;
+ font-weight: 600;
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.root :global(.scan-table-body) {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.root :global(.scan-v4-analysis) {
+ grid-column: auto;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+ margin: 0 14px 14px;
+ padding: 14px;
+ border-color: rgba(56, 189, 248, 0.18);
+ border-radius: 14px;
+ background:
+ linear-gradient(180deg, rgba(7, 20, 36, 0.96), rgba(5, 15, 28, 0.98)),
+ radial-gradient(circle at 4% 0%, rgba(45, 212, 191, 0.11), transparent 28%);
+}
+
+.root :global(.scan-v4-analysis section) {
+ padding: 13px;
+ border-color: rgba(92, 132, 188, 0.14);
+ background: rgba(10, 25, 43, 0.7);
+}
+
+.root :global(.scan-v4-analysis p),
+.root :global(.scan-v4-analysis ul) {
+ color: #b8c9e3;
+}
+
+.root :global(.scan-v4-analysis .scan-v4-evidence) {
+ grid-column: 1 / -1;
+}
+
+.root :global(.scan-v4-analysis .scan-v4-decision) {
+ grid-column: 1 / -1;
+ border-color: rgba(45, 212, 191, 0.24);
+ background:
+ linear-gradient(180deg, rgba(6, 31, 43, 0.86), rgba(8, 23, 38, 0.86)),
+ radial-gradient(circle at 0 0, rgba(20, 184, 166, 0.12), transparent 32%);
+}
+
+.root :global(.scan-v4-analysis .scan-v4-decision.downgrade),
+.root :global(.scan-v4-analysis .scan-v4-decision.watchlist) {
+ border-color: rgba(245, 158, 11, 0.24);
+}
+
+.root :global(.scan-v4-analysis .scan-v4-decision.veto) {
+ border-color: rgba(248, 113, 113, 0.28);
+}
+
+.root :global(.scan-v4-decision p b) {
+ color: #ecfeff;
+ font-weight: 950;
+}
+
+.root :global(.scan-v4-metar-summary) {
+ margin-top: 9px;
+ color: #8fb3d8;
+ font-size: 12px;
+ font-weight: 800;
+ line-height: 1.45;
+}
+
+.root :global(.scan-v4-evidence > div),
+.root :global(.scan-v4-model-sources) {
+ gap: 8px;
+}
+
+.root :global(.scan-v4-analysis) {
+ display: flex;
+ flex-direction: column;
+ gap: 13px;
+ margin: 0 14px 14px;
+ padding: 15px 16px 16px;
+ border: 1px solid rgba(56, 189, 248, 0.16);
+ border-radius: 12px;
+ background:
+ linear-gradient(180deg, rgba(7, 20, 36, 0.96), rgba(5, 15, 28, 0.98)),
+ radial-gradient(circle at 4% 0%, rgba(45, 212, 191, 0.08), transparent 30%);
+}
+
+.root :global(.scan-v4-analysis section),
+.root :global(.scan-v4-analysis section:first-child),
+.root :global(.scan-v4-analysis .scan-v4-evidence) {
+ grid-column: auto;
+ min-width: 0;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+}
+
+.root :global(.scan-v4-heading) {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 18px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid rgba(90, 123, 166, 0.14);
+}
+
+.root :global(.scan-v4-heading > div) {
+ display: grid;
+ gap: 7px;
+ min-width: 0;
+}
+
+.root :global(.scan-v4-heading strong),
+.root :global(.scan-v4-current b),
+.root :global(.scan-v4-brief-grid strong) {
+ color: #6FB7FF;
+ font-size: 12px;
+ font-weight: 950;
+ letter-spacing: 0.02em;
+}
+
+.root :global(.scan-v4-heading p) {
+ margin: 0;
+ color: #d2e1f4;
+ font-size: 14px;
+ font-weight: 800;
+ line-height: 1.55;
+}
+
+.root :global(.scan-v4-decision-pill) {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ min-height: 30px;
+ padding: 0 12px;
+ border: 1px solid rgba(45, 212, 191, 0.3);
+ border-radius: 999px;
+ background: rgba(20, 184, 166, 0.12);
+ color: #7dfbe7;
+ font-size: 12px;
+ font-weight: 950;
+ white-space: nowrap;
+}
+
+.root :global(.scan-v4-decision-pill.downgrade),
+.root :global(.scan-v4-decision-pill.watchlist) {
+ border-color: rgba(245, 158, 11, 0.34);
+ background: rgba(245, 158, 11, 0.12);
+ color: #ffd166;
+}
+
+.root :global(.scan-v4-decision-pill.veto) {
+ border-color: rgba(248, 113, 113, 0.34);
+ background: rgba(248, 113, 113, 0.12);
+ color: #ff8585;
+}
+
+.root :global(.scan-v4-current) {
+ display: grid;
+ gap: 6px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid rgba(90, 123, 166, 0.12);
+}
+
+.root :global(.scan-v4-current span) {
+ color: #c8d8ec;
+ font-size: 13px;
+ font-weight: 850;
+ line-height: 1.5;
+}
+
+.root :global(.scan-v4-current small) {
+ color: #8fb3d8;
+ font-size: 12px;
+ font-weight: 800;
+ line-height: 1.45;
+}
+
+.root :global(.scan-v4-brief-grid) {
+ display: grid;
+ grid-template-columns: minmax(0, 1.45fr) minmax(260px, 0.85fr);
+ gap: 26px;
+}
+
+.root :global(.scan-v4-brief-grid section:first-child) {
+ grid-column: auto;
+}
+
+.root :global(.scan-v4-brief-grid ul) {
+ display: grid;
+ gap: 8px;
+ margin: 8px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.root :global(.scan-v4-brief-grid li) {
+ position: relative;
+ padding-left: 16px;
+ color: #aebfd8;
+ font-size: 12px;
+ font-weight: 800;
+ line-height: 1.48;
+}
+
+.root :global(.scan-v4-brief-grid li::before) {
+ content: "";
+ position: absolute;
+ top: 0.65em;
+ left: 0;
+ width: 5px;
+ height: 5px;
+ border-radius: 999px;
+ background: #4DA3FF;
+ box-shadow: 0 0 10px rgba(45, 212, 191, 0.45);
+}
+
+.root :global(.scan-v4-brief-grid section:nth-child(2) li::before) {
+ background: #f59e0b;
+ box-shadow: 0 0 10px rgba(245, 158, 11, 0.35);
+}
+
+.root :global(details.scan-v4-evidence) {
+ padding-top: 11px;
+ border-top: 1px solid rgba(90, 123, 166, 0.12);
+}
+
+.root :global(details.scan-v4-evidence summary) {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 30px;
+ padding: 0 11px;
+ border: 1px solid rgba(65, 118, 216, 0.28);
+ border-radius: 8px;
+ background: rgba(10, 30, 58, 0.52);
+ color: #8fbdff;
+ font-size: 12px;
+ font-weight: 950;
+ cursor: pointer;
+ list-style: none;
+}
+
+.root :global(details.scan-v4-evidence summary::-webkit-details-marker) {
+ display: none;
+}
+
+.root :global(details.scan-v4-evidence summary::after) {
+ content: "v";
+ color: #8fbdff;
+ font-size: 12px;
+}
+
+.root :global(details.scan-v4-evidence[open] summary::after) {
+ content: "^";
+}
+
+.root :global(details.scan-v4-evidence > div) {
+ margin-top: 10px;
+}
+
+.root :global(.scan-forecast-desk) {
+ gap: 16px;
+ padding: 16px 18px 20px;
+}
+
+
+.root :global(.scan-ai-workspace) {
+ flex: 1;
+ min-height: 0;
+ max-height: 100%;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ border: 1px solid rgba(77, 163, 255, 0.16);
+ border-radius: 18px;
+ background: #0b1220;
+ padding: 18px;
+}
+
+.root :global(.scan-ai-workspace::-webkit-scrollbar) {
+ width: 8px;
+}
+
+.root :global(.scan-ai-workspace::-webkit-scrollbar-thumb) {
+ border-radius: 999px;
+ background: rgba(77, 163, 255, 0.28);
+}
+
+.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;
+}
diff --git a/frontend/components/dashboard/ScanTerminalOpportunity.module.css b/frontend/components/dashboard/ScanTerminalOpportunity.module.css
index 54719b90..1fc76fb8 100644
--- a/frontend/components/dashboard/ScanTerminalOpportunity.module.css
+++ b/frontend/components/dashboard/ScanTerminalOpportunity.module.css
@@ -978,6 +978,8 @@
border-radius: 18px;
background: rgba(13, 17, 23, 0.38);
padding: 14px;
+ content-visibility: auto;
+ contain-intrinsic-size: 520px;
}
.root :global(.scan-opportunity-lane-head) {
diff --git a/frontend/components/dashboard/ScanTerminalShell.module.css b/frontend/components/dashboard/ScanTerminalShell.module.css
new file mode 100644
index 00000000..040a6bb4
--- /dev/null
+++ b/frontend/components/dashboard/ScanTerminalShell.module.css
@@ -0,0 +1,249 @@
+/* Scan terminal shell, topbar, upgrade announcement, and shared action buttons.
+ Extracted from ScanTerminal.module.css to keep terminal layout CSS maintainable. */
+
+.root :global(.scan-topbar) {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 6px 0 18px;
+ border-bottom: 1px solid rgba(118, 146, 188, 0.1);
+}
+
+.root :global(.scan-topbar-title) {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ min-width: 0;
+}
+
+.root :global(.scan-topbar-title strong) {
+ margin: 0;
+ font-size: 28px;
+ line-height: 1.08;
+ letter-spacing: -0.04em;
+ color: #f3f8ff;
+}
+
+.root :global(.scan-topbar-title span) {
+ color: #8fa4c3;
+ font-size: 14px;
+ line-height: 1.5;
+}
+
+.root :global(.scan-topbar-actions) {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 12px;
+ color: #b4c8e2;
+ font-size: 14px;
+}
+
+.root :global(.scan-locale-switch) {
+ height: 36px;
+ padding: 3px;
+ border-radius: 12px;
+ border: 1px solid rgba(99, 132, 180, 0.18);
+ background: rgba(8, 19, 34, 0.9);
+ color: #a8bdd8;
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ font-size: 12px;
+ font-weight: 800;
+ cursor: pointer;
+}
+
+.root :global(.scan-locale-switch span) {
+ min-width: 34px;
+ height: 28px;
+ padding: 0 8px;
+ border-radius: 8px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.root :global(.scan-locale-switch span.active) {
+ color: #eef7ff;
+ background: rgba(63, 140, 255, 0.16);
+ box-shadow: inset 0 0 0 1px rgba(63, 140, 255, 0.22);
+}
+
+.root :global(.scan-topbar-time) {
+ color: #b4c8e2;
+ font-variant-numeric: tabular-nums;
+}
+
+.root :global(.scan-upgrade-announcement) {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 16px;
+ align-items: center;
+ margin: -4px 0 6px;
+ border: 1px solid rgba(77, 163, 255, 0.2);
+ border-radius: 18px;
+ background:
+ radial-gradient(circle at top left, rgba(77, 163, 255, 0.18), transparent 36%),
+ linear-gradient(135deg, rgba(18, 33, 58, 0.96), rgba(15, 23, 42, 0.92));
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.2);
+ padding: 16px 18px;
+}
+
+.root :global(.scan-upgrade-announcement-copy) {
+ min-width: 0;
+}
+
+.root :global(.scan-upgrade-announcement-copy span) {
+ color: #86efac;
+ font-size: 12px;
+ font-weight: 900;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.root :global(.scan-upgrade-announcement-copy strong) {
+ display: block;
+ margin-top: 5px;
+ color: #f3f8ff;
+ font-size: 18px;
+ line-height: 1.25;
+}
+
+.root :global(.scan-upgrade-announcement-copy p) {
+ margin: 6px 0 0;
+ color: #b4c8e2;
+ font-size: 13px;
+ line-height: 1.55;
+}
+
+.root :global(.scan-upgrade-announcement ul) {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.root :global(.scan-upgrade-announcement li) {
+ border: 1px solid rgba(77, 163, 255, 0.22);
+ border-radius: 999px;
+ background: rgba(77, 163, 255, 0.1);
+ color: #d7e5f7;
+ font-size: 12px;
+ font-weight: 850;
+ padding: 7px 10px;
+}
+
+.root :global(.scan-ghost-button),
+.root :global(.scan-cta-ghost),
+.root :global(.scan-theme-button),
+.root :global(.scan-detail-action-button),
+.root :global(.scan-detail-icon-button),
+.root :global(.scan-view-all-button) {
+ border: 1px solid rgba(99, 132, 180, 0.18);
+ background: rgba(8, 19, 34, 0.9);
+ color: #d7e5f7;
+ border-radius: 12px;
+}
+
+.root :global(.scan-ghost-button),
+.root :global(.scan-cta-ghost) {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 11px 14px;
+ font-weight: 700;
+}
+
+.root :global(.scan-primary-button) {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 42px;
+ padding: 11px 16px;
+ border: 1px solid rgba(77, 163, 255, 0.42);
+ border-radius: 12px;
+ background: linear-gradient(180deg, #4DA3FF, #3B82F6);
+ color: #FFFFFF;
+ font-size: 14px;
+ font-weight: 900;
+ text-decoration: none;
+ box-shadow: 0 10px 24px rgba(59, 130, 246, 0.22);
+ cursor: pointer;
+}
+
+.root :global(.scan-primary-button:hover:not(:disabled)) {
+ filter: brightness(1.06);
+}
+
+.root :global(.scan-primary-button:disabled) {
+ cursor: not-allowed;
+ opacity: 0.68;
+}
+
+.root :global(.scan-ai-button) {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 42px;
+ padding: 11px 14px;
+ border: 1px solid rgba(77, 163, 255, 0.3);
+ border-radius: 12px;
+ 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(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) {
+ cursor: not-allowed;
+ opacity: 0.55;
+}
+
+.root :global(.scan-cta-ghost) {
+ color: #6FB7FF;
+ border-color: rgba(23, 217, 139, 0.3);
+ background: rgba(23, 217, 139, 0.12);
+}
+
+.root :global(.scan-account-button) {
+ width: 42px;
+ height: 42px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: #d7e5f7;
+ text-decoration: none;
+ border: 1px solid rgba(99, 132, 180, 0.18);
+ background: rgba(8, 19, 34, 0.9);
+ border-radius: 12px;
+}
+
+.root :global(.scan-account-button:hover) {
+ border-color: rgba(63, 140, 255, 0.28);
+ background: rgba(12, 26, 44, 0.96);
+}
+
+.root :global(.scan-theme-button) {
+ width: 36px;
+ height: 36px;
+ display: inline-grid;
+ place-items: center;
+ color: #d7e5f7;
+ cursor: pointer;
+}
+
+.root :global(.scan-ghost-button .spin) {
+ animation: spin 1s linear infinite;
+}
diff --git a/frontend/components/dashboard/ScanTerminalState.module.css b/frontend/components/dashboard/ScanTerminalState.module.css
new file mode 100644
index 00000000..862c922b
--- /dev/null
+++ b/frontend/components/dashboard/ScanTerminalState.module.css
@@ -0,0 +1,242 @@
+/* Scan terminal empty, loading, and skeleton signal states. */
+
+.root :global(.scan-empty-state) {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ padding: 48px 32px;
+ text-align: center;
+}
+
+.root :global(.scan-empty-title) {
+ font-size: 20px;
+ font-weight: 800;
+ color: #e8f2ff;
+}
+
+.root :global(.scan-empty-copy) {
+ margin-top: 10px;
+ font-size: 14px;
+ color: #8fa4c3;
+ line-height: 1.6;
+}
+
+.root :global(.scan-loading-state) {
+ flex: 1;
+ min-height: 520px;
+ display: grid;
+ place-items: center;
+ padding: 48px 32px;
+ text-align: center;
+}
+
+.root :global(.scan-loading-signal) {
+ position: relative;
+ width: min(100%, 420px);
+ display: grid;
+ justify-items: center;
+ gap: 18px;
+ padding: 28px 30px;
+ border: 1px solid rgba(125, 211, 252, 0.14);
+ border-radius: 28px;
+ background:
+ radial-gradient(circle at 18% 18%, rgba(77, 163, 255, 0.18), transparent 34%),
+ linear-gradient(145deg, rgba(12, 20, 36, 0.92), rgba(5, 11, 22, 0.74));
+ box-shadow:
+ 0 22px 70px rgba(0, 0, 0, 0.3),
+ inset 0 1px 0 rgba(255, 255, 255, 0.05);
+ overflow: hidden;
+}
+
+.root :global(.scan-loading-signal::before) {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ 100deg,
+ transparent 0%,
+ rgba(125, 211, 252, 0.08) 42%,
+ rgba(34, 197, 94, 0.1) 50%,
+ transparent 58%
+ );
+ transform: translateX(-120%);
+ animation: scan-loading-sweep 2.6s ease-in-out infinite;
+ pointer-events: none;
+}
+
+.root :global(.scan-loading-signal.compact) {
+ width: min(100%, 340px);
+ gap: 12px;
+ padding: 18px 20px;
+ border-radius: 22px;
+ background:
+ radial-gradient(circle at 20% 20%, rgba(77, 163, 255, 0.14), transparent 36%),
+ rgba(10, 18, 32, 0.62);
+ box-shadow: none;
+}
+
+.root :global(.scan-loading-decision-flow) {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ grid-template-columns: 16px minmax(70px, 1fr) 16px minmax(70px, 1fr) 16px;
+ align-items: center;
+ width: min(100%, 300px);
+}
+
+.root :global(.scan-loading-node) {
+ width: 16px;
+ height: 16px;
+ border: 1px solid rgba(226, 232, 240, 0.16);
+ border-radius: 999px;
+ background: #0b1220;
+ box-shadow: 0 0 0 5px rgba(77, 163, 255, 0.05);
+}
+
+.root :global(.scan-loading-node.hot) {
+ background: linear-gradient(135deg, #f97316, #fde047);
+ box-shadow: 0 0 24px rgba(251, 146, 60, 0.26);
+}
+
+.root :global(.scan-loading-node.market) {
+ background: linear-gradient(135deg, #38bdf8, #4da3ff);
+ box-shadow: 0 0 24px rgba(56, 189, 248, 0.26);
+}
+
+.root :global(.scan-loading-node.action) {
+ background: linear-gradient(135deg, #22c55e, #1be392);
+ box-shadow: 0 0 24px rgba(34, 197, 94, 0.3);
+ animation: scan-loading-node-breathe 1.8s ease-in-out infinite;
+}
+
+.root :global(.scan-loading-rail) {
+ position: relative;
+ height: 2px;
+ overflow: hidden;
+ background: rgba(148, 163, 184, 0.16);
+}
+
+.root :global(.scan-loading-rail i) {
+ position: absolute;
+ inset: 0;
+ width: 48%;
+ border-radius: 999px;
+ background: linear-gradient(90deg, transparent, #7dd3fc, #22c55e);
+ transform: translateX(-110%);
+ animation: scan-loading-rail-flow 1.55s ease-in-out infinite;
+}
+
+.root :global(.scan-loading-rail:nth-of-type(4) i) {
+ animation-delay: 0.45s;
+}
+
+.root :global(.scan-loading-copy-block) {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ gap: 7px;
+ justify-items: center;
+}
+
+.root :global(.scan-loading-copy-block strong) {
+ color: #e6edf3;
+ font-size: 18px;
+ font-weight: 900;
+ letter-spacing: -0.02em;
+}
+
+.root :global(.scan-loading-copy-block span) {
+ max-width: 320px;
+ color: #9fb2c7;
+ font-size: 13px;
+ font-weight: 650;
+ line-height: 1.55;
+}
+
+.root :global(.scan-loading-signal-bars) {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: end;
+ gap: 5px;
+ height: 28px;
+}
+
+.root :global(.scan-loading-signal-bars span) {
+ width: 7px;
+ min-height: 8px;
+ border-radius: 999px 999px 4px 4px;
+ background: linear-gradient(180deg, #7dd3fc, #4da3ff 52%, #1be392);
+ opacity: 0.42;
+ transform-origin: center bottom;
+ animation: scan-loading-bars 1.35s ease-in-out infinite;
+}
+
+.root :global(.scan-loading-signal-bars span:nth-child(1)) {
+ height: 12px;
+}
+
+.root :global(.scan-loading-signal-bars span:nth-child(2)) {
+ height: 22px;
+ animation-delay: 0.16s;
+}
+
+.root :global(.scan-loading-signal-bars span:nth-child(3)) {
+ height: 16px;
+ animation-delay: 0.32s;
+}
+
+.root :global(.scan-loading-signal-bars span:nth-child(4)) {
+ height: 25px;
+ animation-delay: 0.48s;
+}
+
+@keyframes scan-loading-sweep {
+ 0% {
+ transform: translateX(-130%);
+ }
+ 46%,
+ 100% {
+ transform: translateX(130%);
+ }
+}
+
+@keyframes scan-loading-rail-flow {
+ 0% {
+ transform: translateX(-110%);
+ opacity: 0.15;
+ }
+ 45% {
+ opacity: 1;
+ }
+ 100% {
+ transform: translateX(220%);
+ opacity: 0.15;
+ }
+}
+
+@keyframes scan-loading-node-breathe {
+ 0%,
+ 100% {
+ transform: scale(0.92);
+ filter: saturate(0.9);
+ }
+ 50% {
+ transform: scale(1.08);
+ filter: saturate(1.25);
+ }
+}
+
+@keyframes scan-loading-bars {
+ 0%,
+ 100% {
+ opacity: 0.38;
+ transform: scaleY(0.72);
+ }
+ 50% {
+ opacity: 0.95;
+ transform: scaleY(1);
+ }
+}
diff --git a/frontend/components/dashboard/opportunity-ai-meta.ts b/frontend/components/dashboard/opportunity-ai-meta.ts
new file mode 100644
index 00000000..905e918b
--- /dev/null
+++ b/frontend/components/dashboard/opportunity-ai-meta.ts
@@ -0,0 +1,213 @@
+import type { ScanOpportunityRow } from "@/lib/dashboard-types";
+import { formatQuoteCents } from "./opportunity-format";
+import { getLocalizedRowText } from "./opportunity-copy";
+import { getModelSourceSummary } from "./opportunity-model-summary";
+
+export function getOpportunityStrength(edgePercent?: number | null, locale = "zh-CN") {
+ const edge = Number(edgePercent);
+ const normalized = Number.isFinite(edge) ? edge : 0;
+ if (normalized >= 20) {
+ return {
+ label: locale === "en-US" ? "High confidence" : "高胜率",
+ tone: "strong",
+ };
+ }
+ if (normalized >= 10) {
+ return {
+ label: locale === "en-US" ? "Medium confidence" : "中等胜率",
+ tone: "medium",
+ };
+ }
+ return {
+ label: locale === "en-US" ? "Watch" : "观察",
+ tone: "watch",
+ };
+}
+
+export function getShortAiConclusion(
+ row: ScanOpportunityRow,
+ locale: string,
+ _edgePercent?: number | null,
+ strengthLabel?: string,
+) {
+ const directReason =
+ getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
+ getLocalizedRowText(
+ row,
+ locale,
+ row.ai_watchlist_reason_zh,
+ row.ai_watchlist_reason_en,
+ );
+ if (directReason) return directReason;
+ const cityThesis = getLocalizedRowText(
+ row,
+ locale,
+ row.ai_city_thesis_zh,
+ row.ai_city_thesis_en,
+ );
+ if (cityThesis) return cityThesis;
+
+ const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol);
+ if (locale === "en-US") {
+ return `${strengthLabel || "Watch"}: AI should validate against ${modelBasis}.`;
+ }
+ return `${strengthLabel || "观察"}:AI 需结合${modelBasis}确认。`;
+}
+
+export function getRiskHints(
+ row: ScanOpportunityRow,
+ locale: string,
+ modelProbability?: number | null,
+) {
+ const hints: string[] = [];
+ const spread = Number(row.spread);
+ if (Number.isFinite(spread) && spread > 0.03) {
+ hints.push(
+ locale === "en-US"
+ ? `Wide spread ${formatQuoteCents(spread)} may distort the displayed market price.`
+ : `盘口价差 ${formatQuoteCents(spread)} 偏宽,可能扭曲市场价格参考。`,
+ );
+ }
+ const quoteAgeSeconds =
+ row.quote_age_ms != null && Number.isFinite(Number(row.quote_age_ms))
+ ? Math.round(Number(row.quote_age_ms) / 1000)
+ : null;
+ if (quoteAgeSeconds != null && quoteAgeSeconds > 60) {
+ hints.push(
+ locale === "en-US"
+ ? `Quote age ${quoteAgeSeconds}s; refresh before acting.`
+ : `报价已 ${quoteAgeSeconds}s,执行前需要刷新。`,
+ );
+ }
+ if (row.trend_alignment === false) {
+ hints.push(
+ locale === "en-US"
+ ? "Intraday trend does not fully support this direction."
+ : "日内趋势未完全支持该方向。",
+ );
+ }
+ if (row.cluster_adjusted) {
+ hints.push(
+ locale === "en-US"
+ ? "Tail bucket was cluster-adjusted; bucket confidence may be overstated."
+ : "尾部桶已做模型集群折扣,温度桶信心可能偏乐观。",
+ );
+ }
+ if (modelProbability != null && modelProbability < 10) {
+ hints.push(
+ locale === "en-US"
+ ? "Low model probability makes the setup sensitive to calibration error."
+ : "模型概率偏低,校准误差会显著影响判断。",
+ );
+ }
+ if (!hints.length) {
+ hints.push(
+ locale === "en-US"
+ ? "Main residual risk is late observation updates or a shifted peak window."
+ : "主要残余风险是后续实测升温或峰值窗口漂移。",
+ );
+ }
+ return hints;
+}
+
+export function getRecommendationReasons(
+ row: ScanOpportunityRow,
+ locale: string,
+ _edgePercent?: number | null,
+ price?: number | null,
+) {
+ const reasons: string[] = [];
+ const aiReason = getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en);
+ if (aiReason && String(row.ai_decision || "").toLowerCase() === "approve") {
+ reasons.push(aiReason);
+ }
+ const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol);
+ reasons.push(
+ locale === "en-US"
+ ? `AI uses ${modelBasis} with market ask ${formatQuoteCents(price)} only as downstream bucket context.`
+ : `AI 以${modelBasis}为主,市场买价 ${formatQuoteCents(price)} 只作下游温度桶参考。`,
+ );
+ if (row.peak_alignment_score != null) {
+ reasons.push(
+ locale === "en-US"
+ ? `Peak alignment score ${Number(row.peak_alignment_score).toFixed(2)} supports checking this bucket.`
+ : `峰值对齐分 ${Number(row.peak_alignment_score).toFixed(2)},支持把该桶纳入检查。`,
+ );
+ }
+ return reasons.slice(0, 3);
+}
+
+export function getExclusionReasons(
+ row: ScanOpportunityRow,
+ locale: string,
+ edgePercent?: number | null,
+) {
+ const decision = String(row.ai_decision || "").toLowerCase();
+ const aiReason =
+ getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
+ getLocalizedRowText(
+ row,
+ locale,
+ row.ai_watchlist_reason_zh,
+ row.ai_watchlist_reason_en,
+ );
+ if (decision === "veto" || decision === "downgrade" || decision === "watchlist") {
+ return [
+ aiReason ||
+ (locale === "en-US"
+ ? "AI did not classify this row as the primary forecast bucket."
+ : "AI 未把该合约列为主预测桶。"),
+ ];
+ }
+ if (edgePercent != null && Number(edgePercent) < 10) {
+ return [
+ locale === "en-US"
+ ? "This bucket is not the current forecast center."
+ : "该桶不是当前预测中枢。",
+ ];
+ }
+ return [
+ locale === "en-US"
+ ? "No hard veto in the current AI/rule snapshot."
+ : "当前 AI/规则快照没有硬性排除项。",
+ ];
+}
+
+export function getAiMeta(row: ScanOpportunityRow, locale: string) {
+ const decision = String(row.ai_decision || "").toLowerCase();
+ if (decision === "veto") {
+ return {
+ label: locale === "en-US" ? "AI veto" : "AI 排除",
+ tone: "veto",
+ reason: locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en,
+ };
+ }
+ if (decision === "downgrade") {
+ return {
+ label: locale === "en-US" ? "AI downgrade" : "AI 降级",
+ tone: "downgrade",
+ reason: locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en,
+ };
+ }
+ if (row.ai_rank != null || decision === "approve") {
+ return {
+ label: locale === "en-US" ? `AI pick ${row.ai_rank || ""}`.trim() : `AI 推荐 ${row.ai_rank || ""}`.trim(),
+ tone: "approve",
+ reason:
+ (locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en) ||
+ row.ai_model_cluster_note ||
+ null,
+ };
+ }
+ if (decision === "watchlist") {
+ return {
+ label: locale === "en-US" ? "AI watch" : "AI 观察",
+ tone: "downgrade",
+ reason:
+ locale === "en-US"
+ ? row.ai_watchlist_reason_en || row.ai_watchlist_reason_zh
+ : row.ai_watchlist_reason_zh || row.ai_watchlist_reason_en,
+ };
+ }
+ return null;
+}
diff --git a/frontend/components/dashboard/opportunity-airport-read.ts b/frontend/components/dashboard/opportunity-airport-read.ts
new file mode 100644
index 00000000..d22d554d
--- /dev/null
+++ b/frontend/components/dashboard/opportunity-airport-read.ts
@@ -0,0 +1,274 @@
+import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
+import { formatTemperatureValue } from "@/lib/temperature-utils";
+
+export function decodeRawMetarCloud(rawMetar?: string | null, locale = "zh-CN") {
+ const raw = String(rawMetar || "").toUpperCase();
+ const matches = Array.from(raw.matchAll(/\b(FEW|SCT|BKN|OVC)(\d{3})?\b/g));
+ if (!matches.length) return "";
+ const coverText: Record = {
+ FEW: { zh: "少云", en: "few" },
+ SCT: { zh: "散云", en: "scattered" },
+ BKN: { zh: "多云", en: "broken" },
+ OVC: { zh: "阴天", en: "overcast" },
+ };
+ return matches
+ .slice(0, 3)
+ .map((match) => {
+ const cover = coverText[match[1]] || { zh: match[1], en: match[1] };
+ const base = match[2] ? `${Number(match[2]) * 100}ft` : "";
+ return locale === "en-US"
+ ? [cover.en, base].filter(Boolean).join(" ")
+ : [cover.zh, base].filter(Boolean).join(" ");
+ })
+ .join(locale === "en-US" ? ", " : "、");
+}
+
+export function decodeRawMetarVisibility(rawMetar?: string | null) {
+ const raw = String(rawMetar || "").toUpperCase();
+ if (/\b9999\b/.test(raw)) return "10km+";
+ const meterMatch = raw.match(/\b(\d{4})\b/);
+ if (meterMatch) return `${Number(meterMatch[1]) / 1000}km`;
+ return "";
+}
+
+export 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}`;
+}
+
+export 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" ? ", " : "、");
+}
+
+export 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,
+ };
+}
+
+export 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" : "暂未看到明确天气压温信号")}。`;
+}
+
+export function formatAirportReportRead(
+ row: ScanOpportunityRow,
+ detail: CityDetail | null,
+ locale: string,
+ tempSymbol?: string | null,
+) {
+ const isEn = locale === "en-US";
+ const context = row.metar_context || {};
+ const airport: Partial> =
+ detail?.airport_current || {};
+ const station =
+ context.station ||
+ detail?.risk?.icao ||
+ airport.station_code ||
+ null;
+ const obsTime =
+ context.airport_obs_time ||
+ context.last_time ||
+ airport.obs_time ||
+ row.metar_status?.last_observation_time ||
+ null;
+ const temp =
+ context.airport_current_temp != null && Number.isFinite(Number(context.airport_current_temp))
+ ? Number(context.airport_current_temp)
+ : airport.temp != null && Number.isFinite(Number(airport.temp))
+ ? Number(airport.temp)
+ : null;
+ const 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;
+ const windDir =
+ context.airport_wind_dir != null && Number.isFinite(Number(context.airport_wind_dir))
+ ? Number(context.airport_wind_dir)
+ : airport.wind_dir != null && Number.isFinite(Number(airport.wind_dir))
+ ? Number(airport.wind_dir)
+ : null;
+ const cloud = String(context.airport_cloud_desc || airport.cloud_desc || "").trim();
+ 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)
+ : airport.visibility_mi != null && Number.isFinite(Number(airport.visibility_mi))
+ ? Number(airport.visibility_mi)
+ : null;
+ const decodedVisibility = visibility != null ? `${visibility.toFixed(1)}mi` : decodeRawMetarVisibility(rawMetar);
+
+ const parts: string[] = [];
+ if (temp != null) parts.push(formatTemperatureValue(temp, tempSymbol, { digits: 1 }));
+ if (windSpeed != null) {
+ parts.push(
+ windDir != null
+ ? isEn
+ ? `wind ${Math.round(windDir)}°/${Math.round(windSpeed)}kt`
+ : `风 ${Math.round(windDir)}°/${Math.round(windSpeed)}kt`
+ : isEn
+ ? `wind ${Math.round(windSpeed)}kt`
+ : `风 ${Math.round(windSpeed)}kt`,
+ );
+ }
+ if (decodedCloud) parts.push(isEn ? `cloud ${decodedCloud}` : `云况 ${decodedCloud}`);
+ 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" : "最新机场报文解读";
+ const head = [station, obsTime].filter(Boolean).join(" ");
+ return `${prefix}${head ? ` ${head}` : ""}:${parts.join(",")}。`;
+}
diff --git a/frontend/components/dashboard/opportunity-copy.ts b/frontend/components/dashboard/opportunity-copy.ts
new file mode 100644
index 00000000..41a6c751
--- /dev/null
+++ b/frontend/components/dashboard/opportunity-copy.ts
@@ -0,0 +1,10 @@
+import type { ScanOpportunityRow } from "@/lib/dashboard-types";
+
+export function getLocalizedRowText(
+ row: ScanOpportunityRow,
+ locale: string,
+ zh?: string | null,
+ en?: string | null,
+) {
+ return locale === "en-US" ? en || zh || null : zh || en || null;
+}
diff --git a/frontend/components/dashboard/opportunity-detail.ts b/frontend/components/dashboard/opportunity-detail.ts
new file mode 100644
index 00000000..fb939ceb
--- /dev/null
+++ b/frontend/components/dashboard/opportunity-detail.ts
@@ -0,0 +1,37 @@
+import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
+
+export function normalizeLookupKey(value?: string | null) {
+ return String(value || "")
+ .trim()
+ .toLowerCase()
+ .replace(/[\s_-]+/g, "");
+}
+
+export function getDetailForRow(
+ row: Pick,
+ cityDetailsByName?: Record,
+) {
+ if (!cityDetailsByName) return null;
+ const rowKeys = [row.city, row.city_display_name, row.display_name]
+ .map(normalizeLookupKey)
+ .filter(Boolean);
+ return (
+ Object.entries(cityDetailsByName).find(([name, detail]) => {
+ const detailKeys = [name, detail.name, detail.display_name]
+ .map(normalizeLookupKey)
+ .filter(Boolean);
+ return rowKeys.some((key) => detailKeys.includes(key));
+ })?.[1] || null
+ );
+}
+
+export function getDetailViewDate(detail: CityDetail, row?: ScanOpportunityRow | null) {
+ if (!row) return detail.local_date;
+ const rawDate = row.selected_date || row.local_date || "";
+ const phase = String(row.window_phase || "").toLowerCase();
+ if ((phase === "tomorrow" || phase === "week_ahead") && rawDate) return rawDate;
+ if (!rawDate || rawDate === detail.local_date || row.local_date === detail.local_date) {
+ return detail.local_date;
+ }
+ return detail.local_date || rawDate;
+}
diff --git a/frontend/components/dashboard/opportunity-evidence-summary.ts b/frontend/components/dashboard/opportunity-evidence-summary.ts
new file mode 100644
index 00000000..f0695739
--- /dev/null
+++ b/frontend/components/dashboard/opportunity-evidence-summary.ts
@@ -0,0 +1,127 @@
+import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
+import { formatTemperatureValue } from "@/lib/temperature-utils";
+import { formatTemperatureDelta } from "./opportunity-format";
+import { getMetarObservationContext } from "./opportunity-observation";
+import { getTargetRange } from "./opportunity-target";
+
+export function getDebDistanceSummary(
+ row: ScanOpportunityRow,
+ locale: string,
+ tempSymbol?: string | null,
+) {
+ const deb =
+ row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
+ ? Number(row.deb_prediction)
+ : null;
+ if (deb == null) return locale === "en-US" ? "DEB pending" : "DEB 待确认";
+ const { lower, upper } = getTargetRange(row);
+ if (lower != null && upper == null) {
+ const delta = deb - lower;
+ if (Math.abs(delta) < 0.05) return locale === "en-US" ? "DEB on threshold" : "DEB 贴近阈值";
+ return delta >= 0
+ ? locale === "en-US"
+ ? `DEB above by ${formatTemperatureDelta(delta, tempSymbol)}`
+ : `DEB 高于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`
+ : locale === "en-US"
+ ? `DEB below by ${formatTemperatureDelta(delta, tempSymbol)}`
+ : `DEB 低于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`;
+ }
+ if (upper != null && lower == null) {
+ const delta = deb - upper;
+ if (Math.abs(delta) < 0.05) return locale === "en-US" ? "DEB on threshold" : "DEB 贴近阈值";
+ return delta <= 0
+ ? locale === "en-US"
+ ? `DEB below by ${formatTemperatureDelta(delta, tempSymbol)}`
+ : `DEB 低于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`
+ : locale === "en-US"
+ ? `DEB above by ${formatTemperatureDelta(delta, tempSymbol)}`
+ : `DEB 高于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`;
+ }
+ if (lower != null && upper != null) {
+ if (deb >= lower && deb <= upper) return locale === "en-US" ? "DEB inside bucket" : "DEB 位于桶内";
+ const nearest = deb < lower ? lower : upper;
+ const delta = deb - nearest;
+ return deb < lower
+ ? locale === "en-US"
+ ? `DEB below bucket by ${formatTemperatureDelta(delta, tempSymbol)}`
+ : `DEB 低于桶 ${formatTemperatureDelta(delta, tempSymbol)}`
+ : locale === "en-US"
+ ? `DEB above bucket by ${formatTemperatureDelta(delta, tempSymbol)}`
+ : `DEB 高于桶 ${formatTemperatureDelta(delta, tempSymbol)}`;
+ }
+ return locale === "en-US"
+ ? `DEB ${formatTemperatureValue(deb, tempSymbol, { digits: 1 })}`
+ : `DEB ${formatTemperatureValue(deb, tempSymbol, { digits: 1 })}`;
+}
+
+export function getModelSupportSummary(
+ row: ScanOpportunityRow,
+ locale: string,
+) {
+ const sources = Object.values(row.model_cluster_sources || {})
+ .map((value) => Number(value))
+ .filter((value) => Number.isFinite(value));
+ const deb =
+ row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
+ ? Number(row.deb_prediction)
+ : null;
+ if (!sources.length || deb == null) return locale === "en-US" ? "Models pending" : "模型待确认";
+ const { lower, upper } = getTargetRange(row);
+ let supports = 0;
+ if (lower != null && upper == null) {
+ supports = sources.filter((value) => (deb >= lower ? value >= lower : value < lower)).length;
+ } else if (upper != null && lower == null) {
+ supports = sources.filter((value) => (deb <= upper ? value <= upper : value > upper)).length;
+ } else if (lower != null && upper != null) {
+ if (deb >= lower && deb <= upper) {
+ supports = sources.filter((value) => value >= lower && value <= upper).length;
+ } else if (deb < lower) {
+ supports = sources.filter((value) => value < lower).length;
+ } else {
+ supports = sources.filter((value) => value > upper).length;
+ }
+ } else {
+ const tolerance = 1;
+ supports = sources.filter((value) => Math.abs(value - deb) <= tolerance).length;
+ }
+ return locale === "en-US"
+ ? `${supports}/${sources.length} models support DEB`
+ : `${supports}/${sources.length} 模型支持 DEB`;
+}
+
+export function getMetarConflictSummary(
+ row: ScanOpportunityRow,
+ detail: CityDetail | null,
+ locale: string,
+) {
+ const obs = getMetarObservationContext(row, detail);
+ if (obs.stale || obs.maxTemp == null) return locale === "en-US" ? "METAR pending" : "METAR 待确认";
+ const deb =
+ row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
+ ? Number(row.deb_prediction)
+ : null;
+ const { lower, upper } = getTargetRange(row);
+ if (deb == null || (lower == null && upper == null)) {
+ return locale === "en-US" ? "METAR read only" : "METAR 仅参考";
+ }
+ const phase = String(row.window_phase || "").toLowerCase();
+ const peakPending =
+ phase === "early_today" ||
+ phase === "setup_today" ||
+ (row.minutes_until_peak_start != null && Number(row.minutes_until_peak_start) > 0);
+ if (lower != null && upper == null) {
+ if (deb < lower && obs.maxTemp >= lower) return locale === "en-US" ? "METAR conflicts" : "METAR 冲突";
+ if (deb >= lower && obs.maxTemp < lower && peakPending) return locale === "en-US" ? "Await peak" : "等待峰值";
+ return locale === "en-US" ? "METAR no conflict" : "METAR 未冲突";
+ }
+ if (upper != null && lower == null) {
+ if (deb <= upper && obs.maxTemp > upper) return locale === "en-US" ? "METAR conflicts" : "METAR 冲突";
+ if (deb > upper && obs.maxTemp <= upper && peakPending) return locale === "en-US" ? "Await peak" : "等待峰值";
+ return locale === "en-US" ? "METAR no conflict" : "METAR 未冲突";
+ }
+ if (lower != null && upper != null && deb >= lower && deb <= upper) {
+ if (obs.maxTemp > upper) return locale === "en-US" ? "METAR above bucket" : "METAR 已越过桶";
+ if (obs.maxTemp < lower && peakPending) return locale === "en-US" ? "Await peak" : "等待峰值";
+ }
+ return locale === "en-US" ? "METAR no conflict" : "METAR 未冲突";
+}
diff --git a/frontend/components/dashboard/opportunity-format.ts b/frontend/components/dashboard/opportunity-format.ts
new file mode 100644
index 00000000..c5916815
--- /dev/null
+++ b/frontend/components/dashboard/opportunity-format.ts
@@ -0,0 +1,124 @@
+import type { ScanOpportunityRow } from "@/lib/dashboard-types";
+import {
+ formatTemperatureValue,
+ normalizeTemperatureLabel,
+} from "@/lib/temperature-utils";
+import { getTargetRange } from "./opportunity-target";
+
+export function formatPercent(value?: number | null, signed = false) {
+ if (value == null || Number.isNaN(Number(value))) return "--";
+ const numeric = Number(value);
+ return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
+}
+
+export function normalizeProbability(value?: number | null) {
+ if (value == null) return null;
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric)) return null;
+ return Math.max(0, Math.min(1, numeric > 1 ? numeric / 100 : numeric));
+}
+
+export function formatWindowMinutes(value: number | null | undefined, locale: string) {
+ if (value == null || !Number.isFinite(Number(value))) return "--";
+ const minutes = Math.max(0, Math.round(Number(value)));
+ const hours = Math.floor(minutes / 60);
+ const remains = minutes % 60;
+ if (locale === "en-US") {
+ if (hours <= 0) return `${remains}m left`;
+ return `${hours}h ${remains}m left`;
+ }
+ if (hours <= 0) return `剩余 ${remains} 分钟`;
+ return `剩余 ${hours}h ${remains}m`;
+}
+
+export function formatMinuteSpan(value: number | null | undefined, locale: string) {
+ if (value == null || !Number.isFinite(Number(value))) return "--";
+ const minutes = Math.max(0, Math.round(Number(value)));
+ const hours = Math.floor(minutes / 60);
+ const remains = minutes % 60;
+ if (locale === "en-US") {
+ if (hours <= 0) return `${remains}m`;
+ return `${hours}h ${remains}m`;
+ }
+ if (hours <= 0) return `${remains} 分钟`;
+ return `${hours}h ${remains}m`;
+}
+
+export function formatAction(
+ row: ScanOpportunityRow,
+ locale: string,
+ tempSymbol?: string | null,
+) {
+ return formatTradeSide(row, locale, tempSymbol);
+}
+
+export function formatQuoteCents(value?: number | null) {
+ if (value == null || Number.isNaN(Number(value))) return "--";
+ const cents = Number(value) * 100;
+ const text =
+ cents < 1 || cents >= 99 || Math.abs(cents - Math.round(cents)) >= 0.05
+ ? cents.toFixed(1)
+ : Math.round(cents).toFixed(0);
+ return `${text.replace(/\.0$/, "")}¢`;
+}
+
+export function formatTradeSide(
+ row: ScanOpportunityRow,
+ locale: string,
+ tempSymbol?: string | null,
+) {
+ const side = String(row.side || "").toLowerCase();
+ const isEn = locale === "en-US";
+ const { lower, upper } = getTargetRange(row);
+ const threshold =
+ lower != null && upper == null
+ ? formatTemperatureValue(lower, tempSymbol)
+ : upper != null && lower == null
+ ? formatTemperatureValue(upper, tempSymbol)
+ : null;
+ if (threshold && lower != null && upper == null) {
+ if (side === "yes") return isEn ? `High reaches ${threshold}` : `最高温达到 ${threshold}`;
+ if (side === "no") return isEn ? `High stays below ${threshold}` : `最高温低于 ${threshold}`;
+ }
+ if (threshold && upper != null && lower == null) {
+ if (side === "yes") return isEn ? `High stays at/below ${threshold}` : `最高温不高于 ${threshold}`;
+ if (side === "no") return isEn ? `High exceeds ${threshold}` : `最高温高于 ${threshold}`;
+ }
+ if (lower != null && upper != null && Math.abs(lower - upper) > 0.01) {
+ const range = `${formatTemperatureValue(lower, tempSymbol)} ~ ${formatTemperatureValue(upper, tempSymbol)}`;
+ if (side === "yes") return isEn ? `High lands in ${range}` : `最高温落在 ${range}`;
+ if (side === "no") return isEn ? `High avoids ${range}` : `最高温不在 ${range}`;
+ }
+ const bucket = formatThreshold(row, tempSymbol);
+ if (side === "yes") return isEn ? `High lands on ${bucket}` : `最高温落在 ${bucket} 桶`;
+ if (side === "no") return isEn ? `High avoids ${bucket}` : `最高温不落在 ${bucket} 桶`;
+ if (row.action) {
+ return normalizeTemperatureLabel(
+ String(row.action).replace(String(row.target_label || ""), ""),
+ tempSymbol,
+ )
+ .replace(/\s+/g, " ")
+ .trim()
+ .toUpperCase();
+ }
+ return locale === "en-US" ? "WATCH" : "观察";
+}
+
+export function formatThreshold(row: ScanOpportunityRow, tempSymbol?: string | null) {
+ const targetLabel = normalizeTemperatureLabel(row.target_label, tempSymbol);
+ if (targetLabel) return targetLabel;
+ if (row.target_lower != null && row.target_upper != null) {
+ return `${formatTemperatureValue(Number(row.target_lower), tempSymbol)} ~ ${formatTemperatureValue(Number(row.target_upper), tempSymbol)}`;
+ }
+ if (row.target_threshold != null) {
+ return formatTemperatureValue(Number(row.target_threshold), tempSymbol);
+ }
+ if (row.target_value != null) {
+ return formatTemperatureValue(Number(row.target_value), tempSymbol);
+ }
+ return "--";
+}
+
+export function formatTemperatureDelta(value: number, tempSymbol?: string | null) {
+ return formatTemperatureValue(Math.abs(value), tempSymbol, { digits: 1 });
+}
diff --git a/frontend/components/dashboard/opportunity-groups.ts b/frontend/components/dashboard/opportunity-groups.ts
new file mode 100644
index 00000000..ae867fcc
--- /dev/null
+++ b/frontend/components/dashboard/opportunity-groups.ts
@@ -0,0 +1,185 @@
+import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
+import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
+import { getModelView, getProbabilityView } from "@/lib/model-utils";
+import {
+ formatTemperatureValue,
+ normalizeTemperatureSymbol,
+} from "@/lib/temperature-utils";
+import { getWindowPhaseMeta, type PhaseMeta } from "./opportunity-window-phase";
+import {
+ getDetailForRow,
+ getDetailViewDate,
+} from "./opportunity-detail";
+import {
+ formatThreshold,
+ normalizeProbability,
+} from "./opportunity-format";
+import { formatModelClusterRange } from "./opportunity-model-summary";
+import {
+ extractNumbers,
+ getTargetRange,
+ normalizeBucketLabel,
+} from "./opportunity-target";
+
+export function getBucketText(bucket: { label?: string | null; bucket?: string | null; range?: string | null }) {
+ return [bucket.label, bucket.bucket, bucket.range]
+ .map((value) => String(value || "").trim())
+ .filter(Boolean);
+}
+
+export function bucketMatchesRow(
+ bucket: {
+ label?: string | null;
+ bucket?: string | null;
+ range?: string | null;
+ value?: number | string | null;
+ },
+ row: ScanOpportunityRow,
+ tempSymbol?: string | null,
+) {
+ const targetLabel = normalizeBucketLabel(row.target_label, tempSymbol);
+ const bucketLabels = getBucketText(bucket).map((label) =>
+ normalizeBucketLabel(label, tempSymbol),
+ );
+ if (targetLabel && bucketLabels.some((label) => label === targetLabel)) {
+ return true;
+ }
+
+ const rawTargetLabel = String(row.target_label || "");
+ const targetNumbers = extractNumbers(rawTargetLabel);
+ const targetValue =
+ row.target_value ?? row.target_threshold ?? row.target_lower ?? row.target_upper ?? targetNumbers[0] ?? null;
+ if (targetValue == null || !Number.isFinite(Number(targetValue))) return false;
+
+ const bucketNumbers = [
+ ...(bucket.value != null && Number.isFinite(Number(bucket.value))
+ ? [Number(bucket.value)]
+ : []),
+ ...getBucketText(bucket).flatMap(extractNumbers),
+ ];
+ const matchesNumber = bucketNumbers.some(
+ (value) => Math.abs(Number(value) - Number(targetValue)) < 0.05,
+ );
+ if (!matchesNumber) return false;
+
+ const targetIsUpper =
+ /(\+|以上|or\s*above|above|greater|>=|≥)/i.test(rawTargetLabel) ||
+ (row.target_lower != null && row.target_upper == null);
+ const targetIsLower =
+ /(<=|≤|below|or\s*below|以下)/i.test(rawTargetLabel) ||
+ (row.target_upper != null && row.target_lower == null);
+ const bucketRaw = getBucketText(bucket).join(" ");
+ const bucketIsUpper = /(\+|以上|or\s*above|above|greater|>=|≥|inf|∞)/i.test(bucketRaw);
+ const bucketIsLower = /(<=|≤|below|or\s*below|以下|-inf|-∞)/i.test(bucketRaw);
+
+ if (targetIsUpper || bucketIsUpper) return targetIsUpper === bucketIsUpper;
+ if (targetIsLower || bucketIsLower) return targetIsLower === bucketIsLower;
+ return true;
+}
+
+export function getDetailBucketEventProbability(
+ detail: CityDetail | null,
+ row: ScanOpportunityRow,
+ tempSymbol?: string | null,
+) {
+ if (!detail) return null;
+ const view = getProbabilityView(detail, getDetailViewDate(detail, row));
+ const buckets = Array.isArray(view.probabilitiesAll)
+ ? view.probabilitiesAll
+ : [];
+ if (!buckets.length) return null;
+ const matched = buckets.find((bucket) => bucketMatchesRow(bucket, row, tempSymbol));
+ return normalizeProbability(matched?.probability);
+}
+
+export type OpportunityGroup = {
+ key: string;
+ cityName: string;
+ date?: string | null;
+ tempSymbol?: string | null;
+ debLabel: string;
+ peakLabel: string;
+ peakProbability?: number | null;
+ phaseMeta: PhaseMeta;
+ localTime?: string | null;
+ remainingMinutes?: number | null;
+ rows: ScanOpportunityRow[];
+};
+
+export function buildOpportunityGroups(
+ rows: ScanOpportunityRow[],
+ locale: string,
+ cityDetailsByName?: Record,
+): OpportunityGroup[] {
+ const groups = new Map();
+ for (const row of rows) {
+ const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
+ const detail = getDetailForRow(row, cityDetailsByName);
+ const cityName = getLocalizedCityName(
+ row.city,
+ row.city_display_name || row.display_name || row.city,
+ locale,
+ );
+ const date = detail ? getDetailViewDate(detail, row) : row.selected_date || row.local_date || "";
+ const key = `${row.city || cityName}|${date}`;
+ const modelView = detail ? getModelView(detail, date) : null;
+ const debPrediction = modelView?.deb ?? row.deb_prediction ?? null;
+ const modelClusterLabel = formatModelClusterRange(
+ modelView?.models || row.model_cluster_sources,
+ tempSymbol,
+ );
+ const existing = groups.get(key);
+ if (!existing) {
+ groups.set(key, {
+ key,
+ cityName,
+ date,
+ tempSymbol,
+ debLabel:
+ debPrediction != null
+ ? formatTemperatureValue(Number(debPrediction), tempSymbol, { digits: 1 })
+ : "--",
+ peakLabel: modelClusterLabel,
+ peakProbability: null,
+ phaseMeta: getWindowPhaseMeta(row, locale),
+ localTime: row.local_time,
+ remainingMinutes: row.remaining_window_minutes,
+ rows: [row],
+ });
+ continue;
+ }
+ existing.rows.push(row);
+ if (existing.peakLabel === "--" && modelClusterLabel !== "--") {
+ existing.peakLabel = modelClusterLabel;
+ }
+ }
+ return Array.from(groups.values()).map((group) => ({
+ ...group,
+ rows: [...group.rows].sort(
+ (a, b) =>
+ Number(b.edge_percent ?? -Infinity) - Number(a.edge_percent ?? -Infinity) ||
+ Number(b.final_score ?? -Infinity) - Number(a.final_score ?? -Infinity),
+ ),
+ }));
+}
+
+export 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);
+}
diff --git a/frontend/components/dashboard/opportunity-model-summary.ts b/frontend/components/dashboard/opportunity-model-summary.ts
new file mode 100644
index 00000000..cb31be99
--- /dev/null
+++ b/frontend/components/dashboard/opportunity-model-summary.ts
@@ -0,0 +1,46 @@
+import type { ScanOpportunityRow } from "@/lib/dashboard-types";
+import { formatTemperatureValue } from "@/lib/temperature-utils";
+
+export function formatModelSources(row: ScanOpportunityRow, tempSymbol?: string | null) {
+ const sources = row.model_cluster_sources || {};
+ return Object.entries(sources)
+ .filter(([, value]) => value != null && Number.isFinite(Number(value)))
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([name, value]) => ({
+ name,
+ value: formatTemperatureValue(Number(value), tempSymbol, { digits: 1 }),
+ }));
+}
+
+export function formatModelClusterRange(
+ sources?: Record | null,
+ tempSymbol?: string | null,
+) {
+ const values = Object.values(sources || {})
+ .map((value) => Number(value))
+ .filter((value) => Number.isFinite(value));
+ if (!values.length) return "--";
+ const low = Math.min(...values);
+ const high = Math.max(...values);
+ if (Math.abs(low - high) < 0.05) {
+ return formatTemperatureValue(low, tempSymbol, { digits: 1 });
+ }
+ return `${formatTemperatureValue(low, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(high, tempSymbol, { digits: 1 })}`;
+}
+
+export function getModelSourceSummary(
+ row: ScanOpportunityRow,
+ locale: string,
+ tempSymbol?: string | null,
+) {
+ const sources = formatModelSources(row, tempSymbol);
+ if (!sources.length) {
+ return locale === "en-US"
+ ? "model cluster pending"
+ : "模型集群暂未回传";
+ }
+ const shown = sources.map((item) => `${item.name} ${item.value}`).join(" / ");
+ return locale === "en-US"
+ ? `all models: ${shown}`
+ : `全部模型:${shown}`;
+}
diff --git a/frontend/components/dashboard/opportunity-observation.ts b/frontend/components/dashboard/opportunity-observation.ts
new file mode 100644
index 00000000..458d2796
--- /dev/null
+++ b/frontend/components/dashboard/opportunity-observation.ts
@@ -0,0 +1,328 @@
+import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
+import {
+ formatTemperatureValue,
+ normalizeTemperatureSymbol,
+} from "@/lib/temperature-utils";
+import {
+ formatAirportReportRead,
+ formatAirportWeatherRead,
+} from "./opportunity-airport-read";
+import { formatMinuteSpan } from "./opportunity-format";
+import { getTargetRange } from "./opportunity-target";
+
+export type ObservationPoint = { time?: string; temp?: number | null };
+
+export function normalizeObservationPoints(points?: ObservationPoint[] | null) {
+ if (!Array.isArray(points)) return [];
+ return points
+ .map((point) => ({
+ time: String(point?.time || "").trim(),
+ temp:
+ point?.temp != null && Number.isFinite(Number(point.temp))
+ ? Number(point.temp)
+ : null,
+ }))
+ .filter((point): point is { time: string; temp: number } =>
+ Boolean(point.time && point.temp != null),
+ )
+ .sort((a, b) => getObservationSortMinutes(a.time) - getObservationSortMinutes(b.time));
+}
+
+export function getObservationSortMinutes(time: string) {
+ const parsed = Date.parse(time);
+ if (Number.isFinite(parsed)) {
+ const date = new Date(parsed);
+ return date.getUTCHours() * 60 + date.getUTCMinutes();
+ }
+ const match = time.match(/(\d{1,2}):(\d{2})/);
+ if (!match) return Number.MAX_SAFE_INTEGER;
+ return Number(match[1]) * 60 + Number(match[2]);
+}
+
+export function formatPeakWindowTiming(row: ScanOpportunityRow, locale: string) {
+ const isEn = locale === "en-US";
+ const phase = String(row.window_phase || "").toLowerCase();
+ const label = String(row.peak_window_label || "").trim();
+ const untilStart =
+ row.minutes_until_peak_start != null && Number.isFinite(Number(row.minutes_until_peak_start))
+ ? Number(row.minutes_until_peak_start)
+ : null;
+ const untilEnd =
+ row.minutes_until_peak_end != null && Number.isFinite(Number(row.minutes_until_peak_end))
+ ? Number(row.minutes_until_peak_end)
+ : null;
+ const windowText = label ? `${isEn ? "peak window" : "峰值窗口"} ${label}` : isEn ? "peak window" : "峰值窗口";
+ if (phase === "active_peak" || (untilStart != null && untilStart <= 0 && untilEnd != null && untilEnd > 0)) {
+ return isEn ? `Currently inside the ${windowText}.` : `当前已进入${windowText}。`;
+ }
+ if (phase === "post_peak" || (untilEnd != null && untilEnd <= 0)) {
+ return isEn ? `The ${windowText} has passed.` : `${windowText}已结束。`;
+ }
+ if (untilStart != null && untilStart > 0) {
+ return isEn
+ ? `${windowText} starts in ${formatMinuteSpan(untilStart, locale)}.`
+ : `${windowText}尚未开始,约 ${formatMinuteSpan(untilStart, locale)} 后进入。`;
+ }
+ if (phase === "early_today" || phase === "setup_today") {
+ return isEn ? `Before the ${windowText}; latest METAR is not final peak evidence yet.` : `尚处峰值前,最新 METAR 还不能当作最终峰值证据。`;
+ }
+ return label ? (isEn ? `Reference ${windowText}.` : `参考${windowText}。`) : null;
+}
+
+export function firstNonEmptyPoints(...groups: Array>) {
+ return groups.find((group) => group.length > 0) || [];
+}
+
+export function getMetarObservationContext(
+ row: ScanOpportunityRow,
+ detail: CityDetail | null,
+) {
+ const context = row.metar_context || {};
+ const metarToday = firstNonEmptyPoints(
+ normalizeObservationPoints(context.today_obs),
+ normalizeObservationPoints(row.metar_today_obs),
+ );
+ const detailMetarToday = normalizeObservationPoints(detail?.metar_today_obs);
+ const metarRecent = firstNonEmptyPoints(
+ normalizeObservationPoints(context.recent_obs),
+ normalizeObservationPoints(row.metar_recent_obs),
+ );
+ const detailMetarRecent = normalizeObservationPoints(detail?.metar_recent_obs);
+ const settlementToday = firstNonEmptyPoints(
+ normalizeObservationPoints(context.settlement_today_obs),
+ normalizeObservationPoints(row.settlement_today_obs),
+ );
+ const detailSettlementToday = normalizeObservationPoints(detail?.settlement_today_obs);
+
+ const primaryPoints =
+ metarToday.length
+ ? metarToday
+ : detailMetarToday.length
+ ? detailMetarToday
+ : metarRecent.length
+ ? metarRecent
+ : detailMetarRecent.length
+ ? detailMetarRecent
+ : settlementToday.length
+ ? settlementToday
+ : detailSettlementToday;
+ const trendPoints =
+ (metarRecent.length
+ ? metarRecent
+ : detailMetarRecent.length
+ ? detailMetarRecent
+ : primaryPoints.slice(-4));
+ const explicitLast = context.last_temp ?? row.metar_status?.last_temp ?? detail?.metar_status?.last_temp;
+ const lastPoint = primaryPoints[primaryPoints.length - 1] || null;
+ const maxPoint = primaryPoints.reduce<{ time: string; temp: number } | null>(
+ (best, point) => (!best || point.temp >= best.temp ? point : best),
+ null,
+ );
+ const trendFirst = trendPoints[0] || null;
+ const trendLast = trendPoints[trendPoints.length - 1] || null;
+ const trendDelta =
+ context.trend_delta != null && Number.isFinite(Number(context.trend_delta))
+ ? Number(context.trend_delta)
+ : trendFirst && trendLast && trendPoints.length >= 2
+ ? trendLast.temp - trendFirst.temp
+ : null;
+ const lastTemp =
+ explicitLast != null && Number.isFinite(Number(explicitLast))
+ ? Number(explicitLast)
+ : lastPoint?.temp ?? null;
+ const maxTemp =
+ context.max_temp != null && Number.isFinite(Number(context.max_temp))
+ ? Number(context.max_temp)
+ : maxPoint?.temp ?? null;
+ const stale =
+ context.stale_for_today === true ||
+ row.metar_status?.stale_for_today === true ||
+ detail?.metar_status?.stale_for_today === true;
+
+ return {
+ points: primaryPoints,
+ lastTime: String(context.last_time || lastPoint?.time || ""),
+ lastTemp,
+ maxTime: String(context.max_time || maxPoint?.time || ""),
+ maxTemp,
+ trendDelta,
+ stale,
+ station: context.station || detail?.risk?.icao || detail?.airport_current?.station_code || null,
+ };
+}
+
+export function getMetarGate(
+ row: ScanOpportunityRow,
+ detail: CityDetail | null,
+ locale: string,
+ tempSymbol?: string | null,
+) {
+ const isEn = locale === "en-US";
+ const side = String(row.side || "").toLowerCase();
+ const selectedDate = String(row.selected_date || "");
+ const localDate = String(row.local_date || detail?.local_date || "");
+ const futureContract = Boolean(selectedDate && localDate && selectedDate > localDate);
+ if (futureContract) return null;
+
+ const obs = getMetarObservationContext(row, detail);
+ const evidence: string[] = [];
+ 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}` : ""}`,
+ );
+ }
+ if (obs.maxTemp != null) {
+ evidence.push(
+ `${isEn ? "METAR max" : "METAR 最高"} ${formatTemperatureValue(obs.maxTemp, unit, { digits: 1 })}${obs.maxTime ? ` @ ${obs.maxTime}` : ""}`,
+ );
+ }
+ if (obs.trendDelta != null) {
+ evidence.push(
+ `${isEn ? "Recent METAR delta" : "近端 METAR 变化"} ${formatTemperatureValue(obs.trendDelta, unit, { digits: 1 })}`,
+ );
+ }
+ if (obs.station) evidence.push(`${isEn ? "Station" : "站点"} ${obs.station}`);
+
+ if (obs.stale || !obs.points.length || obs.maxTemp == null) {
+ return {
+ decision: "downgrade" as const,
+ reason: isEn
+ ? "AI has no same-day METAR confirmation yet, so this bucket remains forecast-only."
+ : "AI 还没有拿到同日 METAR 确认,该桶暂时只能作为预测映射。",
+ evidence,
+ };
+ }
+
+ const { lower, upper } = getTargetRange(row);
+ if (lower == null && upper == null) {
+ return {
+ decision: "watchlist" as const,
+ reason: isEn
+ ? "AI has METAR data, but the contract threshold cannot be mapped cleanly to a bucket."
+ : "AI 已读取 METAR,但该合约阈值无法稳定映射到温度桶。",
+ evidence,
+ };
+ }
+
+ const epsilon = String(unit).toUpperCase().includes("F") ? 0.7 : 0.4;
+ const trendDelta = obs.trendDelta;
+ const isNotRising = trendDelta != null && trendDelta <= epsilon;
+ const isFalling = trendDelta != null && trendDelta <= -epsilon;
+ const phase = String(row.window_phase || "").toLowerCase();
+ const remaining =
+ row.remaining_window_minutes != null && Number.isFinite(Number(row.remaining_window_minutes))
+ ? Number(row.remaining_window_minutes)
+ : null;
+ const minutesUntilPeakStart =
+ row.minutes_until_peak_start != null && Number.isFinite(Number(row.minutes_until_peak_start))
+ ? Number(row.minutes_until_peak_start)
+ : null;
+ const lateWindow =
+ phase === "active_peak" ||
+ phase === "post_peak" ||
+ (remaining != null && remaining <= 180);
+ const beforePeak =
+ phase === "early_today" ||
+ phase === "setup_today" ||
+ phase === "tomorrow" ||
+ phase === "week_ahead" ||
+ (minutesUntilPeakStart != null && minutesUntilPeakStart > 0);
+ const aboveUpper = upper != null && obs.maxTemp > upper + epsilon;
+ const belowLower = lower != null && obs.maxTemp < lower - epsilon;
+ const insideBucket =
+ (lower == null || obs.maxTemp >= lower - epsilon) &&
+ (upper == null || obs.maxTemp <= upper + epsilon);
+
+ if (side === "no") {
+ if (aboveUpper) {
+ return {
+ decision: "approve" as const,
+ reason: isEn
+ ? "METAR max has already moved above this bucket, so AI marks the NO bucket as observation-supported."
+ : "METAR 实测最高已越过目标桶上沿,AI 判断 NO 桶有实测支撑。",
+ evidence,
+ };
+ }
+ if (belowLower && (lateWindow || isFalling || isNotRising)) {
+ if (beforePeak && !lateWindow) {
+ return {
+ decision: "watchlist" as const,
+ reason: isEn
+ ? "The peak window has not arrived, so a still-low METAR path cannot confirm this NO bucket yet; AI keeps it on watch."
+ : "峰值窗口尚未到来,METAR 暂未触达不能直接确认 NO 桶,AI 先列观察。",
+ evidence,
+ };
+ }
+ return {
+ decision: "approve" as const,
+ reason: isEn
+ ? "METAR max remains below this bucket and recent observations are not strengthening, so AI favors the NO bucket."
+ : "METAR 最高仍低于目标桶且近期走势不强,AI 倾向 NO 桶。",
+ evidence,
+ };
+ }
+ if (insideBucket && lateWindow && isNotRising) {
+ return {
+ decision: "downgrade" as const,
+ reason: isEn
+ ? "METAR max is still close to this bucket, so AI cannot treat the NO bucket as confirmed."
+ : "METAR 最高仍贴近目标桶,AI 不能把 NO 桶视为已确认。",
+ evidence,
+ };
+ }
+ }
+
+ if (side === "yes") {
+ if (aboveUpper) {
+ return {
+ decision: "veto" as const,
+ reason: isEn
+ ? "METAR max has already exceeded the bucket, so AI marks this YES bucket as outside the observed path."
+ : "METAR 实测最高已越过目标桶上沿,AI 判断该 YES 桶已偏离实测路径。",
+ evidence,
+ };
+ }
+ if (belowLower && (lateWindow || isFalling || isNotRising)) {
+ if (beforePeak && !lateWindow) {
+ return {
+ decision: "watchlist" as const,
+ reason: isEn
+ ? "The peak window has not arrived, so METAR not reaching the bucket only means this bucket still needs peak-window confirmation."
+ : "峰值窗口尚未到来,METAR 未触达目标桶只能说明仍需等待峰值验证,AI 暂列观察。",
+ evidence,
+ };
+ }
+ return {
+ decision: "downgrade" as const,
+ reason: isEn
+ ? "METAR max has not reached the bucket and recent observations are weak, so AI downgrades the YES bucket."
+ : "METAR 最高仍未触达目标桶且走势不强,AI 将 YES 桶降级观察。",
+ evidence,
+ };
+ }
+ if (insideBucket) {
+ return {
+ decision: "approve" as const,
+ reason: isEn
+ ? "METAR max is inside the target bucket, so AI sees observation support while monitoring an overshoot."
+ : "METAR 实测最高已落入目标桶,AI 认为 YES 桶有实测依据,但仍需防止继续升穿上沿。",
+ evidence,
+ };
+ }
+ }
+
+ return {
+ decision: "watchlist" as const,
+ reason: isEn
+ ? "METAR does not give a clean final confirmation yet, so AI keeps this as watchlist."
+ : "METAR 还没有给出干净的最终确认,AI 暂列观察。",
+ evidence,
+ };
+}
diff --git a/frontend/components/dashboard/opportunity-target.ts b/frontend/components/dashboard/opportunity-target.ts
new file mode 100644
index 00000000..04a0d5db
--- /dev/null
+++ b/frontend/components/dashboard/opportunity-target.ts
@@ -0,0 +1,48 @@
+import type { ScanOpportunityRow } from "@/lib/dashboard-types";
+import { normalizeTemperatureLabel } from "@/lib/temperature-utils";
+
+export function normalizeBucketLabel(value?: string | null, tempSymbol?: string | null) {
+ return normalizeTemperatureLabel(value, tempSymbol)
+ .toLowerCase()
+ .replace(/\s+/g, "")
+ .replace(/℃/g, "°c");
+}
+
+export function extractNumbers(value?: string | null) {
+ return Array.from(String(value || "").matchAll(/-?\d+(?:\.\d+)?/g)).map((match) =>
+ Number(match[0]),
+ );
+}
+
+export function getTargetRange(row: ScanOpportunityRow) {
+ const lower =
+ row.target_lower != null && Number.isFinite(Number(row.target_lower))
+ ? Number(row.target_lower)
+ : null;
+ const upper =
+ row.target_upper != null && Number.isFinite(Number(row.target_upper))
+ ? Number(row.target_upper)
+ : null;
+ if (lower != null || upper != null) return { lower, upper };
+
+ const rawLabel = String(row.target_label || row.action || "");
+ const numbers = extractNumbers(rawLabel);
+ if (numbers.length >= 2) {
+ return { lower: Math.min(numbers[0], numbers[1]), upper: Math.max(numbers[0], numbers[1]) };
+ }
+ const value =
+ row.target_threshold ??
+ row.target_value ??
+ (numbers.length ? numbers[0] : null);
+ if (value == null || !Number.isFinite(Number(value))) {
+ return { lower: null, upper: null };
+ }
+ const numeric = Number(value);
+ if (/(\+|above|higher|or\s+higher|>=|≥|以上)/i.test(rawLabel)) {
+ return { lower: numeric, upper: null };
+ }
+ if (/(below|or\s+below|<=|≤|以下)/i.test(rawLabel)) {
+ return { lower: null, upper: numeric };
+ }
+ return { lower: numeric, upper: numeric };
+}
diff --git a/frontend/components/dashboard/opportunity-v4-decision.ts b/frontend/components/dashboard/opportunity-v4-decision.ts
new file mode 100644
index 00000000..cf1bbcf8
--- /dev/null
+++ b/frontend/components/dashboard/opportunity-v4-decision.ts
@@ -0,0 +1,293 @@
+import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
+import {
+ formatTemperatureValue,
+ normalizeTemperatureSymbol,
+} from "@/lib/temperature-utils";
+import { formatAirportReportRead } from "./opportunity-airport-read";
+import { getLocalizedRowText } from "./opportunity-copy";
+import { getBucketDisplayLabel } from "./opportunity-groups";
+import { getMetarGate } from "./opportunity-observation";
+import { getTargetRange } from "./opportunity-target";
+import {
+ getForecastContractFit,
+ getForecastRangeLabel,
+ getPaceDecisionTail,
+} from "./opportunity-v4-forecast";
+import type { V4CityForecast, V4TradeDecision } from "./opportunity-v4-types";
+
+export function getV4DecisionLabel(
+ decision: V4TradeDecision["decision"],
+ locale: string,
+) {
+ if (locale === "en-US") {
+ if (decision === "approve") return "AI Confirmed";
+ if (decision === "veto") return "AI Outside";
+ if (decision === "downgrade") return "AI Downgrade";
+ return "AI Watch";
+ }
+ if (decision === "approve") return "AI 确认";
+ if (decision === "veto") return "AI 区间外";
+ if (decision === "downgrade") return "AI 降级";
+ return "AI 观察";
+}
+
+export function getV4TradeDecision(
+ row: ScanOpportunityRow,
+ detail: CityDetail | null,
+ locale: string,
+ edgePercent?: number | null,
+ tempSymbol?: string | null,
+): V4TradeDecision {
+ const isEn = locale === "en-US";
+ const backendMetarDecision = String(row.v4_metar_decision || "").toLowerCase();
+ const backendMetarReason =
+ getLocalizedRowText(row, locale, row.v4_metar_reason_zh, row.v4_metar_reason_en) ||
+ null;
+ const metarGate = getMetarGate(row, detail, locale, tempSymbol);
+ const aiDecision = String(row.ai_decision || "").toLowerCase();
+ const aiReason =
+ getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
+ getLocalizedRowText(
+ row,
+ locale,
+ row.ai_watchlist_reason_zh,
+ row.ai_watchlist_reason_en,
+ );
+
+ let decision: V4TradeDecision["decision"] =
+ backendMetarDecision === "veto" ||
+ backendMetarDecision === "downgrade" ||
+ backendMetarDecision === "approve" ||
+ backendMetarDecision === "watchlist"
+ ? (backendMetarDecision as V4TradeDecision["decision"])
+ : metarGate?.decision ||
+ (aiDecision === "veto" || aiDecision === "downgrade" || aiDecision === "approve" || aiDecision === "watchlist"
+ ? (aiDecision as V4TradeDecision["decision"])
+ : Number(edgePercent || 0) >= 20
+ ? "watchlist"
+ : "watchlist");
+
+ if (metarGate?.decision === "veto") decision = "veto";
+ if (metarGate?.decision === "watchlist" && backendMetarDecision === "downgrade") {
+ decision = "watchlist";
+ }
+ if (metarGate?.decision === "downgrade" && decision !== "veto") decision = "downgrade";
+ if (metarGate?.decision === "approve" && decision !== "veto" && decision !== "downgrade") {
+ decision = "approve";
+ }
+
+ const reason =
+ (metarGate?.decision === "watchlist" ? metarGate.reason : null) ||
+ backendMetarReason ||
+ metarGate?.reason ||
+ aiReason ||
+ (isEn
+ ? "AI keeps this on watch until METAR and the full weather-model cluster align."
+ : "AI 会等 METAR 与全量天气模型集群对齐后再确认。");
+ const airportReport = formatAirportReportRead(
+ row,
+ detail,
+ locale,
+ normalizeTemperatureSymbol(tempSymbol),
+ );
+ const metarSummary =
+ metarGate?.evidence?.filter((item) => item !== airportReport).join(" · ") || null;
+ return {
+ decision,
+ label: getV4DecisionLabel(decision, locale),
+ tone: decision,
+ reason,
+ metarSummary,
+ airportReport,
+ metarEvidence: metarGate?.evidence || [],
+ };
+}
+
+export 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",
+ };
+}
+
+export 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),
+ };
+}
diff --git a/frontend/components/dashboard/opportunity-v4-forecast.ts b/frontend/components/dashboard/opportunity-v4-forecast.ts
new file mode 100644
index 00000000..7c1088fe
--- /dev/null
+++ b/frontend/components/dashboard/opportunity-v4-forecast.ts
@@ -0,0 +1,250 @@
+import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
+import { getTodayPaceView } from "@/lib/pace-utils";
+import {
+ formatTemperatureValue,
+ normalizeTemperatureSymbol,
+} from "@/lib/temperature-utils";
+import {
+ formatAirportReportRead,
+ formatAirportWeatherRead,
+} from "./opportunity-airport-read";
+import { getLocalizedRowText } from "./opportunity-copy";
+import { formatPeakWindowTiming } from "./opportunity-observation";
+import { getModelSourceSummary } from "./opportunity-model-summary";
+import { getTargetRange } from "./opportunity-target";
+import type { OpportunityGroup } from "./opportunity-groups";
+import type { V4CityForecast } from "./opportunity-v4-types";
+
+export 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,
+ };
+}
+
+export 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}`;
+}
+
+export 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 路径。";
+}
+
+export function median(values: number[]) {
+ if (!values.length) return null;
+ const sorted = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
+}
+
+export function getV4CityForecast(
+ row: ScanOpportunityRow,
+ group: OpportunityGroup,
+ detail: CityDetail | null,
+ locale: string,
+ tempSymbol?: string | null,
+): V4CityForecast {
+ const isEn = locale === "en-US";
+ const aiPredicted =
+ row.ai_predicted_max != null && Number.isFinite(Number(row.ai_predicted_max))
+ ? Number(row.ai_predicted_max)
+ : null;
+ const aiLow =
+ row.ai_predicted_low != null && Number.isFinite(Number(row.ai_predicted_low))
+ ? Number(row.ai_predicted_low)
+ : null;
+ const aiHigh =
+ row.ai_predicted_high != null && Number.isFinite(Number(row.ai_predicted_high))
+ ? Number(row.ai_predicted_high)
+ : null;
+ const modelValues = Object.values(row.model_cluster_sources || {})
+ .map((value) => Number(value))
+ .filter((value) => Number.isFinite(value));
+ const fallbackPredicted =
+ aiPredicted ??
+ (row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
+ ? Number(row.deb_prediction)
+ : median(modelValues));
+ const fallbackLow = aiLow ?? (modelValues.length ? Math.min(...modelValues) : fallbackPredicted);
+ const fallbackHigh = aiHigh ?? (modelValues.length ? Math.max(...modelValues) : fallbackPredicted);
+ const peakWindow =
+ getLocalizedRowText(row, locale, row.ai_peak_window_zh, row.ai_peak_window_en) ||
+ formatPeakWindowTiming(row, locale);
+ const airportRead =
+ getLocalizedRowText(
+ row,
+ locale,
+ 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 ||
+ getModelSourceSummary(row, locale, tempSymbol);
+ const reason =
+ getLocalizedRowText(row, locale, row.ai_forecast_reason_zh, row.ai_forecast_reason_en) ||
+ 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 })}; market temperature buckets are only mapped against that forecast range.`
+ : `${group.cityName} 最终最高温先以 ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })} 附近为中枢,市场温度桶只用于对照 AI 预测区间。`
+ : null);
+ return {
+ predicted: fallbackPredicted,
+ low: fallbackLow,
+ high: fallbackHigh,
+ 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",
+ };
+}
+
+export function getForecastRangeLabel(forecast: V4CityForecast, tempSymbol?: string | null) {
+ if (forecast.low == null && forecast.high == null) return "--";
+ if (forecast.low != null && forecast.high != null) {
+ if (Math.abs(forecast.low - forecast.high) < 0.05) {
+ return formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 });
+ }
+ return `${formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(forecast.high, tempSymbol, { digits: 1 })}`;
+ }
+ if (forecast.low != null) return `>= ${formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 })}`;
+ return `<= ${formatTemperatureValue(Number(forecast.high), tempSymbol, { digits: 1 })}`;
+}
+
+export function getForecastContractFit(
+ row: ScanOpportunityRow,
+ forecast: V4CityForecast,
+ locale: string,
+ tempSymbol?: string | null,
+) {
+ const isEn = locale === "en-US";
+ const explicitMatch = String(row.ai_forecast_match || "").toLowerCase();
+ const explicitReason = getLocalizedRowText(
+ row,
+ locale,
+ row.ai_forecast_match_reason_zh,
+ row.ai_forecast_match_reason_en,
+ );
+ if (explicitMatch && explicitReason) {
+ return {
+ label:
+ explicitMatch === "core"
+ ? isEn ? "Core bucket" : "核心桶"
+ : explicitMatch === "outside"
+ ? isEn ? "Outside forecast" : "预测区间外"
+ : explicitMatch === "edge"
+ ? isEn ? "Boundary bucket" : "边界桶"
+ : isEn ? "Watch" : "观察",
+ tone: explicitMatch === "core" ? "approve" : explicitMatch === "outside" ? "veto" : "watchlist",
+ reason: explicitReason,
+ };
+ }
+
+ const { lower, upper } = getTargetRange(row);
+ const predicted = forecast.predicted;
+ if (predicted == null || (lower == null && upper == null)) {
+ return {
+ label: isEn ? "Await forecast" : "等待预测",
+ tone: "watchlist",
+ reason: isEn ? "AI has no stable max-temperature center yet." : "AI 还没有稳定的最高温中枢。",
+ };
+ }
+ const unit = normalizeTemperatureSymbol(tempSymbol);
+ const tolerance = String(unit).toUpperCase().includes("F") ? 1.0 : 0.5;
+ const inside =
+ (lower == null || predicted >= lower - tolerance) &&
+ (upper == null || predicted <= upper + tolerance);
+ const rangeOverlaps =
+ forecast.low != null &&
+ forecast.high != null &&
+ (lower == null || forecast.high >= lower - tolerance) &&
+ (upper == null || forecast.low <= upper + tolerance);
+ if (inside) {
+ return {
+ label: isEn ? "Core bucket" : "核心桶",
+ tone: "approve",
+ reason: isEn
+ ? `AI max-temperature center ${formatTemperatureValue(predicted, unit, { digits: 1 })} sits inside this bucket.`
+ : `AI 最高温中枢 ${formatTemperatureValue(predicted, unit, { digits: 1 })} 落在这个温度桶内。`,
+ };
+ }
+ if (rangeOverlaps) {
+ return {
+ label: isEn ? "Boundary bucket" : "边界桶",
+ tone: "watchlist",
+ reason: isEn
+ ? `This bucket touches the AI interval ${getForecastRangeLabel(forecast, unit)}, but is not the center.`
+ : `该桶触及 AI 区间 ${getForecastRangeLabel(forecast, unit)},但不是预测中枢。`,
+ };
+ }
+ return {
+ label: isEn ? "Outside forecast" : "预测区间外",
+ tone: "veto",
+ reason: isEn
+ ? `This bucket is outside the AI max-temperature interval ${getForecastRangeLabel(forecast, unit)}.`
+ : `该桶位于 AI 最高温区间 ${getForecastRangeLabel(forecast, unit)} 之外。`,
+ };
+}
diff --git a/frontend/components/dashboard/opportunity-v4-risk.ts b/frontend/components/dashboard/opportunity-v4-risk.ts
new file mode 100644
index 00000000..2128f7f4
--- /dev/null
+++ b/frontend/components/dashboard/opportunity-v4-risk.ts
@@ -0,0 +1,104 @@
+import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
+import {
+ formatTemperatureValue,
+ normalizeTemperatureSymbol,
+} from "@/lib/temperature-utils";
+import { formatModelClusterRange } from "./opportunity-model-summary";
+import { getMetarObservationContext } from "./opportunity-observation";
+import type { V4CityForecast } from "./opportunity-v4-types";
+
+export 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);
+}
+
+export 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);
+}
diff --git a/frontend/components/dashboard/opportunity-v4-types.ts b/frontend/components/dashboard/opportunity-v4-types.ts
new file mode 100644
index 00000000..6c231453
--- /dev/null
+++ b/frontend/components/dashboard/opportunity-v4-types.ts
@@ -0,0 +1,26 @@
+export type V4TradeDecision = {
+ decision: "approve" | "downgrade" | "veto" | "watchlist";
+ label: string;
+ tone: "approve" | "downgrade" | "veto" | "watchlist";
+ reason: string;
+ metarSummary?: string | null;
+ airportReport?: string | null;
+ metarEvidence: string[];
+};
+
+export type V4CityForecast = {
+ predicted: number | null;
+ low: number | null;
+ high: number | null;
+ 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";
+};
diff --git a/frontend/components/dashboard/opportunity-window-phase.ts b/frontend/components/dashboard/opportunity-window-phase.ts
new file mode 100644
index 00000000..7fcf39e8
--- /dev/null
+++ b/frontend/components/dashboard/opportunity-window-phase.ts
@@ -0,0 +1,59 @@
+import type { ScanOpportunityRow } from "@/lib/dashboard-types";
+
+export type PhaseMeta = {
+ label: string;
+ tone: "green" | "amber" | "blue" | "red";
+};
+
+export function getWindowPhaseMeta(
+ row: Pick,
+ locale: string,
+): PhaseMeta {
+ const mode = String(row.window_phase || "").toLowerCase();
+ if (mode === "city_snapshot") {
+ return {
+ label: locale === "en-US" ? "City Snapshot" : "城市概况",
+ tone: "blue",
+ };
+ }
+ if (mode === "active_peak") {
+ return {
+ label: locale === "en-US" ? "Peak Window" : "峰值窗口",
+ tone: "red",
+ };
+ }
+ if (mode === "setup_today") {
+ return {
+ label: locale === "en-US" ? "Touch Play" : "触达博弈",
+ tone: "red",
+ };
+ }
+ if (mode === "early_today") {
+ return {
+ label: locale === "en-US" ? "Early Today" : "日内早段",
+ tone: "blue",
+ };
+ }
+ if (mode === "tomorrow" || mode === "week_ahead") {
+ return {
+ label: locale === "en-US" ? "Early" : "早期机会",
+ tone: "blue",
+ };
+ }
+ if (mode === "post_peak") {
+ return {
+ label: locale === "en-US" ? "Post Peak" : "峰后确认",
+ tone: "amber",
+ };
+ }
+ if (row.trend_alignment) {
+ return {
+ label: locale === "en-US" ? "Trend" : "趋势确认",
+ tone: "amber",
+ };
+ }
+ return {
+ label: locale === "en-US" ? "Tradable" : "可交易",
+ tone: "green",
+ };
+}
diff --git a/frontend/components/dashboard/scan-terminal/CalendarView.tsx b/frontend/components/dashboard/scan-terminal/CalendarView.tsx
index 286448c9..1afcbd74 100644
--- a/frontend/components/dashboard/scan-terminal/CalendarView.tsx
+++ b/frontend/components/dashboard/scan-terminal/CalendarView.tsx
@@ -1,5 +1,5 @@
import { memo, useEffect, useMemo, useState } from "react";
-import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable";
+import { getWindowPhaseMeta } from "@/components/dashboard/opportunity-window-phase";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
import { formatTemperatureValue } from "@/lib/temperature-utils";
@@ -73,7 +73,7 @@ const CalendarActionCard = memo(function CalendarActionCard({
);
});
-export function CalendarView({
+export const CalendarView = memo(function CalendarView({
rows,
locale,
selectedRowId,
@@ -181,5 +181,5 @@ export function CalendarView({
))}
);
-}
+});
diff --git a/frontend/components/dashboard/scan-terminal/OpportunityOverview.tsx b/frontend/components/dashboard/scan-terminal/OpportunityOverview.tsx
index 65d824b6..03a93126 100644
--- a/frontend/components/dashboard/scan-terminal/OpportunityOverview.tsx
+++ b/frontend/components/dashboard/scan-terminal/OpportunityOverview.tsx
@@ -93,7 +93,7 @@ const OpportunityDecisionCard = memo(function OpportunityDecisionCard({
);
});
-export function OpportunityOverview({
+export const OpportunityOverview = memo(function OpportunityOverview({
rows,
terminalData,
loading,
@@ -212,5 +212,5 @@ export function OpportunityOverview({
);
-}
+});