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 && ( -
-
- )} + {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) => ( -
-
- ))} -
+ {isNoaaSettlement && (
{locale === "en-US" @@ -1773,253 +1034,29 @@ function FutureForecastModalContent({ {isToday ? (
@@ -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}
- {modelSummary}
-
) : ( - <> -
-
- {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 ( +
+
+ ); +} + +export function FutureSyncStatusStrip({ + items, + compact = false, +}: { + items: readonly FutureSyncStatusItem[]; + compact?: boolean; +}) { + return ( +
+ {items.map((item) => ( +
+
+ ))} +
+ ); +} 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 ( -
-
{row.label}
-
-
- {probability}% -
-
-
- ); - }) - )} -
- {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 ( +
+
{row.label}
+
+
+ {probability}% +
+
+
+ ); + }) + )} +
+ {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({
); -} +});