diff --git a/frontend/components/dashboard/FutureForecastForwardView.tsx b/frontend/components/dashboard/FutureForecastForwardView.tsx deleted file mode 100644 index 9d8b5d8b..00000000 --- a/frontend/components/dashboard/FutureForecastForwardView.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"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 deleted file mode 100644 index b271cbef..00000000 --- a/frontend/components/dashboard/FutureForecastModal.module.css +++ /dev/null @@ -1,1793 +0,0 @@ -/* 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: var(--color-accent-secondary); - 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: var(--color-accent-secondary); - 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: var(--color-accent-secondary); - 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: var(--color-accent-secondary); - 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: var(--color-accent-secondary); - 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: var(--color-accent-secondary); -} - -.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%, var(--color-accent-secondary) 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) { - display: block; - width: 100%; - height: 100%; -} - -.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-forward-stats) { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 14px; -} - -.root :global(.future-forward-stat-card) { - min-width: 0; - overflow: hidden; - padding: 16px 18px; - border: 1px solid var(--border-subtle); - border-radius: 14px; - background: linear-gradient( - 180deg, - rgba(34, 211, 238, 0.075) 0%, - rgba(255, 255, 255, 0.025) 100% - ); - box-shadow: 0 16px 42px rgba(2, 6, 23, 0.18); -} - -.root :global(.future-forward-stat-card .label) { - display: block; - margin-bottom: 8px; - overflow: hidden; - color: var(--text-muted); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.05em; - line-height: 1.25; - text-overflow: ellipsis; - text-transform: uppercase; - white-space: nowrap; -} - -.root :global(.future-forward-stat-card .val) { - display: block; - overflow: hidden; - color: var(--text-primary); - font-size: clamp(24px, 3vw, 34px); - font-weight: 800; - letter-spacing: -0.03em; - line-height: 1.05; - text-overflow: ellipsis; - white-space: nowrap; -} - -.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%, var(--color-text-secondary) 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-forward-stats) { - grid-template-columns: repeat(2, minmax(0, 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-forward-stats) { - gap: 10px; - } - - .root :global(.future-forward-stat-card) { - border-radius: 12px; - padding: 12px; - } - - .root :global(.future-forward-stat-card .label) { - font-size: 10px; - } - - .root :global(.future-forward-stat-card .val) { - font-size: 24px; - } - - .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; - } -} - -@media (max-width: 480px) { - .root :global(.future-forward-stats) { - grid-template-columns: 1fr; - } -} - -/* ══════════════════════════════════════════════════════════════ - LIGHT-MODE OVERRIDES — future-v2 decision card components - These classes use dark hardcoded backgrounds (rgba(2,6,23,…)) - which are correct for dark theme but must be overridden in light. - ══════════════════════════════════════════════════════════════ */ - -html.light .root :global(.future-v2-meteorology-brief) { - background: linear-gradient(180deg, rgba(239, 246, 255, 0.72), rgba(255, 255, 255, 0.92)); - border-color: rgba(37, 99, 235, 0.18); -} - -html.light .root :global(.future-v2-anchor-source) { - background: #dbeafe; - border-color: rgba(59, 130, 246, 0.32); - color: #1d4ed8; -} - -html.light .root :global(.future-v2-meteorology-copy h3) { - color: #0f172a; -} - -html.light .root :global(.future-v2-meteorology-meta span) { - background: #eef2f7; - border-color: rgba(148, 163, 184, 0.3); - color: #334155; -} - -html.light .root :global(.future-v2-decision-anchor) { - background: #ffffff; - border-color: rgba(37, 99, 235, 0.18); -} - -html.light .root :global(.future-v2-decision-anchor strong) { - color: #0f172a; -} - -html.light .root :global(.future-v2-decision-anchor small) { - color: #2563eb; -} - -html.light .root :global(.future-v2-decision-grid div) { - background: #ffffff; - border-color: rgba(37, 99, 235, 0.16); -} - -html.light .root :global(.future-v2-decision-grid strong) { - color: #0369a1; -} - -html.light .root :global(.future-v2-meteorology-paths div) { - background: #ffffff; - border-color: rgba(37, 99, 235, 0.16); -} - -html.light .root :global(.future-v2-meteorology-paths span) { - color: #475569; -} - -html.light .root :global(.future-v2-meteorology-paths strong) { - color: #0369a1; -} - -html.light .root :global(.future-v2-hero-temp) { - color: #0f172a; - text-shadow: none; -} - -html.light .root :global(.future-v2-hero-divider) { - background: rgba(15, 23, 42, 0.14); -} - -html.light .root :global(.future-v2-hero-obs) { - color: #334155; -} - -html.light .root :global(.future-v2-card) { - background: rgba(255, 255, 255, 0.72); - border-color: rgba(125, 171, 214, 0.28); -} - -html.light .root :global(.future-v2-focus-card) { - background: linear-gradient(180deg, rgba(219, 234, 254, 0.4) 0%, rgba(255, 255, 255, 0.72) 100%); -} - -html.light .root :global(.future-v2-support-card) { - background: linear-gradient(180deg, rgba(226, 232, 240, 0.4) 0%, rgba(255, 255, 255, 0.72) 100%); -} - -html.light .root :global(.future-v2-hero-card) { - background: linear-gradient(180deg, rgba(219, 234, 254, 0.5) 0%, rgba(255, 255, 255, 0.72) 100%); -} - -html.light .root :global(.future-v2-pace-card) { - background: linear-gradient(180deg, rgba(239, 246, 255, 0.72) 0%, rgba(255, 255, 255, 0.72) 100%); -} - -html.light .root :global(.future-v2-mini-item) { - background: rgba(255, 255, 255, 0.82); - border-color: rgba(148, 163, 184, 0.28); -} - -html.light .root :global(.future-v2-mini-item strong) { - color: #0f172a; -} - -html.light .root :global(.future-v2-pace-signal-card) { - background: rgba(255, 255, 255, 0.82); - border-color: rgba(148, 163, 184, 0.28); -} - -html.light .root :global(.future-v2-pace-delta.neutral) { - color: #475569; -} - -html.light .root :global(.future-v2-daylight) { - background: linear-gradient(180deg, rgba(254, 243, 199, 0.5) 0%, rgba(255, 255, 255, 0.4) 100%); - border-color: rgba(245, 158, 11, 0.24); -} - -html.light .root :global(.future-v2-daylight-bar) { - background: linear-gradient( - 90deg, - rgba(226, 232, 240, 0.9) 0%, - rgba(186, 230, 253, 0.5) 15%, - rgba(253, 224, 71, 0.45) 50%, - rgba(186, 230, 253, 0.4) 85%, - rgba(226, 232, 240, 0.9) 100% - ); -} - -html.light .root :global(.future-v2-daylight-head strong) { - color: #b45309; -} - -html.light .root :global(.future-v2-signal-meter) { - background: rgba(15, 23, 42, 0.08); -} - -html.light .root :global(.future-v2-signal-tag) { - background: rgba(15, 23, 42, 0.05); - border-color: rgba(15, 23, 42, 0.12); - color: #475569; -} - -html.light .root :global(.intraday-scene-shell) { - background: linear-gradient(180deg, rgba(219, 234, 254, 0.3) 0%, rgba(255, 255, 255, 0.5) 100%); - border-color: rgba(37, 99, 235, 0.16); -} - -html.light .root :global(.intraday-scene-legend) { - background: rgba(248, 250, 252, 0.82); - border-top-color: rgba(148, 163, 184, 0.22); -} - -/* ── Sync strip / refresh lock chip ── */ -html.light .root :global(.future-v2-sync-chip) { - background: rgba(240, 253, 250, 0.82); - border-color: rgba(16, 185, 129, 0.22); -} - -html.light .root :global(.future-v2-sync-strip-compact .future-v2-sync-chip) { - background: rgba(255, 255, 255, 0.72); -} - -html.light .root :global(.future-v2-refresh-lock) { - background: rgba(255, 255, 255, 0.96); - border-color: rgba(14, 165, 233, 0.28); - box-shadow: 0 8px 24px rgba(40, 70, 110, 0.12); -} - -html.light .root :global(.future-v2-refresh-lock strong) { - color: #0f172a; -} - - -@keyframes spin { to { transform: rotate(360deg); } } diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx deleted file mode 100644 index 0c3323b4..00000000 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client"; - -import { - useDashboardModal, - useDashboardSelection, - useProAccess, -} from "@/hooks/useDashboardStore"; -import { useI18n } from "@/hooks/useI18n"; -import { FutureForecastModalContent } from "./FutureForecastModalContent"; - -export function FutureForecastModal() { - const modal = useDashboardModal(); - const selection = useDashboardSelection(); - const proAccess = useProAccess(); - const { locale, t } = useI18n(); - const detail = selection.selectedDetail; - const dateStr = modal.futureModalDate; - - if (!detail || !dateStr) return null; - - return ( - - ); -} diff --git a/frontend/components/dashboard/FutureForecastModal.utils.ts b/frontend/components/dashboard/FutureForecastModal.utils.ts deleted file mode 100644 index e9ff97fc..00000000 --- a/frontend/components/dashboard/FutureForecastModal.utils.ts +++ /dev/null @@ -1,252 +0,0 @@ -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/FutureForecastModalContent.tsx b/frontend/components/dashboard/FutureForecastModalContent.tsx deleted file mode 100644 index 4008eabb..00000000 --- a/frontend/components/dashboard/FutureForecastModalContent.tsx +++ /dev/null @@ -1,1015 +0,0 @@ -"use client"; - -import clsx from "clsx"; -import Link from "next/link"; -import { LockKeyhole } from "lucide-react"; -import { CSSProperties, useEffect, useMemo, useState } from "react"; -import type { Locale } from "@/lib/i18n"; -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"; -import { getWeatherSummary } from "@/lib/weather-summary-utils"; -import { - normalizeObservationSourceCode, - normalizeObservationSourceLabel, -} from "@/lib/source-labels"; -import type { - CityDetail, - ForecastModalMode, - LoadingState, - MarketScan, - ProAccessState, -} from "@/lib/dashboard-types"; -import { FutureForecastForwardView } from "./FutureForecastForwardView"; -import { FutureForecastTodayDecisionBrief } from "./FutureForecastTodayDecisionBrief"; -import { FutureForecastTodayLayout } from "./FutureForecastTodayLayout"; -import { FutureForecastModalHeader } from "./FutureForecastModalHeader"; -import { - FutureRefreshLock, - FutureSyncStatusStrip, - type FutureSyncStatusItem, -} from "./FutureForecastModalStatus"; -import { type FuturePaceSignalItem } from "./FutureForecastTodayCards"; -import { - TODAY_MARKET_SCAN_AUTO_REFRESH_MS, - clamp, - formatBucketLabel, - formatMarketPercent, - getTrendMetricVisual, - localizedList, - localizedText, - parseBucketBoundaries, - parseClockMinutes, - parseLeadingNumber, -} from "./FutureForecastModal.utils"; - -type FutureForecastModalControls = { - closeFutureModal: () => void; - forecastModalMode: ForecastModalMode | null; - futureModalDate: string | null; - loadingState: LoadingState; - openFutureModal: (dateStr: string, forceRefresh?: boolean) => Promise; - openTodayModal: (forceRefresh?: boolean) => Promise; -}; - -export function FutureForecastModalContent({ - modal, - proAccess, - locale, - t, - detail, - dateStr, -}: { - modal: FutureForecastModalControls; - proAccess: ProAccessState; - locale: Locale; - t: (key: string, params?: Record) => string; - detail: CityDetail; - dateStr: string; -}) { - const isPro = proAccess.subscriptionActive; - const isProLoading = proAccess.loading; - const hasModalContext = true; - const [showDeferredTodaySections, setShowDeferredTodaySections] = - useState(false); - const [freshMarketScan, setFreshMarketScan] = useState( - null, - ); - - useEffect(() => { - if (!hasModalContext) { - setShowDeferredTodaySections(false); - return; - } - setShowDeferredTodaySections(false); - if (typeof window === "undefined") { - setShowDeferredTodaySections(true); - return; - } - - let cancelled = false; - let timeoutId: ReturnType | null = null; - let idleId: number | null = null; - const reveal = () => { - if (!cancelled) { - setShowDeferredTodaySections(true); - } - }; - - if ("requestIdleCallback" in window) { - idleId = window.requestIdleCallback(reveal, { timeout: 600 }); - } else { - timeoutId = setTimeout(reveal, 120); - } - - return () => { - cancelled = true; - if (idleId != null && "cancelIdleCallback" in window) { - window.cancelIdleCallback(idleId); - } - if (timeoutId != null) { - clearTimeout(timeoutId); - } - }; - }, [dateStr, detail, hasModalContext]); - - const isToday = - modal.forecastModalMode === "today" || - (modal.forecastModalMode == null && dateStr === detail?.local_date); - const detailDepth = detail?.detail_depth || "full"; - const isFullDetailReady = detailDepth === "full"; - const isStructureSyncing = - modal.loadingState.futureDeep || !isFullDetailReady; - const isAnyLayerSyncing = isStructureSyncing; - const isTodayBlockingRefresh = isToday && isStructureSyncing; - const activeMarketScan = freshMarketScan || detail?.market_scan || null; - - useEffect(() => { - setFreshMarketScan(null); - if (!hasModalContext || !isToday || !isFullDetailReady || !isPro) return; - const cityName = String(detail?.name || detail?.display_name || "").trim(); - if (!cityName || !dateStr) return; - - let cancelled = false; - let intervalId: ReturnType | null = null; - - const refreshMarketScan = () => { - dashboardClient - .getCityMarketScan(cityName, { - force: false, - lite: false, - marketSlug: detail?.market_scan?.primary_market?.slug || null, - targetDate: dateStr, - }) - .then((payload) => { - if (cancelled) return; - setFreshMarketScan(payload.market_scan || null); - }) - .catch(() => { - if (!cancelled) { - setFreshMarketScan(null); - } - }); - }; - - refreshMarketScan(); - intervalId = setInterval(() => { - if ( - typeof document !== "undefined" && - document.visibilityState === "hidden" - ) { - return; - } - refreshMarketScan(); - }, TODAY_MARKET_SCAN_AUTO_REFRESH_MS); - - return () => { - cancelled = true; - if (intervalId != null) { - clearInterval(intervalId); - } - }; - }, [ - dateStr, - detail?.display_name, - detail?.local_date, - detail?.market_scan?.primary_market?.slug, - detail?.name, - detail?.updated_at, - hasModalContext, - isFullDetailReady, - isPro, - isToday, - ]); - - const view = getFutureModalView(detail, dateStr, locale); - const scorePosition = `${50 + view.front.score / 2}%`; - const barStyle = { - "--score-position": scorePosition, - } as CSSProperties & { "--score-position": string }; - const weatherSummary = getWeatherSummary(detail, locale); - const paceView = useMemo( - () => - isToday && showDeferredTodaySections - ? getTodayPaceView(detail, locale) - : null, - [detail, isToday, locale, showDeferredTodaySections], - ); - const probabilityView = useMemo( - () => getProbabilityView(detail, dateStr), - [dateStr, detail], - ); - const modelView = useMemo( - () => getModelView(detail, dateStr), - [dateStr, detail], - ); - const probabilityEngineKey = String(probabilityView?.engine || "") - .trim() - .toLowerCase(); - const probabilityCalibrationMode = String( - probabilityView?.calibrationMode || "", - ) - .trim() - .toLowerCase(); - const hasLgbmProbability = useMemo( - () => - Object.keys(modelView?.models || {}).some((name) => - String(name || "") - .toLowerCase() - .replace(/[\s_/-]/g, "") - .includes("lgbm"), - ), - [modelView], - ); - const hasEmosProbability = - probabilityEngineKey === "emos" || - probabilityCalibrationMode.includes("emos"); - const probabilityEngineLabel = hasLgbmProbability - ? locale === "en-US" - ? "LGBM" - : "LGBM" - : hasEmosProbability - ? "EMOS" - : locale === "en-US" - ? "Calibrated model" - : "校准模型"; - const probabilityTitle = hasLgbmProbability - ? locale === "en-US" - ? "LGBM-Calibrated Probability" - : "LGBM 校准概率" - : hasEmosProbability - ? locale === "en-US" - ? "EMOS-Calibrated Probability" - : "EMOS 校准概率" - : locale === "en-US" - ? "Calibrated Model Probability" - : "校准模型概率"; - const topProbabilityBucket = useMemo(() => { - const buckets = Array.isArray(probabilityView?.probabilities) - ? probabilityView.probabilities - : []; - return [...buckets] - .filter((bucket) => Number.isFinite(Number(bucket?.probability))) - .sort((a, b) => Number(b?.probability) - Number(a?.probability))[0]; - }, [probabilityView]); - const modelSpreadView = useMemo(() => { - const values = Object.values(modelView?.models || {}) - .map((value) => Number(value)) - .filter((value) => Number.isFinite(value)); - if (!values.length) return null; - const min = Math.min(...values); - const max = Math.max(...values); - const spread = max - min; - return { - count: values.length, - max, - min, - spread, - }; - }, [modelView]); - const boundaryRiskView = useMemo(() => { - if (!showDeferredTodaySections) return null; - if (!isToday || !paceView) return null; - const selectedBucket = topProbabilityBucket || null; - const bounds = parseBucketBoundaries(selectedBucket); - if (!bounds) return null; - const projected = - paceView.paceAdjustedHigh ?? - (detail.deb?.prediction != null ? Number(detail.deb.prediction) : null); - if (projected == null || !Number.isFinite(projected)) return null; - - const distances = [bounds.lower, bounds.upper] - .filter( - (value): value is number => value != null && Number.isFinite(value), - ) - .map((value) => ({ - boundary: value, - gap: Math.abs(projected - value), - })) - .sort((a, b) => a.gap - b.gap); - if (!distances.length) return null; - - const nearest = distances[0]; - const tone = - nearest.gap <= 0.4 ? "amber" : nearest.gap <= 0.8 ? "blue" : "cyan"; - const status = - nearest.gap <= 0.4 - ? locale === "en-US" - ? "High boundary risk" - : "边界风险高" - : nearest.gap <= 0.8 - ? locale === "en-US" - ? "Watch boundary" - : "边界需观察" - : locale === "en-US" - ? "Boundary buffer" - : "边界缓冲"; - const note = - locale === "en-US" - ? `${projected.toFixed(1)}${detail.temp_symbol} is ${nearest.gap.toFixed(1)}${detail.temp_symbol} from the nearest boundary ${nearest.boundary.toFixed(1)}°C.` - : `${projected.toFixed(1)}${detail.temp_symbol} 距最近边界 ${nearest.boundary.toFixed(1)}°C 还有 ${nearest.gap.toFixed(1)}${detail.temp_symbol}。`; - return { - label: locale === "en-US" ? "Boundary risk" : "边界风险", - note, - status, - tone, - value: `${nearest.gap.toFixed(1)}${detail.temp_symbol}`, - }; - }, [ - detail.deb?.prediction, - detail.temp_symbol, - isToday, - locale, - paceView, - showDeferredTodaySections, - topProbabilityBucket, - ]); - const peakWindowStateView = useMemo(() => { - if (!showDeferredTodaySections) return null; - if (!isToday || !paceView) return null; - const firstHour = Number(detail.peak?.first_h); - const lastHour = Number(detail.peak?.last_h); - if ( - !Number.isFinite(firstHour) || - !Number.isFinite(lastHour) || - firstHour < 0 || - lastHour < firstHour - ) { - return null; - } - const currentMinutes = parseClockMinutes(detail.local_time); - const startMinutes = firstHour * 60; - const endMinutes = (lastHour + 1) * 60; - let status = locale === "en-US" ? "Awaiting peak" : "未进入峰值"; - let tone: "amber" | "blue" | "cyan" = "blue"; - if (currentMinutes != null && currentMinutes >= endMinutes) { - status = locale === "en-US" ? "Past peak" : "已过峰值"; - tone = "cyan"; - } else if (currentMinutes != null && currentMinutes >= startMinutes) { - status = locale === "en-US" ? "Peak window live" : "峰值窗口进行中"; - tone = "amber"; - } - const note = - locale === "en-US" - ? `Primary peak window ${paceView.peakWindowText}.` - : `核心峰值窗口 ${paceView.peakWindowText}。`; - return { - label: locale === "en-US" ? "Peak window" : "峰值窗口状态", - note, - status, - tone, - value: paceView.peakWindowText, - }; - }, [ - detail.local_time, - detail.peak?.first_h, - detail.peak?.last_h, - isToday, - locale, - paceView, - showDeferredTodaySections, - ]); - const networkLeadView = useMemo(() => { - if (!showDeferredTodaySections) return null; - if (!isToday) return null; - const delta = Number(detail.airport_vs_network_delta); - const leadSignal = detail.network_lead_signal; - if (!Number.isFinite(delta)) return null; - const leaderLabel = - String(leadSignal?.leader_station_label || "").trim() || - String(leadSignal?.leader_station_code || "").trim(); - const leaderSyncStatus = String(leadSignal?.leader_sync_status || "") - .trim() - .toLowerCase(); - const leaderSyncDelta = Number( - leadSignal?.leader_time_delta_vs_anchor_minutes, - ); - const syncNote = - leaderSyncStatus === "near_realtime" || leaderSyncStatus === "lagged" - ? Number.isFinite(leaderSyncDelta) - ? locale === "en-US" - ? ` Timing offset versus the airport anchor is about ${Math.round(leaderSyncDelta)} minutes.` - : ` 与机场锚点存在约 ${Math.round(leaderSyncDelta)} 分钟时间差。` - : locale === "en-US" - ? " Nearby observations are not fully synchronized." - : " 周边观测并非完全同步。" - : leaderSyncStatus === "unknown" - ? locale === "en-US" - ? " Nearby station timing is not fully verified." - : " 周边站观测时间尚未完全校验。" - : ""; - const absDelta = Math.abs(delta); - const status = - delta <= -0.4 - ? locale === "en-US" - ? "Airport trailing" - : "机场落后" - : delta >= 0.4 - ? locale === "en-US" - ? "Airport leading" - : "机场领先" - : locale === "en-US" - ? "Tracking network" - : "与站网齐平"; - const tone = delta <= -0.4 ? "amber" : delta >= 0.4 ? "cyan" : "blue"; - const note = - delta <= -0.4 - ? locale === "en-US" - ? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} cooler than the nearby official network${leaderLabel ? `, led by ${leaderLabel}` : ""}.${syncNote}` - : `机场主站当前比周边官方站网低 ${absDelta.toFixed(1)}${detail.temp_symbol}${leaderLabel ? `,领先点位是 ${leaderLabel}` : ""}。${syncNote}` - : delta >= 0.4 - ? locale === "en-US" - ? `Airport anchor is ${absDelta.toFixed(1)}${detail.temp_symbol} hotter than the nearby official network.${syncNote}` - : `机场主站当前比周边官方站网高 ${absDelta.toFixed(1)}${detail.temp_symbol}。${syncNote}` - : locale === "en-US" - ? "Airport anchor and nearby official network are broadly aligned." - : "机场主站与周边官方站网当前大体齐平。"; - return { - label: locale === "en-US" ? "Airport vs network" : "机场 vs 周边站", - note, - status, - tone, - value: `${delta > 0 ? "+" : ""}${delta.toFixed(1)}${detail.temp_symbol}`, - }; - }, [ - detail.airport_vs_network_delta, - detail.network_lead_signal, - detail.temp_symbol, - isToday, - 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"; - const noaaStationCode = String( - detail.current?.station_code || detail.risk?.icao || "NOAA", - ) - .trim() - .toUpperCase(); - const noaaStationName = - String(detail.current?.station_name || "").trim() || - String(detail.risk?.airport || "").trim() || - noaaStationCode; - const hottestBucketLabel = formatBucketLabel(topProbabilityBucket); - const probabilitySummary = (() => { - if (!topProbabilityBucket) { - return locale === "en-US" - ? "Probability mass is still too dispersed; avoid over-reading a single bracket." - : "当前概率还比较分散,不要只盯单一区间。"; - } - const bucketLabel = formatBucketLabel(topProbabilityBucket); - const bucketProb = formatMarketPercent(topProbabilityBucket.probability); - if (!isToday) { - return locale === "en-US" - ? `Target-day model probability reference puts the leading bucket at ${bucketLabel} (${bucketProb}). EMOS is reserved for intraday analysis after live anchor observations arrive.` - : `目标日模型概率参考显示领先温度桶为 ${bucketLabel}(${bucketProb})。EMOS 仅用于有实时锚点观测后的日内分析。`; - } - if (hasLgbmProbability) { - return locale === "en-US" - ? `LGBM-calibrated read puts the leading bucket at ${bucketLabel} (${bucketProb}). Treat this as the base case, not the final settlement.` - : `LGBM 校准后领先温度桶为 ${bucketLabel}(${bucketProb})。可作为基准情形,但不要直接等同于最终结算。`; - } - if (hasEmosProbability) { - return locale === "en-US" - ? `EMOS-calibrated probability puts the leading bucket at ${bucketLabel} (${bucketProb}). It is the primary calibrated probability layer, not the final settlement.` - : `EMOS 校准概率显示领先温度桶为 ${bucketLabel}(${bucketProb})。这是当前主概率层,但不要直接等同于最终结算。`; - } - return locale === "en-US" - ? `Calibrated model probability puts the leading bucket at ${bucketLabel} (${bucketProb}). Treat this as the base case, not the final settlement.` - : `校准模型概率显示领先温度桶为 ${bucketLabel}(${bucketProb})。可作为基准情形,但不要直接等同于最终结算。`; - })(); - const modelSummary = (() => { - if (!modelSpreadView) { - return locale === "en-US" - ? "Model spread is unavailable right now." - : "当前拿不到可用的模型分歧。"; - } - const modelEntries = Object.entries(modelView?.models || {}).filter( - ([, value]) => - value !== null && value !== undefined && Number.isFinite(Number(value)), - ); - if (modelEntries.length === 1) { - const [singleModelName, singleModelValue] = modelEntries[0]; - return locale === "en-US" - ? `Only ${singleModelName} is available right now at ${Number(singleModelValue).toFixed(1)}${detail.temp_symbol}; multi-model spread is temporarily unavailable.` - : `当前只收到 ${singleModelName} ${Number(singleModelValue).toFixed(1)}${detail.temp_symbol},其他多模型暂未回传,所以这里先不判断模型分歧。`; - } - return locale === "en-US" - ? `Model range runs from ${modelSpreadView.min.toFixed(1)}${detail.temp_symbol} to ${modelSpreadView.max.toFixed(1)}${detail.temp_symbol}; spread ${modelSpreadView.spread.toFixed(1)}${detail.temp_symbol}.` - : `当前模型区间在 ${modelSpreadView.min.toFixed(1)}${detail.temp_symbol} 到 ${modelSpreadView.max.toFixed(1)}${detail.temp_symbol},分歧 ${modelSpreadView.spread.toFixed(1)}${detail.temp_symbol}。`; - })(); - const upperAirSignal = detail.vertical_profile_signal || {}; - const tafSignal = detail.taf?.signal || {}; - const upperAirCue = useMemo(() => { - if (!showDeferredTodaySections) return null; - if (!isToday || (!upperAirSignal.source && !tafSignal.available)) - return null; - - const setup = String( - upperAirSignal.heating_setup || "neutral", - ).toLowerCase(); - const tafSuppression = String( - tafSignal.suppression_level || "low", - ).toLowerCase(); - const tafDisruption = String( - tafSignal.disruption_level || "low", - ).toLowerCase(); - const reasons: string[] = []; - let score = 0; - - if (setup === "supportive") { - score += 2; - reasons.push( - locale === "en-US" - ? "upper-air structure still supports daytime heating" - : "高空结构仍偏支持白天冲高", - ); - } else if (setup === "suppressed") { - score -= 2; - reasons.push( - locale === "en-US" - ? "upper-air structure still leans toward capping the peak" - : "高空结构更偏向压住峰值", - ); - } - - if (tafSuppression === "high") { - score -= 2; - reasons.push( - locale === "en-US" - ? "TAF flags meaningful cloud/rain suppression near the peak window" - : "TAF 在峰值窗口提示云雨压温风险偏高", - ); - } else if (tafSuppression === "medium") { - score -= 1; - reasons.push( - locale === "en-US" - ? "TAF keeps some cloud/rain suppression risk on the table" - : "TAF 仍提示一定的云雨压温风险", - ); - } - - if (tafDisruption === "high") { - score -= 1; - reasons.push( - locale === "en-US" - ? "TAF also suggests a noisier afternoon regime" - : "TAF 还提示午后扰动偏强", - ); - } else if (tafDisruption === "medium") { - score -= 0.5; - reasons.push( - locale === "en-US" - ? "TAF keeps some afternoon timing noise in play" - : "TAF 提示午后仍可能有时段性扰动", - ); - } - - if (score >= 1.5) { - return { - summary: - locale === "en-US" - ? "The combined upper-air and TAF read still leans warmer. Do not fade lower buckets too early." - : "高空和 TAF 两层信号合并后仍偏暖侧,不宜过早做更低温区间。", - note: - locale === "en-US" - ? `${reasons.slice(0, 2).join("; ")}.` - : `${reasons.slice(0, 2).join(";")}。`, - tone: "warm", - value: locale === "en-US" ? "Lean warmer" : "偏暖侧", - }; - } - - if (score <= -1.5) { - return { - summary: - locale === "en-US" - ? "The combined upper-air and TAF read leans more defensive. Be more careful chasing higher buckets." - : "高空和 TAF 两层信号合并后更偏防守,追更高温区间要更谨慎。", - note: - locale === "en-US" - ? `${reasons.slice(0, 2).join("; ")}.` - : `${reasons.slice(0, 2).join(";")}。`, - tone: "cold", - value: locale === "en-US" ? "Lean cautious" : "偏谨慎", - }; - } - - return { - summary: - locale === "en-US" - ? "The combined upper-air and TAF read is mixed. Let surface structure decide before taking a side." - : "高空和 TAF 两层信号目前偏混合,先看近地面结构变化,不急着站边。", - note: - locale === "en-US" - ? `${reasons.slice(0, 2).join("; ") || "No clean edge from the upper-air layer alone"}.` - : `${reasons.slice(0, 2).join(";") || "单看高空层还没有干净的交易边"}。`, - tone: "", - value: locale === "en-US" ? "Wait / confirm" : "先观察", - }; - }, [ - tafSignal.available, - tafSignal.disruption_level, - tafSignal.suppression_level, - isToday, - locale, - upperAirSignal.heating_setup, - upperAirSignal.source, - showDeferredTodaySections, - ]); - const topObservedTemp = - detail.current?.max_so_far != null - ? detail.current.max_so_far - : detail.current?.temp; - const currentTempText = - detail.current?.temp != null - ? `${detail.current.temp}${detail.temp_symbol}` - : "--"; - const daylightProgress = (() => { - const now = parseClockMinutes(detail.current?.obs_time); - const sunrise = parseClockMinutes(detail.forecast?.sunrise); - const sunset = parseClockMinutes(detail.forecast?.sunset); - if (now == null || sunrise == null || sunset == null || sunset <= sunrise) { - return null; - } - const percent = clamp(((now - sunrise) / (sunset - sunrise)) * 100, 0, 100); - const phase = - now < sunrise ? "夜间" : now > sunset ? "已日落" : "白昼进行中"; - return { - phase, - percent, - }; - })(); - const displayedUpperAirSummary = showDeferredTodaySections - ? upperAirCue?.summary || view.front.upperAirSummary - : ""; - const displayedUpperAirMetrics = showDeferredTodaySections - ? (view.front.upperAirMetrics || []).map((metric, index) => - index === 0 && - (metric.label === "Trade cue" || metric.label === "交易动作") && - upperAirCue - ? { - ...metric, - note: upperAirCue.note, - tone: upperAirCue.tone, - value: upperAirCue.value, - } - : metric, - ) - : []; - const localizedAiCommentaryLines = useMemo(() => { - if (!showDeferredTodaySections) return [] as string[]; - const commentary = detail.dynamic_commentary || {}; - const headline = String( - locale === "en-US" - ? commentary.headline_en || "" - : commentary.headline_zh || "", - ).trim(); - const bullets = ( - locale === "en-US" ? commentary.bullets_en : commentary.bullets_zh - ) as string[] | null | undefined; - const cleanedBullets = Array.isArray(bullets) - ? bullets.map((item) => String(item || "").trim()).filter(Boolean) - : []; - return [headline, ...cleanedBullets].filter(Boolean).slice(0, 3); - }, [detail.dynamic_commentary, locale, showDeferredTodaySections]); - const todayTradeSummaryLines = useMemo(() => { - if (!showDeferredTodaySections) return [] as string[]; - if (!isToday) return [] as string[]; - if (localizedAiCommentaryLines.length > 0) { - return localizedAiCommentaryLines; - } - const lines: string[] = []; - if (paceView) { - const headline = - paceView.biasTone === "warm" - ? locale === "en-US" - ? `Pace is running hot by ${paceView.deltaText}; the day high still leans above the base curve.` - : `节奏偏热 ${paceView.deltaText},日高仍偏向落在基础曲线之上。` - : paceView.biasTone === "cold" - ? locale === "en-US" - ? `Pace is trailing by ${paceView.deltaText}; chasing higher buckets needs caution.` - : `节奏落后 ${paceView.deltaText},继续追更高温区间要更谨慎。` - : locale === "en-US" - ? "Pace is still on curve; the next move depends on the peak-window push." - : "节奏目前贴着曲线走,下一步主要看峰值窗口还有没有上冲。"; - lines.push(headline); - } - if (boundaryRiskView) { - lines.push( - locale === "en-US" - ? `${boundaryRiskView.label}: ${boundaryRiskView.note}` - : `${boundaryRiskView.label}:${boundaryRiskView.note}`, - ); - } - if (networkLeadView) { - lines.push( - locale === "en-US" - ? `${networkLeadView.label}: ${networkLeadView.note}` - : `${networkLeadView.label}:${networkLeadView.note}`, - ); - } - return lines.slice(0, 3); - }, [ - boundaryRiskView, - isToday, - locale, - localizedAiCommentaryLines, - networkLeadView, - paceView, - showDeferredTodaySections, - ]); - const intradayMeteorology = detail.intraday_meteorology || {}; - const meteorologySignals = Array.isArray( - intradayMeteorology.signal_contributions, - ) - ? intradayMeteorology.signal_contributions - : []; - const invalidationRules = localizedList( - locale, - intradayMeteorology.invalidation_rules, - intradayMeteorology.invalidation_rules_en, - ); - const confirmationRules = localizedList( - locale, - intradayMeteorology.confirmation_rules, - intradayMeteorology.confirmation_rules_en, - ); - const meteorologyHeadline = - localizedText( - locale, - intradayMeteorology.headline, - intradayMeteorology.headline_en, - ) || - todayTradeSummaryLines[0] || - (locale === "en-US" - ? "Intraday meteorology layers are still syncing; use the next observation as the anchor." - : "关键日内气象层仍在同步,先以下一次观测作为判断锚点。"); - const baseCaseBucket = - String(intradayMeteorology.base_case_bucket || "").trim() || - formatBucketLabel(topProbabilityBucket); - const nextObservationTime = - String(intradayMeteorology.next_observation_time || "").trim() || "--"; - const baseBucketNumber = parseLeadingNumber(baseCaseBucket); - const referenceObservedTemp = - topObservedTemp != null && Number.isFinite(Number(topObservedTemp)) - ? Number(topObservedTemp) - : detail.current?.temp != null - ? Number(detail.current.temp) - : null; - const gapToBaseBucket = - baseBucketNumber != null && referenceObservedTemp != null - ? Math.max(0, baseBucketNumber - referenceObservedTemp) - : null; - const pathStatus = - gapToBaseBucket == null - ? locale === "en-US" - ? "Awaiting anchor" - : "等待锚点" - : gapToBaseBucket <= 0.05 - ? locale === "en-US" - ? "Base path touched" - : "基准路径已触达" - : gapToBaseBucket <= 1.0 - ? locale === "en-US" - ? "Base path open" - : "基准路径开放" - : locale === "en-US" - ? "Needs peak push" - : "需要峰值推动"; - const peakWindowText = - String(intradayMeteorology.peak_window || "").trim() || - paceView?.peakWindowText || - "--"; - const settlementSourceCode = normalizeObservationSourceCode( - detail.current?.settlement_source || "", - ); - const settlementStationCode = String( - detail.current?.station_code || detail.risk?.icao || "", - ) - .trim() - .toUpperCase(); - const settlementStationName = - String(detail.current?.station_name || detail.risk?.airport || "").trim() || - settlementStationCode || - (locale === "en-US" ? "Anchor station" : "锚点站"); - const airportMetarAnchor = - settlementSourceCode === "metar" || - Boolean(settlementStationCode && /^[A-Z]{4}$/.test(settlementStationCode)); - const anchorSourceLabel = airportMetarAnchor - ? settlementStationCode - ? `${settlementStationCode} METAR` - : "METAR" - : normalizeObservationSourceLabel( - detail.current?.settlement_source_label || - detail.current?.settlement_source, - locale === "en-US" ? "Official observation" : "官方观测", - ); - const anchorRuleText = airportMetarAnchor - ? locale === "en-US" - ? `Airport contract anchor: use the ${anchorSourceLabel} reports. Third-party history pages are display-only when present.` - : `机场合约锚点:以 ${anchorSourceLabel} 报文为准;第三方历史页只作为展示入口。` - : locale === "en-US" - ? `Official anchor: use ${anchorSourceLabel} observations for this contract.` - : `官方锚点:该合约按 ${anchorSourceLabel} 观测口径判断。`; - const nextObservationLabel = airportMetarAnchor - ? locale === "en-US" - ? "Next METAR watch" - : "下一次 METAR 观察" - : locale === "en-US" - ? "Next anchor watch" - : "下一次锚点观察"; - const gapToBaseText = - gapToBaseBucket == null - ? "--" - : `${gapToBaseBucket.toFixed(1)}${detail.temp_symbol || "°C"}`; - const syncStatusItems = [ - { - key: "base", - state: isAnyLayerSyncing ? "syncing" : "ready", - label: isAnyLayerSyncing - ? locale === "en-US" - ? "Refreshing base analysis" - : "正在刷新基础分析" - : locale === "en-US" - ? "Base analysis ready" - : "基础分析已加载", - note: isAnyLayerSyncing - ? locale === "en-US" - ? "Latest anchor readings and forecast curve are being rebuilt." - : "正在重建最新锚点读数和预测曲线。" - : locale === "en-US" - ? "Forecast curve, anchor state, and the core intraday view are available." - : "预测曲线、锚点状态和核心日内视图已经可用。", - }, - { - key: "market", - state: isAnyLayerSyncing ? "syncing" : "ready", - label: isAnyLayerSyncing - ? locale === "en-US" - ? "Refreshing probability layer" - : "正在刷新概率层" - : locale === "en-US" - ? "Probability layer ready" - : "概率层已加载", - note: isAnyLayerSyncing - ? locale === "en-US" - ? "Model spread and calibrated buckets are updating." - : "模型分歧和校准概率桶正在更新。" - : locale === "en-US" - ? `Probability buckets are derived from the ${probabilityEngineLabel} layer.` - : `概率桶当前由 ${probabilityEngineLabel} 层推导。`, - }, - ] satisfies FutureSyncStatusItem[]; - - return ( -
{ - if (event.target === event.currentTarget) { - modal.closeFutureModal(); - } - }} - > - {isProLoading ? ( -
-
- {t("dashboard.loading")} -
-
- ) : !isPro ? ( -
-
- -

- {locale === "en-US" ? "Pro subscription required" : "需要 Pro 订阅"} -

-

- {locale === "en-US" - ? "Subscribe in Account Center to unlock full analysis." - : "请在账户中心订阅以解锁完整分析功能。"} -

- - {locale === "en-US" ? "Subscribe" : "去订阅"} - -
-
- ) : ( -
- { - if (isToday) { - void modal.openTodayModal(true); - return; - } - modal.openFutureModal(dateStr, true); - }} - t={t} - /> -
- {isTodayBlockingRefresh && } - {isToday && ( - - )} - - {isNoaaSettlement && ( -
- {locale === "en-US" - ? `${detail.display_name} now settles against NOAA ${noaaStationCode} (${noaaStationName}). The market uses the highest rounded whole-degree Celsius reading in the Temp column after the day is finalized.` - : `${detail.display_name} 当前按 NOAA ${noaaStationCode}(${noaaStationName})结算。市场最终采用该日 Temp 列完成质控后的最高整度摄氏值,不按小数温度结算。`} -
- )} - {isToday ? ( - - ) : ( - - )} -
-
- )} -
- ); -} - diff --git a/frontend/components/dashboard/FutureForecastModalHeader.tsx b/frontend/components/dashboard/FutureForecastModalHeader.tsx deleted file mode 100644 index 2dc74dd9..00000000 --- a/frontend/components/dashboard/FutureForecastModalHeader.tsx +++ /dev/null @@ -1,100 +0,0 @@ -"use client"; - -import clsx from "clsx"; -import type { Locale } from "@/lib/i18n"; - -type FutureForecastModalHeaderProps = { - cityDisplayName: string; - dateStr: string; - isAnyLayerSyncing: boolean; - isPro: boolean; - isProLoading: boolean; - isToday: boolean; - locale: Locale; - onClose: () => void; - onRefresh: () => void; - t: (key: string, params?: Record) => string; -}; - -export function FutureForecastModalHeader({ - cityDisplayName, - dateStr, - isAnyLayerSyncing, - isPro, - isProLoading, - isToday, - locale, - onClose, - onRefresh, - t, -}: FutureForecastModalHeaderProps) { - const cityLabel = cityDisplayName.toUpperCase(); - return ( -
-
-
- {locale === "en-US" ? "Analysis workspace" : "分析工作台"} - - {cityLabel} -
-

- - {isToday - ? t("future.todayTitle", { - city: cityLabel, - }) - : t("future.dateTitle", { - city: cityLabel, - date: dateStr, - })} - - -

-
- {isToday - ? locale === "en-US" - ? "Base signal first, then probability and model layers." - : "先看基础信号,再看概率层和模型层。" - : locale === "en-US" - ? "Forward date view with phased model and structure sync." - : "未来日期视图,模型层与结构层分阶段补齐。"} -
-
- -
- ); -} diff --git a/frontend/components/dashboard/FutureForecastModalPanels.tsx b/frontend/components/dashboard/FutureForecastModalPanels.tsx deleted file mode 100644 index 72fb2d3a..00000000 --- a/frontend/components/dashboard/FutureForecastModalPanels.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"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/FutureForecastTodayCards.tsx b/frontend/components/dashboard/FutureForecastTodayCards.tsx deleted file mode 100644 index 0b800569..00000000 --- a/frontend/components/dashboard/FutureForecastTodayCards.tsx +++ /dev/null @@ -1,245 +0,0 @@ -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, - topObservedTemp, - tempSymbol, - gapToBaseBucket, - pathStatus, -}: { - locale: Locale; - currentTempText: string; - weatherSummary: WeatherSummaryView; - obsTime?: 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 || "--"}
-
- - - - -
-
- ); -} - -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 deleted file mode 100644 index 0143e5ad..00000000 --- a/frontend/components/dashboard/FutureForecastTodayDecisionBrief.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"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 deleted file mode 100644 index 96913865..00000000 --- a/frontend/components/dashboard/FutureForecastTodayEvidenceGrid.tsx +++ /dev/null @@ -1,129 +0,0 @@ -"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/FutureForecastTodayLayout.tsx b/frontend/components/dashboard/FutureForecastTodayLayout.tsx deleted file mode 100644 index 4d4a4a9a..00000000 --- a/frontend/components/dashboard/FutureForecastTodayLayout.tsx +++ /dev/null @@ -1,193 +0,0 @@ -"use client"; - -import type { MarketScan, CityDetail } from "@/lib/dashboard-types"; -import type { FuturePaceSignalItem } from "./FutureForecastTodayCards"; -import { FutureForecastTodayEvidenceGrid } from "./FutureForecastTodayEvidenceGrid"; -import { - FutureModelForecastPanel, - FutureProbabilityPanel, - FutureTemperaturePathChart, -} from "./FutureForecastModalPanels"; -import { - FutureAnchorStatusCard, - FuturePaceCard, - FuturePaceLoadingCard, -} from "./FutureForecastTodayCards"; - -type Locale = string; - -interface WeatherSummaryView { - weatherIcon: string; - weatherText: string; -} - -interface DaylightProgressView { - phase: string; - percent: number; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type PaceView = any; - -export interface FutureForecastTodayLayoutProps { - locale: Locale; - isToday: boolean; - dateStr: string; - detail: CityDetail; - currentTempText: string; - weatherSummary: WeatherSummaryView; - daylightProgress: DaylightProgressView | null; - topObservedTemp: number | string | null | undefined; - gapToBaseBucket: number | null; - pathStatus: string; - showDeferredTodaySections: boolean; - paceView: PaceView | null; - paceSignalItems: FuturePaceSignalItem[]; - baseCaseBucket: string | undefined; - upsideBucket: string | null | undefined; - nextObservationTime: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - airportMetarAnchor: any; - confirmationRules: string[]; - invalidationRules: string[]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - meteorologySignals: any[]; - modelSummary: string; - probabilityTitle: string; - probabilitySummary: string; - activeMarketScan: MarketScan | null | undefined; -} - -export function FutureForecastTodayLayout(props: FutureForecastTodayLayoutProps) { - const { - locale, - isToday, - dateStr, - detail, - currentTempText, - weatherSummary, - daylightProgress, - topObservedTemp, - gapToBaseBucket, - pathStatus, - showDeferredTodaySections, - paceView, - paceSignalItems, - baseCaseBucket, - upsideBucket, - nextObservationTime, - airportMetarAnchor, - confirmationRules, - invalidationRules, - meteorologySignals, - modelSummary, - probabilityTitle, - probabilitySummary, - activeMarketScan, - } = props; - - return ( -
- - -
-
-
-
- {locale === "en-US" ? "Primary view" : "主视图"} -
-

- {locale === "en-US" - ? "Today's temperature path (anchor obs + models)" - : "今日气温路径(锚点观测 + 模型)"} -

-
- -
- - {locale === "en-US" ? "Base" : "基准"} ·{" "} - {baseCaseBucket || "--"} - - - {locale === "en-US" ? "Upside" : "上修"} ·{" "} - {upsideBucket || "--"} - - - {locale === "en-US" ? "Invalidates at" : "失效观察"} ·{" "} - {nextObservationTime} - -
-
- - - -
-
-
-
- {locale === "en-US" ? "Probability read" : "概率判断"} -
-

{probabilityTitle}

-
-
- {probabilitySummary} -
-
- -
-
-
-
-
- {locale === "en-US" ? "Model layer" : "模型层"} -
-

- {locale === "en-US" - ? "Model Range & Spread" - : "模型区间与分歧"} -

-
-
- {modelSummary} -
- -
-
-
-
- ); -} diff --git a/frontend/components/dashboard/scan-root-styles.ts b/frontend/components/dashboard/scan-root-styles.ts index e7a67474..39674891 100644 --- a/frontend/components/dashboard/scan-root-styles.ts +++ b/frontend/components/dashboard/scan-root-styles.ts @@ -9,7 +9,6 @@ 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 modalChromeStyles from "./ModalChrome.module.css"; import scanTerminalStyles from "./ScanTerminal.module.css"; import scanTerminalBoardStyles from "./ScanTerminalBoard.module.css"; @@ -41,5 +40,4 @@ export const scanRootClass = clsx( detailContentStyles.root, detailSectionsStyles.root, modalChromeStyles.root, - futureForecastModalStyles.root, );