Improve dashboard maintainability before the next release

The dashboard had several oversized orchestration, component, and CSS files that made product-copy changes and mobile/performance work risky. This refactor preserves behavior while splitting scan terminal CSS, opportunity helpers, future forecast panels, history/detail charts, and probability/model sections into smaller ownership boundaries.

Constraint: No user-visible version bump because this batch is architecture and performance cleanup, not a release announcement.

Rejected: Rewrite dashboard state management in the same batch | too broad for a safe upload after CSS and component splitting.

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Keep new component/CSS boundaries instead of moving product copy back into the large dashboard files.

Tested: npm run build; npm run test:business; git diff --check

Not-tested: Browser visual smoke test after push
This commit is contained in:
2569718930@qq.com
2026-04-28 20:19:17 +08:00
parent 20516000a2
commit 2b1d7c0b65
56 changed files with 15303 additions and 15063 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,846 @@
/* ── Map ── */
.root :global(.map) {
position: absolute;
top: calc(var(--header-height) + 16px);
right: 392px;
bottom: 164px;
left: calc(var(--sidebar-width) + 22px);
z-index: 1;
overflow: hidden;
border: 1px solid rgba(115, 137, 161, 0.16);
border-radius: 24px;
background: #040912;
box-shadow:
0 32px 100px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.04),
inset 0 0 0 1px rgba(0, 224, 164, 0.04);
}
.weatherAura {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
opacity: 0.96;
mix-blend-mode: screen;
overflow: hidden;
}
.weatherAura :global(canvas) {
width: 100%;
height: 100%;
display: block;
opacity: 0.94;
filter: saturate(1.05) blur(0.3px);
}
.weatherAuraScrim {
position: absolute;
inset: 0;
background:
linear-gradient(
180deg,
rgba(3, 8, 19, 0.64) 0%,
rgba(5, 10, 20, 0.16) 24%,
rgba(4, 8, 18, 0.1) 54%,
rgba(3, 6, 14, 0.42) 100%
),
radial-gradient(
circle at 50% 60%,
rgba(0, 224, 164, 0.08) 0%,
rgba(123, 97, 255, 0) 48%
);
}
.weatherAura[data-reduced-motion="true"] :global(canvas) {
display: none;
}
.root :global(.map .leaflet-tile),
.root :global(.map .leaflet-marker-icon),
.root :global(.map .leaflet-marker-shadow),
.root :global(.map .leaflet-container img),
.root :global(.map .leaflet-container svg) {
max-width: none !important;
max-height: none !important;
}
.root :global(.map .leaflet-tile) {
width: 256px !important;
height: 256px !important;
}
/* Remove Leaflet default styling for cleaner look */
.root :global(.leaflet-control-attribution) {
background: var(--bg-glass) !important;
color: var(--text-muted) !important;
backdrop-filter: blur(8px);
border: 1px solid var(--border-subtle) !important;
font-size: 10px !important;
border-radius: 6px !important;
padding: 2px 8px !important;
}
.root :global(.leaflet-control-attribution a) {
color: var(--text-secondary) !important;
}
.root :global(.leaflet-control-zoom) {
border: none !important;
box-shadow: var(--shadow-lg) !important;
}
.root :global(.leaflet-control-zoom a) {
background: var(--bg-glass) !important;
color: var(--text-primary) !important;
backdrop-filter: blur(12px) !important;
border: 1px solid var(--border-glass) !important;
width: 36px !important;
height: 36px !important;
line-height: 36px !important;
font-size: 16px !important;
border-radius: 8px !important;
transition: var(--transition);
}
.root :global(.leaflet-control-zoom a:hover) {
background: rgba(99, 102, 241, 0.2) !important;
border-color: var(--accent-blue) !important;
}
/* ── Custom Map Markers ── */
.root :global(.city-marker) {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
/* Remove transition: transform to avoid fighting with Leaflet's own positioning */
}
.root :global(.city-marker:hover) {
transform: scale(1.15);
z-index: 1000 !important;
}
.root :global(.marker-bubble) {
min-width: 44px;
padding: 4px 10px;
border-radius: 14px;
font-family: "Inter", sans-serif;
font-size: 13px;
font-weight: 700;
text-align: center;
color: white;
white-space: nowrap;
position: relative;
box-shadow:
0 6px 18px rgba(0, 0, 0, 0.42),
0 0 0 1px rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.14);
}
.root :global(.marker-bubble::after) {
content: "";
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid;
border-top-color: inherit;
}
.root :global(.marker-bubble.risk-high) {
background: linear-gradient(135deg, #991b1b, #fb7185);
border-color: rgba(251, 113, 133, 0.42);
box-shadow:
0 6px 18px rgba(0, 0, 0, 0.42),
0 0 22px rgba(251, 113, 133, 0.18);
}
.root :global(.marker-bubble.risk-high::after) {
border-top-color: #ef4444;
}
.root :global(.marker-bubble.risk-medium) {
background: linear-gradient(135deg, #9a3412, #f59e0b);
border-color: rgba(245, 158, 11, 0.42);
box-shadow:
0 6px 18px rgba(0, 0, 0, 0.42),
0 0 22px rgba(245, 158, 11, 0.16);
}
.root :global(.marker-bubble.risk-medium::after) {
border-top-color: #f59e0b;
}
.root :global(.marker-bubble.risk-low) {
background: linear-gradient(135deg, #065f46, #22c55e);
border-color: rgba(74, 222, 128, 0.42);
box-shadow:
0 6px 18px rgba(0, 0, 0, 0.42),
0 0 22px rgba(74, 222, 128, 0.14);
}
.root :global(.marker-bubble.risk-low::after) {
border-top-color: #22C55E;
}
.root :global(.marker-name) {
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
margin-top: 2px;
white-space: nowrap;
}
/* Marker glow animation for selected city */
.root :global(.city-marker.selected .marker-bubble) {
animation: markerGlow 2s ease-in-out infinite;
}
@keyframes markerGlow {
0%,
100% {
box-shadow:
0 6px 18px rgba(0, 0, 0, 0.42),
0 0 24px rgba(34, 211, 238, 0.12);
}
50% {
box-shadow:
0 6px 26px rgba(0, 0, 0, 0.46),
0 0 20px rgba(34, 211, 238, 0.24),
0 0 46px rgba(34, 211, 238, 0.14);
}
}
/* ── Loading Overlay ── */
.root :global(.loading-overlay) {
position: fixed;
inset: 0;
z-index: 2000;
background: radial-gradient(
circle at 50% 50%,
rgba(8, 14, 32, 0.76) 0%,
rgba(4, 8, 22, 0.9) 44%,
rgba(2, 6, 20, 0.96) 100%
);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(12px);
}
.root :global(.loading-card) {
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
min-width: 240px;
padding: 28px 32px;
border-radius: 28px;
background: linear-gradient(
180deg,
rgba(10, 18, 36, 0.78) 0%,
rgba(8, 14, 30, 0.62) 100%
);
border: 1px solid rgba(92, 142, 255, 0.16);
box-shadow:
0 24px 90px rgba(0, 0, 0, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.root :global(.loading-clouds) {
position: relative;
width: 220px;
height: 26px;
overflow: hidden;
}
.root :global(.loading-cloud) {
position: absolute;
top: 0;
height: 18px;
border-radius: 999px;
background:
radial-gradient(
circle at 30% 50%,
rgba(186, 230, 253, 0.22),
transparent 54%
),
linear-gradient(
180deg,
rgba(148, 163, 184, 0.28) 0%,
rgba(71, 85, 105, 0.1) 100%
);
filter: blur(0.3px);
box-shadow:
0 6px 24px rgba(56, 189, 248, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
animation: cloud-drift 6.8s ease-in-out infinite;
}
.root :global(.loading-cloud-1) {
left: 12px;
width: 76px;
}
.root :global(.loading-cloud-2) {
right: 8px;
top: 7px;
width: 98px;
opacity: 0.75;
animation-delay: -2.6s;
}
.root :global(.loading-windfield) {
position: relative;
width: 220px;
height: 30px;
overflow: hidden;
opacity: 0.9;
}
.root :global(.loading-windline) {
position: absolute;
left: -28%;
height: 1px;
border-radius: 999px;
background: linear-gradient(
90deg,
rgba(34, 211, 238, 0) 0%,
rgba(103, 232, 249, 0.5) 22%,
rgba(125, 211, 252, 0.9) 55%,
rgba(34, 211, 238, 0) 100%
);
filter: blur(0.2px);
animation: wind-shift 2.8s linear infinite;
}
.root :global(.loading-windline-1) {
top: 4px;
width: 124px;
}
.root :global(.loading-windline-2) {
top: 13px;
width: 176px;
animation-delay: -0.9s;
opacity: 0.82;
}
.root :global(.loading-windline-3) {
top: 22px;
width: 142px;
animation-delay: -1.6s;
opacity: 0.66;
}
.root :global(.loading-copy) {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.root :global(.loading-copy strong) {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: rgba(103, 232, 249, 0.92);
}
.root :global(.loading-copy span) {
max-width: 240px;
font-size: 13px;
font-weight: 500;
line-height: 1.55;
color: rgba(203, 213, 225, 0.82);
}
.root :global(.loading-radar) {
position: relative;
width: 108px;
height: 108px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(22, 78, 99, 0.16) 0%,
rgba(7, 18, 36, 0.08) 48%,
rgba(5, 10, 22, 0.02) 76%,
transparent 100%
);
box-shadow:
inset 0 0 0 1px rgba(56, 189, 248, 0.12),
0 0 40px rgba(34, 211, 238, 0.08);
}
.root :global(.loading-radar-core) {
position: absolute;
top: 50%;
left: 50%;
width: 14px;
height: 14px;
border-radius: 50%;
background: radial-gradient(circle, #6FB7FF 0%, #0891b2 100%);
transform: translate(-50%, -50%);
box-shadow:
0 0 12px rgba(34, 211, 238, 0.65),
0 0 28px rgba(59, 130, 246, 0.25);
}
.root :global(.loading-radar-ring) {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(56, 189, 248, 0.14);
}
.root :global(.loading-radar-ring-1) {
transform: scale(0.62);
}
.root :global(.loading-radar-ring-2) {
transform: scale(0.88);
}
.root :global(.loading-radar-sweep) {
position: absolute;
inset: 6px;
border-radius: 50%;
background: conic-gradient(
from 90deg,
rgba(34, 211, 238, 0) 0deg,
rgba(34, 211, 238, 0) 260deg,
rgba(34, 211, 238, 0.3) 312deg,
rgba(96, 165, 250, 0.06) 360deg
);
mask: radial-gradient(circle, transparent 0 22px, #000 23px);
animation: radar-sweep 2.4s linear infinite;
}
.root :global(.loading-radar-blip) {
position: absolute;
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(253, 224, 71, 0.92);
box-shadow: 0 0 12px rgba(253, 224, 71, 0.45);
animation: radar-blip 1.8s ease-in-out infinite;
}
.root :global(.loading-radar-blip-1) {
top: 26px;
right: 24px;
animation-delay: 0.15s;
}
.root :global(.loading-radar-blip-2) {
bottom: 25px;
left: 20px;
background: rgba(74, 222, 128, 0.9);
box-shadow: 0 0 12px rgba(74, 222, 128, 0.4);
animation-delay: 0.9s;
}
.root :global(.loading-thermals) {
display: flex;
align-items: end;
gap: 8px;
height: 28px;
}
.root :global(.loading-thermal) {
width: 10px;
border-radius: 999px;
background: linear-gradient(
180deg,
rgba(251, 191, 36, 0.15) 0%,
rgba(251, 146, 60, 0.8) 55%,
rgba(239, 68, 68, 0.9) 100%
);
box-shadow: 0 0 18px rgba(251, 146, 60, 0.18);
animation: thermal-rise 1.5s ease-in-out infinite;
transform-origin: center bottom;
}
.root :global(.loading-thermal-1) {
height: 11px;
animation-delay: 0s;
}
.root :global(.loading-thermal-2) {
height: 22px;
animation-delay: 0.2s;
}
.root :global(.loading-thermal-3) {
height: 16px;
animation-delay: 0.42s;
}
.root :global(.loading-thermal-4) {
height: 25px;
animation-delay: 0.68s;
}
.root :global(.loading-drizzle) {
position: relative;
width: 220px;
height: 26px;
overflow: hidden;
opacity: 0.88;
}
.root :global(.loading-drizzle-drop) {
position: absolute;
top: -2px;
width: 2px;
height: 16px;
border-radius: 999px;
background: linear-gradient(
180deg,
rgba(125, 211, 252, 0) 0%,
rgba(125, 211, 252, 0.9) 100%
);
box-shadow: 0 0 8px rgba(56, 189, 248, 0.12);
animation: drizzle-fall 1.35s linear infinite;
}
.root :global(.loading-drizzle-drop-1) {
left: 38px;
animation-delay: -0.1s;
}
.root :global(.loading-drizzle-drop-2) {
left: 76px;
height: 18px;
animation-delay: -0.52s;
}
.root :global(.loading-drizzle-drop-3) {
left: 112px;
height: 14px;
animation-delay: -0.92s;
}
.root :global(.loading-drizzle-drop-4) {
left: 156px;
height: 17px;
animation-delay: -0.38s;
}
.root :global(.loading-drizzle-drop-5) {
left: 192px;
height: 13px;
animation-delay: -0.74s;
}
.root :global(.loading-overlay.hidden) {
display: none;
}
.root :global(.loading-spinner) {
width: 48px;
height: 48px;
border: 2px solid rgba(34, 211, 238, 0.1);
border-top-color: var(--accent-cyan);
border-radius: 50%;
animation: loading-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
box-shadow: 0 0 15px rgba(34, 211, 238, 0.1);
}
@keyframes loading-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes city-loading-pulse {
0% {
transform: scale(0.92);
box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.44);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 10px rgba(34, 211, 238, 0);
}
100% {
transform: scale(0.92);
box-shadow: 0 0 0 0 rgba(34, 211, 238, 0);
}
}
@keyframes radar-sweep {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes radar-blip {
0%,
100% {
opacity: 0.2;
transform: scale(0.72);
}
40% {
opacity: 1;
transform: scale(1);
}
60% {
opacity: 0.9;
transform: scale(1.18);
}
}
@keyframes wind-shift {
from {
transform: translateX(0);
opacity: 0;
}
18% {
opacity: 0.9;
}
82% {
opacity: 0.78;
}
to {
transform: translateX(145%);
opacity: 0;
}
}
@keyframes thermal-rise {
0%,
100% {
transform: scaleY(0.78);
opacity: 0.72;
}
50% {
transform: scaleY(1.14);
opacity: 1;
}
}
@keyframes cloud-drift {
0%,
100% {
transform: translateX(0);
opacity: 0.7;
}
50% {
transform: translateX(12px);
opacity: 0.95;
}
}
@keyframes drizzle-fall {
0% {
transform: translateY(-3px);
opacity: 0;
}
20% {
opacity: 0.9;
}
100% {
transform: translateY(18px);
opacity: 0;
}
}
/* ── Nearby Stations (Premium Glassmorphism) ── */
.root :global(.nearby-marker-premium) {
display: flex;
align-items: center;
gap: 10px;
background: rgba(10, 14, 26, 0.75);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 999px; /* Pill shape */
padding: 5px 14px 5px 10px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
inset 0 1px 1px rgba(255, 255, 255, 0.05);
pointer-events: none;
white-space: nowrap;
}
.root :global(.nearby-marker-shell) {
display: inline-block;
will-change: transform;
}
@keyframes nearby-fade-in {
from {
opacity: 0;
transform: scale(0.9) translateY(4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.root :global(.nearby-pulse) {
position: relative;
width: 12px;
height: 12px;
flex-shrink: 0;
}
.root :global(.pulse-core) {
position: absolute;
top: 50%;
left: 50%;
width: 6px;
height: 6px;
background: var(--accent-cyan);
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 0 8px var(--accent-cyan);
}
.root :global(.pulse-ring) {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
border: 1.5px solid var(--accent-cyan);
border-radius: 50%;
transform: translate(-50%, -50%);
opacity: 0.3;
box-shadow: 0 0 10px rgba(34, 211, 238, 0.18);
}
.root :global(.nearby-content) {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.root :global(.nearby-label) {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 0.05em;
}
.root :global(.nearby-stats) {
display: flex;
align-items: baseline;
gap: 2px;
}
.root :global(.nearby-temp-val) {
font-size: 13px;
font-weight: 800;
color: var(--text-primary);
text-shadow: 0 0 12px rgba(255, 255, 255, 0.1);
}
.root :global(.nearby-temp-unit) {
font-size: 10px;
color: var(--text-muted);
}
.root :global(.nearby-time) {
display: flex;
align-items: center;
gap: 4px;
margin-top: 3px;
font-size: 11px;
font-weight: 700;
color: rgba(203, 213, 225, 0.95);
white-space: nowrap;
}
.root :global(.nearby-time span + span)::before {
content: "·";
margin-right: 4px;
color: rgba(148, 163, 184, 0.55);
}
.root :global(.nearby-time.is-stale) {
color: #fbbf24;
}
.root :global(.nearby-wind) {
display: flex;
align-items: center;
gap: 4px;
padding-left: 10px;
margin-left: 2px;
border-left: 1px solid rgba(255, 255, 255, 0.1);
}
.root :global(.nearby-wind .wind-arrow) {
font-size: 12px;
color: var(--accent-cyan);
text-shadow: 0 0 8px rgba(34, 211, 238, 0.4);
}
.root :global(.nearby-wind .wind-val) {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
}
/* ── Trend badge ── */
.root :global(.trend-badge) {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
}
.root :global(.trend-badge.rising) {
background: rgba(34, 197, 94, 0.12);
color: var(--accent-green);
border: 1px solid rgba(34, 197, 94, 0.25);
}
.root :global(.trend-badge.falling) {
background: rgba(248, 113, 113, 0.12);
color: var(--accent-red);
border: 1px solid rgba(248, 113, 113, 0.25);
}
.root :global(.trend-badge.stagnant) {
background: rgba(251, 191, 36, 0.12);
color: var(--accent-yellow);
border: 1px solid rgba(251, 191, 36, 0.25);
}
.root :global(.trend-badge.mixed) {
background: rgba(167, 139, 250, 0.12);
color: var(--accent-purple);
border: 1px solid rgba(167, 139, 250, 0.25);
}
.root :global(.dead-market) {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
background: rgba(248, 113, 113, 0.15);
color: var(--accent-red);
border: 1px solid rgba(248, 113, 113, 0.3);
animation: deadPulse 2s ease-in-out infinite;
}
@keyframes deadPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
@@ -0,0 +1,303 @@
.root :global(.scan-select) {
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #4DA3FF, #00b383);
color: #000;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(0, 224, 164, 0.2);
}
.root :global(.scan-cta-button:hover:not(:disabled)) {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(0, 224, 164, 0.4);
filter: brightness(1.1);
}
.root :global(.scan-cta-button:active:not(:disabled)) {
transform: translateY(1px);
}
.root :global(.modal-overlay) {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(2, 6, 23, 0.75);
backdrop-filter: blur(12px);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.root :global(.modal-content) {
background: #16213A;
border: 1px solid var(--border-subtle);
border-radius: 16px;
width: 100%;
max-width: 700px;
max-height: calc(100vh - 48px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
.root :global(.modal-header) {
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
display: flex;
justify-content: space-between;
align-items: center;
}
.root :global(.modal-header h2) {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
min-width: 0;
}
.root :global(.future-modal-title-with-actions) {
display: flex;
align-items: center;
gap: 12px;
}
.root :global(.future-refresh-btn) {
background: transparent;
border: none;
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 6px;
transition: all 0.2s ease;
}
.root :global(.future-refresh-btn:hover) {
color: var(--accent-cyan);
background: rgba(34, 211, 238, 0.1);
}
.root :global(.future-refresh-btn.spinning svg) {
animation: spin 1s linear infinite;
color: var(--accent-cyan);
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
.root :global(.modal-close) {
background: none;
border: none;
font-size: 20px;
color: var(--text-muted);
cursor: pointer;
transition: color 0.2s;
}
.root :global(.modal-close:hover) {
color: var(--accent-red);
}
.root :global(.modal-body) {
padding: 20px;
overflow-y: auto;
}
/* Keep scroll behavior but hide visual scrollbar in today's/future analysis modal */
.root :global(.modal-content.large.future-modal .modal-body) {
-ms-overflow-style: none;
scrollbar-width: none;
}
.root
:global(.modal-content.large.future-modal .modal-body::-webkit-scrollbar) {
width: 0;
height: 0;
}
@media (max-width: 640px) {
.root :global(.modal-content) {
border-radius: 14px;
max-height: calc(100vh - 16px);
}
.root :global(.modal-header) {
padding: 14px 14px 12px;
align-items: flex-start;
gap: 12px;
}
.root :global(.modal-header h2) {
font-size: 15px;
line-height: 1.35;
}
.root :global(.modal-body) {
padding: 14px;
}
}
/* ── Info Button ── */
.root :global(.account-btn) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid rgba(34, 211, 238, 0.34);
background: rgba(34, 211, 238, 0.08);
color: var(--accent-cyan);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
text-decoration: none;
transition: var(--transition);
}
.root :global(.account-btn:hover) {
background: rgba(34, 211, 238, 0.16);
border-color: rgba(34, 211, 238, 0.62);
color: #f8fbff;
transform: translateY(-1px);
}
.root :global(.account-renew-badge) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(245, 158, 11, 0.34);
background: rgba(245, 158, 11, 0.1);
color: #fbbf24;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.01em;
text-decoration: none;
transition: var(--transition);
}
.root :global(.account-renew-badge:hover) {
background: rgba(245, 158, 11, 0.18);
border-color: rgba(245, 158, 11, 0.6);
color: #fff6d5;
transform: translateY(-1px);
}
.root :global(.account-renew-badge.expired) {
border-color: rgba(239, 68, 68, 0.34);
background: rgba(239, 68, 68, 0.12);
color: #fca5a5;
}
.root :global(.trial-promo-badge) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(34, 211, 238, 0.28);
background: rgba(34, 211, 238, 0.1);
color: #a5f3fc;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.01em;
text-decoration: none;
transition: var(--transition);
}
.root :global(.trial-promo-badge:hover) {
background: rgba(34, 211, 238, 0.16);
border-color: rgba(34, 211, 238, 0.45);
color: #ecfeff;
transform: translateY(-1px);
}
.root :global(.info-btn) {
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.3);
color: var(--accent-blue);
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
gap: 6px;
}
.root :global(.info-btn:hover) {
background: rgba(99, 102, 241, 0.2);
border-color: var(--accent-blue);
transform: translateY(-1px);
}
.root :global(.info-btn.active) {
background: rgba(34, 211, 238, 0.14);
border-color: rgba(34, 211, 238, 0.42);
color: #e0fbff;
}
/* ── Guide Modal Customizations ── */
.root :global(.modal-content.large) {
max-width: 900px;
}
.root :global(.guide-grid) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
padding: 4px;
}
.root :global(.guide-card) {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 16px;
transition: var(--transition);
}
.root :global(.guide-card:hover) {
background: rgba(255, 255, 255, 0.05);
border-color: var(--border-glass);
}
.root :global(.guide-card h3) {
font-size: 15px;
font-weight: 700;
color: var(--accent-cyan);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.root :global(.guide-card p) {
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 0;
}
.root :global(.guide-card b) {
color: var(--text-primary);
}
.root :global(.guide-footer) {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--border-subtle);
text-align: center;
font-size: 11px;
color: var(--text-muted);
}
@@ -0,0 +1,827 @@
/* ── Header ── */
.root :global(.header) {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--header-height);
z-index: 1000;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 0 16px;
background: rgba(7, 11, 18, 0.94);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border-glass);
}
.root :global(.brand) {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.root :global(.brand-mark) {
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 10px;
border: 1px solid rgba(0, 224, 164, 0.22);
background: rgba(8, 15, 28, 0.78);
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.08),
0 0 20px rgba(0, 224, 164, 0.12);
}
.root :global(.brand-mark img) {
width: 24px;
height: 24px;
display: block;
object-fit: contain;
}
.root :global(.brand h1) {
font-size: 18px;
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(
135deg,
var(--accent-cyan) 0%,
var(--accent-blue) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.root :global(.subtitle) {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 0.04em;
opacity: 0.8;
}
.root :global(.header-nav) {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
flex: 1 1 auto;
min-width: 0;
}
.root :global(.header-nav-link) {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 12px 18px;
border-radius: 10px;
color: rgba(203, 213, 225, 0.82);
text-decoration: none;
font-size: 14px;
font-weight: 550;
transition: var(--transition);
position: relative;
}
.root :global(.header-nav-link:hover) {
color: #f8fafc;
background: rgba(30, 41, 59, 0.44);
}
.root :global(.header-nav-link.active) {
color: #f8fafc;
background: transparent;
box-shadow: none;
}
.root :global(.header-nav-link.active::after) {
content: "";
position: absolute;
left: 18px;
right: 18px;
bottom: 4px;
height: 2px;
border-radius: 999px;
background: var(--accent-cyan);
}
.root :global(.header-right) {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.root :global(.lang-switch) {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px;
border-radius: 10px;
border: 1px solid var(--border-glass);
background: var(--bg-glass);
}
.root :global(.lang-btn) {
border: none;
background: transparent;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
line-height: 1;
padding: 6px 8px;
border-radius: 7px;
cursor: pointer;
transition: var(--transition);
}
.root :global(.lang-btn:hover) {
color: var(--text-primary);
background: rgba(99, 102, 241, 0.12);
}
.root :global(.lang-btn.active) {
color: var(--text-primary);
background: rgba(0, 224, 164, 0.12);
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.2);
}
.root :global(.live-badge) {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 9999px;
background: rgba(0, 224, 164, 0.08);
border: 1px solid rgba(0, 224, 164, 0.22);
font-size: 10px;
font-weight: 700;
color: var(--accent-green);
letter-spacing: 1.2px;
text-transform: uppercase;
}
.root :global(.pulse-dot) {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-green);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(0, 224, 164, 0.4);
}
50% {
opacity: 0.7;
box-shadow: 0 0 0 6px rgba(0, 224, 164, 0);
}
}
.root :global(.refresh-btn) {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid var(--border-glass);
background: rgba(13, 19, 33, 0.6);
color: var(--text-secondary);
cursor: pointer;
transition: all 150ms ease;
display: flex;
align-items: center;
justify-content: center;
}
.root :global(.refresh-btn:hover) {
background: rgba(0, 224, 164, 0.1);
border-color: rgba(0, 224, 164, 0.3);
color: var(--accent-cyan);
}
.root :global(.refresh-btn.spinning) {
animation: spin 1s linear infinite;
}
.root :global(.locale-switch) {
height: 32px;
padding: 3px;
border-radius: 10px;
border: 1px solid rgba(0, 224, 164, 0.2);
background: rgba(8, 15, 27, 0.82);
color: rgba(148, 163, 184, 0.86);
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 11px;
font-weight: 750;
cursor: pointer;
transition: var(--transition);
}
.root :global(.locale-switch span) {
min-width: 32px;
height: 24px;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.root :global(.locale-switch span.active) {
color: #f8fafc;
background: rgba(0, 224, 164, 0.16);
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.24);
}
.root :global(.locale-switch:hover) {
border-color: rgba(0, 224, 164, 0.34);
background: rgba(11, 22, 40, 0.94);
}
.root :global(.header-utility-btn) {
min-width: 32px;
height: 32px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid rgba(115, 137, 161, 0.18);
background: rgba(8, 15, 27, 0.82);
color: rgba(226, 232, 240, 0.8);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
text-decoration: none;
font-size: 12px;
font-weight: 650;
transition: var(--transition);
}
.root :global(.header-utility-btn:hover) {
color: #f8fafc;
border-color: rgba(0, 224, 164, 0.34);
background: rgba(11, 22, 40, 0.94);
}
.root :global(.header-utility-btn.active) {
color: #f8fafc;
border-color: rgba(123, 97, 255, 0.34);
}
.root :global(.header-utility-btn.more) {
padding: 0;
width: 32px;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* ── City List Sidebar ── */
.root :global(.city-list) {
position: fixed;
top: calc(var(--header-height) + 12px);
left: 12px;
width: var(--sidebar-width);
max-height: calc(100vh - var(--header-height) - 24px);
z-index: 900;
background: var(--bg-glass);
backdrop-filter: blur(var(--glass-blur));
border: 1px solid var(--border-glass);
border-radius: 18px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-lg);
}
.root :global(.city-list-header) {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border-subtle);
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.root :global(.city-search) {
display: flex;
align-items: center;
gap: 8px;
margin: 0 14px 12px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(115, 137, 161, 0.14);
background: rgba(6, 12, 22, 0.82);
color: rgba(148, 163, 184, 0.86);
}
.root :global(.city-search input) {
width: 100%;
border: none;
outline: none;
background: transparent;
color: #f8fafc;
font: inherit;
font-size: 13px;
}
.root :global(.city-search input::placeholder) {
color: rgba(148, 163, 184, 0.72);
}
.root :global(.city-count) {
background: rgba(123, 97, 255, 0.2);
color: var(--accent-blue);
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
padding: 2px 8px;
border-radius: 9999px;
border: 1px solid rgba(123, 97, 255, 0.15);
}
.root :global(.city-list-items) {
overflow-y: auto;
flex: 1;
padding: 0 6px 8px;
}
.root :global(.city-list-items::-webkit-scrollbar) {
width: 4px;
}
.root :global(.city-list-items::-webkit-scrollbar-track) {
background: transparent;
}
.root :global(.city-list-items::-webkit-scrollbar-thumb) {
background: var(--border-glass);
border-radius: 2px;
}
.root :global(.sidebar-footer) {
border-top: 1px solid var(--border-subtle);
padding: 10px 12px;
color: rgba(148, 163, 184, 0.72);
font-size: 10px;
line-height: 1.55;
}
.root :global(.city-group) {
border: 1px solid rgba(115, 137, 161, 0.12);
border-radius: 14px;
background: rgba(10, 17, 30, 0.48);
margin-bottom: 8px;
overflow: hidden;
}
.root :global(.city-group:last-child) {
margin-bottom: 0;
}
.root :global(.city-group-header) {
width: 100%;
border: none;
background: rgba(15, 28, 47, 0.56);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 10px;
cursor: pointer;
font-family: inherit;
transition: background 150ms ease;
}
.root :global(.city-group-header:hover) {
background: rgba(18, 34, 55, 0.82);
}
.root :global(.city-group-title) {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.root :global(.city-group-indicator) {
width: 3px;
height: 14px;
border-radius: 2px;
flex-shrink: 0;
}
.root :global(.city-group-indicator.high) {
background: var(--risk-high);
box-shadow: 0 0 6px rgba(239, 68, 68, 0.3);
}
.root :global(.city-group-indicator.medium) {
background: var(--risk-medium);
box-shadow: 0 0 6px rgba(245, 158, 11, 0.3);
}
.root :global(.city-group-indicator.low) {
background: var(--risk-low);
box-shadow: 0 0 6px rgba(34, 197, 94, 0.3);
}
.root :global(.city-group-indicator.other) {
background: var(--text-muted);
opacity: 0.5;
}
.root :global(.city-group-meta) {
display: inline-flex;
align-items: center;
gap: 8px;
}
.root :global(.city-group-count) {
min-width: 18px;
height: 18px;
border-radius: 9px;
padding: 0 6px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(123, 97, 255, 0.26);
color: var(--text-primary);
font-size: 10px;
font-weight: 700;
}
.root :global(.city-group-arrow) {
font-size: 11px;
color: var(--text-muted);
transform: rotate(-90deg);
transition: transform 0.2s ease;
}
.root :global(.city-group-arrow.expanded) {
transform: rotate(0deg);
}
.root :global(.city-group-items) {
padding: 4px;
}
.root :global(.city-group.collapsed .city-group-items) {
display: none;
}
.root :global(.city-item) {
width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border-radius: 10px;
cursor: pointer;
transition: var(--transition);
border: 1px solid transparent;
background: transparent;
color: inherit;
font-family: inherit;
text-align: left;
}
.root :global(.city-item:hover) {
background: rgba(10, 26, 44, 0.84);
border-color: rgba(0, 224, 164, 0.18);
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.03);
}
.root :global(.city-item.active) {
background:
linear-gradient(135deg, rgba(0, 224, 164, 0.12), rgba(123, 97, 255, 0.1)),
rgba(9, 18, 32, 0.92);
border-color: rgba(0, 224, 164, 0.24);
box-shadow:
0 0 20px rgba(0, 224, 164, 0.08),
inset 0 0 0 1px rgba(0, 224, 164, 0.08);
}
.root :global(.city-item-main) {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.root :global(.city-item .city-name-text) {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.root :global(.city-item-info) {
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 20px; /* Align with name text, after the dot */
font-size: 10px;
color: var(--text-muted);
}
.root :global(.city-item .city-max-info) {
color: var(--accent-blue);
font-weight: 500;
}
.root :global(.city-item .city-deviation-info) {
font-weight: 600;
}
.root :global(.city-item .city-deviation-cold) {
color: #4DA3FF;
}
.root :global(.city-item .city-deviation-hot) {
color: #f59e0b;
}
.root :global(.city-item .city-deviation-normal) {
color: #22d3ee;
}
.root :global(.city-item .city-deviation-info.strong) {
text-shadow: 0 0 10px rgba(56, 189, 248, 0.18);
}
.root :global(.city-item .city-deviation-hot.strong) {
text-shadow: 0 0 10px rgba(245, 158, 11, 0.24);
}
.root :global(.city-item .risk-dot) {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.root :global(.city-item .risk-dot.high) {
background: var(--risk-high);
box-shadow: 0 0 6px var(--risk-high);
}
.root :global(.city-item .risk-dot.medium) {
background: var(--risk-medium);
box-shadow: 0 0 6px var(--risk-medium);
}
.root :global(.city-item .risk-dot.low) {
background: var(--risk-low);
box-shadow: 0 0 6px var(--risk-low);
}
.root :global(.city-clock-icon) {
display: inline-block;
vertical-align: -1px;
margin-right: 3px;
opacity: 0.6;
}
.root :global(.city-item .city-temp) {
margin-left: auto;
font-size: 13px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--accent-cyan);
opacity: 0;
transition: opacity 200ms ease;
}
.root :global(.city-item .city-temp.loaded) {
opacity: 1;
}
/* ── Responsive ── */
@media (max-width: 1100px) {
.root {
--panel-width: 460px;
}
.root :global(.hero-value) {
font-size: 52px;
}
}
@media (max-width: 768px) {
.root :global(.header) {
height: auto;
min-height: var(--header-height);
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
}
.root :global(.brand) {
align-items: flex-start;
gap: 8px;
min-width: 0;
}
.root :global(.header-right) {
flex: 1 1 auto;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.root :global(.lang-switch) {
order: 1;
}
.root :global(.account-btn),
.root :global(.info-btn) {
padding: 6px 10px;
font-size: 12px;
}
.root :global(.live-badge) {
order: 4;
padding: 4px 10px;
white-space: nowrap;
}
.root :global(.refresh-btn) {
order: 5;
}
.root :global(.city-list) {
display: flex;
top: auto;
left: 8px;
right: 8px;
bottom: 8px;
width: auto;
max-height: min(36vh, 320px);
border-radius: 14px;
z-index: 920;
}
.root :global(.detail-panel) {
width: 100%;
}
.root {
--panel-width: 100%;
}
.root :global(.city-list-header) {
padding: 12px 14px;
font-size: 14px;
}
.root :global(.city-list-items) {
padding: 6px;
}
.root :global(.city-group-header) {
padding: 8px 9px;
}
.root :global(.city-item) {
padding: 8px 9px;
}
.root :global(.city-item .city-name-text),
.root :global(.city-item .city-temp) {
font-size: 12px;
}
.root :global(.city-item-info) {
padding-left: 18px;
flex-wrap: wrap;
row-gap: 4px;
}
.root :global(.panel-header) {
padding: 16px 16px 14px;
}
}
@media (max-width: 600px) {
.root :global(.header) {
flex-direction: column;
align-items: stretch;
gap: 8px;
padding: 8px 10px;
}
.root :global(.brand) {
justify-content: space-between;
width: 100%;
}
.root :global(.subtitle) {
display: none;
}
.root :global(.brand h1) {
font-size: 16px;
}
.root :global(.header-right) {
width: 100%;
justify-content: flex-start;
gap: 6px;
}
.root :global(.lang-switch) {
margin-right: auto;
}
.root :global(.account-btn),
.root :global(.info-btn) {
min-height: 34px;
padding: 6px 9px;
font-size: 11px;
}
.root :global(.account-btn span),
.root :global(.info-btn span) {
max-width: 88px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.root :global(.live-badge) {
padding: 4px 8px;
font-size: 10px;
letter-spacing: 0.6px;
}
.root :global(.refresh-btn) {
width: 34px;
height: 34px;
font-size: 16px;
}
.root :global(.hero-value) {
font-size: 42px;
}
.root :global(.hero-details) {
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.root :global(.hero-item .value) {
font-size: 14px;
}
.root :global(.city-list) {
left: 6px;
right: 6px;
bottom: 6px;
max-height: min(40vh, 300px);
}
.root :global(.city-list-header) {
padding: 10px 12px;
}
.root :global(.city-group-title) {
font-size: 11px;
}
.root :global(.city-item-main) {
gap: 6px;
}
.root :global(.city-item-info) {
font-size: 9px;
}
.root :global(.panel-header) {
padding: 14px 14px 12px;
}
.root :global(.city-marker:hover) {
transform: none;
}
.root :global(.marker-bubble) {
min-width: 48px;
min-height: 32px;
padding: 6px 10px;
}
.root :global(.marker-name) {
font-size: 11px;
}
.root :global(.prob-price-grid) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.root :global(.prob-price-head) {
align-items: flex-start;
flex-direction: column;
}
}
@@ -0,0 +1,135 @@
"use client";
import type { ChartConfiguration } from "chart.js";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useI18n } from "@/hooks/useI18n";
import { getTemperatureChartData } from "@/lib/chart-utils";
import type { CityDetail } from "@/lib/dashboard-types";
export function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
const { locale, t } = useI18n();
const chartData = useMemo(
() => getTemperatureChartData(detail, locale),
[detail, locale],
);
const forecastLabel = chartData?.datasets.hasMgmHourly
? locale === "en-US"
? "MGM Forecast"
: "MGM 预测"
: locale === "en-US"
? "DEB Forecast"
: "DEB 预测";
const observationLabel =
chartData?.observationLabel ||
(locale === "en-US" ? "METAR Observation" : "METAR 实况");
const canvasRef = useChart(() => {
if (!chartData) {
return {
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
}
const forecastPoints = chartData.datasets.hasMgmHourly
? chartData.datasets.mgmHourlyPoints
: chartData.datasets.debPast.map(
(value, index) => value ?? chartData.datasets.debFuture[index],
);
return {
data: {
datasets: [
{
borderColor: chartData.datasets.hasMgmHourly
? "rgba(250, 204, 21, 0.92)"
: "rgba(52, 211, 153, 0.86)",
borderWidth: 1.8,
data: forecastPoints,
fill: false,
label: forecastLabel,
pointRadius: 0,
spanGaps: true,
tension: 0.28,
},
{
backgroundColor: "#4DA3FF",
borderColor: "#4DA3FF",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label: observationLabel,
pointHoverRadius: 5,
pointRadius: 3.2,
showLine: false,
},
],
labels: chartData.times,
},
options: {
interaction: { intersect: false, mode: "index" },
layout: { padding: { bottom: 0, left: 0, right: 6, top: 4 } },
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.95)",
borderColor: "rgba(77, 163, 255, 0.28)",
borderWidth: 1,
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.03)" },
ticks: {
callback: (_value, index) =>
typeof index === "number" && index % 4 === 0
? chartData.times[index]
: "",
color: "#6B7A90",
font: { size: 10 },
maxTicksLimit: 6,
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.03)" },
max: chartData.max,
min: chartData.min,
ticks: {
callback: (value) =>
`${Number(value).toFixed(chartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
color: "#6B7A90",
font: { size: 10 },
maxTicksLimit: 5,
stepSize: chartData.yTickStep,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [chartData, detail.temp_symbol, forecastLabel, observationLabel]);
return (
<div className="detail-mini-chart-wrap">
<div className="detail-mini-chart">
<canvas ref={canvasRef} />
</div>
{chartData ? (
<div className="detail-mini-chart-legend">
<span>
<i className="forecast" />
{forecastLabel}
</span>
<span>
<i className="observation" />
{observationLabel}
</span>
</div>
) : null}
</div>
);
}
+11 -130
View File
@@ -1,148 +1,29 @@
"use client";
import type { ChartConfiguration } from "chart.js";
import clsx from "clsx";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { ForecastTable } from "@/components/dashboard/PanelSections";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
import { CityDetail } from "@/lib/dashboard-types";
import { trackAppEvent } from "@/lib/app-analytics";
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
import { getTemperatureChartData } from "@/lib/chart-utils";
import { getCityProfileStats } from "@/lib/dashboard-utils";
import { normalizeObservationSourceLabel } from "@/lib/source-labels";
import { getRiskBadgeLabel } from "@/lib/weather-summary-utils";
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
const { locale, t } = useI18n();
const chartData = useMemo(
() => getTemperatureChartData(detail, locale),
[detail, locale],
);
const forecastLabel = chartData?.datasets.hasMgmHourly
? locale === "en-US"
? "MGM Forecast"
: "MGM 预测"
: locale === "en-US"
? "DEB Forecast"
: "DEB 预测";
const observationLabel =
chartData?.observationLabel ||
(locale === "en-US" ? "METAR Observation" : "METAR 实况");
const canvasRef = useChart(() => {
if (!chartData) {
return {
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
}
const forecastPoints = chartData.datasets.hasMgmHourly
? chartData.datasets.mgmHourlyPoints
: chartData.datasets.debPast.map(
(value, index) => value ?? chartData.datasets.debFuture[index],
);
return {
data: {
datasets: [
{
borderColor: chartData.datasets.hasMgmHourly
? "rgba(250, 204, 21, 0.92)"
: "rgba(52, 211, 153, 0.86)",
borderWidth: 1.8,
data: forecastPoints,
fill: false,
label: forecastLabel,
pointRadius: 0,
spanGaps: true,
tension: 0.28,
},
{
backgroundColor: "#4DA3FF",
borderColor: "#4DA3FF",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
label: observationLabel,
pointHoverRadius: 5,
pointRadius: 3.2,
showLine: false,
},
],
labels: chartData.times,
},
options: {
interaction: { intersect: false, mode: "index" },
layout: { padding: { bottom: 0, left: 0, right: 6, top: 4 } },
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.95)",
borderColor: "rgba(77, 163, 255, 0.28)",
borderWidth: 1,
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.03)" },
ticks: {
callback: (_value, index) =>
typeof index === "number" && index % 4 === 0
? chartData.times[index]
: "",
color: "#6B7A90",
font: { size: 10 },
maxTicksLimit: 6,
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.03)" },
max: chartData.max,
min: chartData.min,
ticks: {
callback: (value) =>
`${Number(value).toFixed(chartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
color: "#6B7A90",
font: { size: 10 },
maxTicksLimit: 5,
stepSize: chartData.yTickStep,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [chartData, detail.temp_symbol, forecastLabel, observationLabel]);
return (
<div className="detail-mini-chart-wrap">
<div className="detail-mini-chart">
<canvas ref={canvasRef} />
</div>
{chartData ? (
<div className="detail-mini-chart-legend">
<span>
<i className="forecast" />
{forecastLabel}
</span>
<span>
<i className="observation" />
{observationLabel}
</span>
</div>
) : null}
</div>
);
}
const DetailMiniTemperatureChart = dynamic(
() =>
import("@/components/dashboard/DetailMiniTemperatureChart").then(
(module) => module.DetailMiniTemperatureChart,
),
{
loading: () => <div className="detail-mini-chart detail-mini-chart-loading" />,
ssr: false,
},
);
export function DetailPanel({
variant = "overlay",
@@ -210,4 +210,3 @@
font-size: 13px;
line-height: 1.7;
}
@@ -0,0 +1,290 @@
/* Detail panel content styles. */
.root :global(.detail-grid) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.root :global(.detail-card) {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 12px;
}
.root :global(.detail-source-list) {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.root :global(.detail-source-note) {
margin: 8px 0 12px;
color: var(--text-muted);
font-size: 12px;
line-height: 1.6;
}
.root :global(.detail-source-link) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.03);
color: var(--text-main);
text-decoration: none;
transition:
background 0.18s ease,
border-color 0.18s ease,
transform 0.18s ease;
}
.root :global(.detail-source-link:hover) {
background: rgba(34, 211, 238, 0.08);
border-color: rgba(34, 211, 238, 0.28);
transform: translateY(-1px);
}
.root :global(.detail-source-kind) {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
color: var(--accent-cyan);
text-transform: uppercase;
flex-shrink: 0;
}
.root :global(.detail-source-label) {
font-size: 13px;
font-weight: 600;
color: var(--text-main);
text-align: right;
}
.root :global(.detail-label) {
display: block;
color: var(--text-muted);
font-size: 11px;
margin-bottom: 6px;
}
.root :global(.detail-value) {
display: block;
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
line-height: 1.5;
word-break: break-word;
}
.root :global(.profile-lead) {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
}
.root :global(.detail-mini-chart-wrap) {
display: grid;
gap: 10px;
}
.root :global(.detail-mini-chart) {
position: relative;
height: 210px;
border: 1px solid var(--border-subtle);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 10px;
}
.root :global(.scan-city-detail-rail .detail-mini-chart) {
height: 230px;
}
.root :global(.detail-mini-chart canvas) {
width: 100% !important;
height: 100% !important;
}
.root :global(.detail-mini-chart-legend) {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
color: var(--text-muted);
font-size: 11px;
line-height: 1.4;
}
.root :global(.detail-mini-chart-legend span) {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.root :global(.detail-mini-chart-legend i) {
width: 16px;
height: 3px;
border-radius: 999px;
flex: 0 0 auto;
}
.root :global(.detail-mini-chart-legend i.forecast) {
background: rgba(52, 211, 153, 0.86);
}
.root :global(.detail-mini-chart-legend i.observation) {
width: 7px;
height: 7px;
background: #4DA3FF;
}
.root :global(.detail-mini-meta) {
color: var(--text-muted);
font-size: 11px;
line-height: 1.55;
}
.root :global(.insight-list) {
display: grid;
gap: 10px;
}
.root :global(.insight-item) {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 12px;
}
.root :global(.insight-title) {
color: var(--accent-cyan);
font-size: 12px;
font-weight: 700;
margin-bottom: 6px;
}
.root :global(.insight-text) {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.65;
}
.root :global(.detail-scenery-card) {
position: relative;
min-height: 210px;
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--border-subtle);
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.18), rgba(15, 23, 42, 0.78)),
rgba(255, 255, 255, 0.03);
}
.root :global(.detail-scenery-image) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.root :global(.detail-scenery-overlay) {
position: relative;
z-index: 1;
min-height: 210px;
padding: 18px;
display: flex;
flex-direction: column;
justify-content: space-between;
background: linear-gradient(
180deg,
rgba(2, 6, 23, 0.08) 0%,
rgba(2, 6, 23, 0.86) 100%
);
}
.root :global(.detail-scenery-copy),
.root :global(.detail-scenery-fallback) {
display: flex;
flex-direction: column;
gap: 6px;
}
.root :global(.detail-scenery-fallback) {
min-height: 210px;
padding: 18px;
justify-content: flex-end;
background:
radial-gradient(
circle at top left,
rgba(34, 211, 238, 0.12),
transparent 34%
),
linear-gradient(180deg, rgba(15, 23, 42, 0.92), rgba(2, 6, 23, 0.96));
}
.root :global(.detail-scenery-kicker) {
color: rgba(226, 232, 240, 0.78);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.root :global(.detail-scenery-title) {
color: #fff;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.03em;
text-shadow: 0 6px 22px rgba(2, 6, 23, 0.42);
}
.root :global(.detail-scenery-subtitle) {
max-width: 320px;
color: rgba(226, 232, 240, 0.9);
font-size: 13px;
line-height: 1.6;
text-shadow: 0 2px 10px rgba(2, 6, 23, 0.45);
}
.root :global(.detail-scenery-credit) {
align-self: flex-start;
color: rgba(226, 232, 240, 0.9);
font-size: 11px;
text-decoration: none;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(2, 6, 23, 0.35);
backdrop-filter: blur(10px);
}
.root :global(.detail-scenery-credit:hover) {
border-color: rgba(34, 211, 238, 0.4);
color: #fff;
}
@media (max-width: 640px) {
.root :global(.detail-grid) {
grid-template-columns: 1fr;
}
.root :global(.detail-mini-chart) {
height: 170px;
}
.root :global(.detail-source-link) {
padding: 10px 12px;
align-items: flex-start;
flex-direction: column;
}
.root :global(.detail-source-label) {
text-align: left;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,80 @@
"use client";
import type { useDashboardStore } from "@/hooks/useDashboardStore";
import type { useI18n } from "@/hooks/useI18n";
import type { getFutureModalView } from "@/lib/dashboard-utils";
import {
FutureModelForecastPanel,
FutureProbabilityPanel,
FutureTemperaturePathChart,
} from "./FutureForecastModalPanels";
type DashboardDetail = NonNullable<
ReturnType<typeof useDashboardStore>["selectedDetail"]
>;
type FutureModalView = ReturnType<typeof getFutureModalView>;
type TranslationFn = ReturnType<typeof useI18n>["t"];
export function FutureForecastForwardView({
dateStr,
detail,
t,
view,
}: {
dateStr: string;
detail: DashboardDetail;
t: TranslationFn;
view: FutureModalView;
}) {
return (
<>
<div className="history-stats">
<div className="h-stat-card">
<span className="label">{t("future.targetForecast")}</span>
<span className="val">
{view.forecastEntry?.max_temp ?? "--"}
{detail.temp_symbol}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("future.deb")}</span>
<span className="val">
{view.deb ?? "--"}
{detail.temp_symbol}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("future.mu")}</span>
<span className="val">
{view.mu != null
? `${view.mu.toFixed(1)}${detail.temp_symbol}`
: "--"}
</span>
</div>
<div className="h-stat-card">
<span className="label">{t("future.score")}</span>
<span className="val">
{view.front.score > 0 ? "+" : ""}
{view.front.score}
</span>
</div>
</div>
<section className="future-modal-section">
<h3>{t("future.targetTempTrend")}</h3>
<FutureTemperaturePathChart dateStr={dateStr} forceToday={false} />
</section>
<div className="future-modal-grid">
<section className="future-modal-section">
<h3>{t("future.probability")}</h3>
<FutureProbabilityPanel detail={detail} targetDate={dateStr} hideTitle />
</section>
<section className="future-modal-section">
<h3>{t("future.models")}</h3>
<FutureModelForecastPanel detail={detail} targetDate={dateStr} hideTitle />
</section>
</div>
</>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,252 @@
import type { IntradayMeteorologySignal } from "@/lib/dashboard-types";
export const TODAY_MARKET_SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000;
export function normalizeMarketValue(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
return Math.max(0, Math.min(1, numeric));
}
export function formatMinuteAxisLabel(value: number) {
if (!Number.isFinite(value)) return "";
const total = Math.max(0, Math.round(value));
const hour = Math.floor(total / 60) % 24;
const minute = total % 60;
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
export function formatMarketPercent(value?: number | null) {
const normalized = normalizeMarketValue(value);
if (normalized == null) return "--";
return `${(normalized * 100).toFixed(1)}%`;
}
export function formatBucketLabel(
bucket?: {
label?: string | null;
bucket?: string | null;
range?: string | null;
value?: number | null;
temp?: number | null;
} | null,
) {
if (!bucket) return "--";
const direct =
String(bucket.label || "").trim() ||
String(bucket.bucket || "").trim() ||
String(bucket.range || "").trim();
if (direct) {
let str = direct.toUpperCase().replace(/\s+/g, "");
str = str.replace(/°?C($|\+|-)/g, "℃$1");
if (!str.includes("℃") && /[0-9]/.test(str)) {
str += "℃";
}
return str;
}
const temp = Number(bucket.value ?? bucket.temp);
if (Number.isFinite(temp)) {
return `${Math.round(temp)}`;
}
return "--";
}
export function parseBucketBoundaries(
bucket?: {
label?: string | null;
bucket?: string | null;
range?: string | null;
value?: number | null;
temp?: number | null;
} | null,
) {
if (!bucket) return null;
const raw =
String(bucket.label || "").trim() ||
String(bucket.bucket || "").trim() ||
String(bucket.range || "").trim();
if (!raw) return null;
const numbers = Array.from(raw.matchAll(/-?\d+(?:\.\d+)?/g)).map((match) =>
Number(match[0]),
);
if (!numbers.length) return null;
if (raw.includes("+")) {
return {
lower: numbers[0] ?? null,
upper: null as number | null,
boundaryLabel: `${numbers[0]}°C`,
};
}
if (numbers.length >= 2) {
return {
lower: numbers[0],
upper: numbers[1],
boundaryLabel: null as string | null,
};
}
return {
lower: numbers[0],
upper: null as number | null,
boundaryLabel: `${numbers[0]}°C`,
};
}
export function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
export function parseClockMinutes(value?: string | null) {
const text = String(value || "").trim();
const match = text.match(/^(\d{1,2}):(\d{2})$/);
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
return hours * 60 + minutes;
}
export function parseLeadingNumber(value?: string | number | null) {
if (typeof value === "number" && Number.isFinite(value)) return value;
const text = String(value || "").trim();
const match = text.match(/[-+]?\d+(?:\.\d+)?/);
if (!match) return null;
const numeric = Number(match[0]);
return Number.isFinite(numeric) ? numeric : null;
}
export function parsePercentFromText(value?: string | number | null) {
if (typeof value === "number" && Number.isFinite(value)) {
return clamp(value, 0, 100);
}
const text = String(value || "").trim();
const percentMatch = text.match(/([-+]?\d+(?:\.\d+)?)\s*%/);
if (percentMatch) {
const numeric = Number(percentMatch[1]);
return Number.isFinite(numeric) ? clamp(numeric, 0, 100) : null;
}
return parseLeadingNumber(text);
}
export function formatConfidenceLabel(value?: string | null, locale = "zh-CN") {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "high") return locale === "en-US" ? "High" : "高";
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
if (normalized === "low") return locale === "en-US" ? "Low" : "低";
return locale === "en-US" ? "Pending" : "待确认";
}
export function formatSignalDirection(value?: string | null, locale = "zh-CN") {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "support")
return locale === "en-US" ? "Support" : "支持升温";
if (normalized === "suppress")
return locale === "en-US" ? "Suppress" : "压制峰值";
return locale === "en-US" ? "Neutral" : "中性";
}
export function formatSignalStrength(value?: string | null, locale = "zh-CN") {
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "strong") return locale === "en-US" ? "Strong" : "强";
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
return locale === "en-US" ? "Weak" : "弱";
}
export function signalTone(signal?: IntradayMeteorologySignal | null) {
const direction = String(signal?.direction || "")
.trim()
.toLowerCase();
if (direction === "support") return "cyan";
if (direction === "suppress") return "amber";
return "blue";
}
export function localizedText(
locale: string,
primary?: string | null,
english?: string | null,
) {
const en = String(english || "").trim();
const value = String(primary || "").trim();
if (locale === "en-US" && en) return en;
return value || en;
}
export function localizedList(
locale: string,
primary?: string[] | null,
english?: string[] | null,
) {
const en = Array.isArray(english)
? english.filter((item) => String(item || "").trim())
: [];
const value = Array.isArray(primary)
? primary.filter((item) => String(item || "").trim())
: [];
if (locale === "en-US" && en.length) return en;
return value.length ? value : en;
}
export function getTrendMetricVisual(metric: {
label?: string;
value?: string;
tone?: string;
}) {
const label = String(metric.label || "").toLowerCase();
const value = String(metric.value || "");
const numeric = parseLeadingNumber(value);
if (label.includes("降水") || label.includes("precip")) {
const precipPercent = parsePercentFromText(value);
if (precipPercent == null) return null;
return {
mode: "fill" as const,
percent: precipPercent,
tone: "cold" as const,
};
}
if (numeric == null) return null;
if (label.includes("温度") || label.includes("temp")) {
return {
mode: "center" as const,
percent: clamp(50 + (numeric / 4) * 50, 0, 100),
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
};
}
if (label.includes("露点") || label.includes("dew")) {
return {
mode: "center" as const,
percent: clamp(50 + (numeric / 3) * 50, 0, 100),
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
};
}
if (label.includes("气压") || label.includes("pressure")) {
return {
mode: "center" as const,
percent: clamp(50 + (numeric / 4) * 50, 0, 100),
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
};
}
if (label.includes("云量") || label.includes("cloud")) {
return {
mode: "center" as const,
percent: clamp(50 + (numeric / 40) * 50, 0, 100),
tone: numeric >= 0 ? ("cold" as const) : ("warm" as const),
};
}
return null;
}
@@ -0,0 +1,411 @@
"use client";
import type { ChartConfiguration } from "chart.js";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getTemperatureChartData } from "@/lib/chart-utils";
import { getFutureModalView } from "@/lib/dashboard-utils";
import { formatMinuteAxisLabel } from "./FutureForecastModal.utils";
export function DailyTemperatureChart({
dateStr,
forceToday = false,
}: {
dateStr: string;
forceToday?: boolean;
}) {
const store = useDashboardStore();
const { locale, t } = useI18n();
const detail = store.selectedDetail;
const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
const isToday =
forceToday || (detail ? dateStr === detail.local_date : false);
const todayChartData = useMemo(
() => (detail && isToday ? getTemperatureChartData(detail, locale) : null),
[detail, isToday, locale],
);
const canvasRef = useChart(() => {
if (!detail || !view) {
return {
data: { datasets: [], labels: [] },
type: "line",
} satisfies ChartConfiguration<"line">;
}
if (isToday && todayChartData) {
const datasets: NonNullable<
ChartConfiguration<"line">["data"]
>["datasets"] = [];
if (todayChartData.datasets.hasMgmHourly) {
datasets.push({
backgroundColor: "rgba(234, 179, 8, 0.05)",
borderColor: "rgba(234, 179, 8, 0.8)",
borderWidth: 2,
data: todayChartData.datasets.mgmHourlySeries,
fill: false,
label: locale === "en-US" ? "MGM Forecast" : "MGM 预测",
parsing: false,
pointHoverRadius: 6,
pointRadius: 3,
spanGaps: true,
tension: 0.3,
});
} else {
datasets.push({
backgroundColor: "rgba(77, 163, 255, 0.06)",
borderColor: "rgba(77, 163, 255, 0.66)",
borderWidth: 1.5,
data: todayChartData.datasets.debPastSeries,
fill: true,
label: locale === "en-US" ? "DEB Forecast" : "DEB 预测",
parsing: false,
pointHoverRadius: 3,
pointRadius: 0,
tension: 0.3,
});
datasets.push({
borderColor: "rgba(77, 163, 255, 0.36)",
borderDash: [5, 3],
borderWidth: 1.5,
data: todayChartData.datasets.debFutureSeries,
fill: false,
label: locale === "en-US" ? "DEB Forecast" : "DEB 预测",
parsing: false,
pointRadius: 0,
tension: 0.3,
});
}
datasets.push({
backgroundColor: "#4DA3FF",
borderColor: "#4DA3FF",
borderWidth: 0,
data: todayChartData.datasets.metarSeries,
fill: false,
label:
todayChartData.observationLabel ||
(locale === "en-US" ? "Observation" : "观测实况"),
order: 0,
parsing: false,
pointHoverRadius: 7,
pointRadius: 5,
showLine: false,
});
if (todayChartData.datasets.airportMetarSeries?.length > 0) {
datasets.push({
backgroundColor: "#60a5fa",
borderColor: "#60a5fa",
borderWidth: 1,
data: todayChartData.datasets.airportMetarSeries,
fill: false,
label: locale === "en-US" ? "Airport METAR" : "机场 METAR",
order: 0,
parsing: false,
pointHoverRadius: 6,
pointRadius: 4,
showLine: false,
});
}
if (todayChartData.datasets.mgmSeries?.length > 0) {
datasets.push({
backgroundColor: "#facc15",
borderColor: "#facc15",
borderWidth: 0,
data: todayChartData.datasets.mgmSeries,
fill: false,
label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
order: -1,
parsing: false,
pointHoverRadius: 9,
pointRadius: 7,
showLine: false,
});
}
if (
!todayChartData.datasets.hasMgmHourly &&
Math.abs(todayChartData.datasets.offset) > 0.3
) {
datasets.push({
borderColor: "rgba(77, 163, 255, 0.22)",
borderDash: [2, 4],
borderWidth: 1,
data: todayChartData.datasets.tempsSeries,
fill: false,
label: locale === "en-US" ? "OM Raw" : "OM 原始",
parsing: false,
pointRadius: 0,
tension: 0.3,
});
}
if ((todayChartData.tafMarkers || []).length > 0) {
datasets.push({
backgroundColor: "#f59e0b",
borderColor: "#f59e0b",
borderWidth: 0,
data: todayChartData.datasets.tafCurrentMarkerSeries,
fill: false,
label: locale === "en-US" ? "Current TAF" : "当前 TAF",
order: -3,
parsing: false,
pointHoverRadius: 8,
pointRadius: 6,
showLine: false,
});
datasets.push({
backgroundColor: "rgba(250, 204, 21, 0.72)",
borderColor: "rgba(250, 204, 21, 0.72)",
borderWidth: 0,
data: todayChartData.datasets.tafPeakWindowMarkerSeries,
fill: false,
label: locale === "en-US" ? "Peak-window TAF" : "峰值窗口 TAF",
order: -2,
parsing: false,
pointHoverRadius: 7,
pointRadius: 4,
showLine: false,
});
datasets.push({
backgroundColor: "#f59e0b",
borderColor: "#f59e0b",
borderWidth: 0,
data: todayChartData.datasets.tafMarkerSeries,
fill: false,
label: locale === "en-US" ? "TAF Timing" : "TAF 时段",
order: -4,
parsing: false,
pointHoverRadius: 0,
pointRadius: 0,
showLine: false,
});
}
return {
data: {
datasets,
labels: [],
},
options: {
interaction: { intersect: false, mode: "nearest" },
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: "#9FB2C7",
filter: (legendItem, chartData) => {
const text = String(legendItem.text || "");
if (!text) return false;
if (text === "TAF Timing" || text === "TAF 时段")
return false;
if (!text.includes("DEB")) return true;
const firstDebIndex = (chartData.datasets || []).findIndex(
(dataset) => String(dataset.label || "").includes("DEB"),
);
return legendItem.datasetIndex === firstDebIndex;
},
font: { family: "Inter", size: 11 },
},
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.96)",
borderColor: "rgba(77, 163, 255, 0.24)",
borderWidth: 1,
callbacks: {
title: (items) => {
const rawX = items?.[0]?.parsed?.x;
return rawX != null
? formatMinuteAxisLabel(Number(rawX))
: "";
},
label: (ctx) => {
const label = String(ctx.dataset.label || "");
const raw = ctx.raw as
| {
marker?: {
summary?: string;
markerType?: string;
displayType?: string;
isCurrent?: boolean;
isPeakWindow?: boolean;
};
}
| undefined;
if (
label === "TAF Timing" ||
label === "TAF 时段" ||
label === "Current TAF" ||
label === "当前 TAF" ||
label === "Peak-window TAF" ||
label === "峰值窗口 TAF"
) {
const marker = raw?.marker;
if (!marker) return label;
const markerType = String(marker.markerType || "");
const displayType = String(
marker.displayType || marker.markerType || "",
);
const summary = String(marker.summary || "");
const prefix =
marker.isCurrent && marker.isPeakWindow
? locale === "en-US"
? "Current / peak-window TAF"
: "当前 / 峰值窗口 TAF"
: marker.isCurrent
? locale === "en-US"
? "Current TAF"
: "当前 TAF"
: marker.isPeakWindow
? locale === "en-US"
? "Peak-window TAF"
: "峰值窗口 TAF"
: label;
return `${prefix}: ${
markerType
? summary.replace(markerType, displayType)
: summary
}`;
}
const value = ctx.parsed.y;
if (value == null) return label;
return `${label}: ${value.toFixed(1)}${detail.temp_symbol || "°C"}`;
},
},
},
},
responsive: true,
scales: {
x: {
max: todayChartData.xMax,
min: todayChartData.xMin,
grid: { color: "rgba(255,255,255,0.04)" },
type: "linear",
ticks: {
callback: (value) => {
const num = Number(value);
if (!Number.isFinite(num)) return "";
const minutes = Math.round(num);
if (
minutes !== todayChartData.xMin &&
minutes !== todayChartData.xMax &&
minutes % 120 !== 0
) {
return "";
}
return formatMinuteAxisLabel(minutes);
},
color: "#6B7A90",
font: { family: "Inter", size: 10 },
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
max: todayChartData.max,
min: todayChartData.min,
ticks: {
callback: (value) =>
`${Number(value).toFixed(todayChartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
color: "#6B7A90",
font: { family: "Inter", size: 10 },
stepSize: todayChartData.yTickStep,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}
const labels = view.slice.map((point) => point.label);
const unit = detail.temp_symbol || "°C";
return {
data: {
datasets: [
{
backgroundColor: "rgba(77, 163, 255, 0.08)",
borderColor: "#4DA3FF",
data: view.slice.map((point) => point.temp),
fill: false,
label:
locale === "en-US" ? "Open-Meteo Temperature" : "Open-Meteo 温度",
pointRadius: 2,
tension: 0.28,
},
{
backgroundColor: "transparent",
borderColor: "#93C5FD",
borderDash: [5, 4],
data: view.slice.map((point) => point.dewPoint),
fill: false,
label: locale === "en-US" ? "Dew Point" : "露点",
pointRadius: 0,
tension: 0.24,
},
],
labels,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: "#9FB2C7",
font: { family: "Inter", size: 11 },
},
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.96)",
borderColor: "rgba(77, 163, 255, 0.24)",
borderWidth: 1,
callbacks: {
label: (ctx) =>
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${unit}`,
},
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#6B7A90",
font: { family: "Inter", size: 10 },
maxRotation: 0,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
callback: (value) => `${value}${unit}`,
color: "#6B7A90",
font: { family: "Inter", size: 10 },
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [detail, isToday, locale, todayChartData, view]);
return (
<>
<div className="history-chart-wrapper future-chart-wrapper">
<canvas ref={canvasRef} />
</div>
{isToday && (
<div className="chart-legend">
{todayChartData?.legendText || t("future.chartLegendEmpty")}
</div>
)}
</>
);
}
@@ -0,0 +1,87 @@
"use client";
import dynamic from "next/dynamic";
import type { useDashboardStore } from "@/hooks/useDashboardStore";
import type { MarketScan } from "@/lib/dashboard-types";
const DailyTemperatureChart = dynamic(
() =>
import("./FutureForecastModalChart").then(
(module) => module.DailyTemperatureChart,
),
{
loading: () => <div className="history-chart-wrapper future-chart-wrapper" />,
ssr: false,
},
);
const ProbabilityDistribution = dynamic(
() =>
import("@/components/dashboard/PanelSections").then(
(module) => module.ProbabilityDistribution,
),
{
loading: () => <div className="future-v2-panel-loading" />,
ssr: false,
},
);
const ModelForecast = dynamic(
() =>
import("@/components/dashboard/PanelSections").then(
(module) => module.ModelForecast,
),
{
loading: () => <div className="future-v2-panel-loading" />,
ssr: false,
},
);
type DashboardDetail = NonNullable<
ReturnType<typeof useDashboardStore>["selectedDetail"]
>;
export function FutureTemperaturePathChart({
dateStr,
forceToday,
}: {
dateStr: string;
forceToday: boolean;
}) {
return <DailyTemperatureChart dateStr={dateStr} forceToday={forceToday} />;
}
export function FutureProbabilityPanel({
detail,
targetDate,
marketScan,
hideTitle = true,
}: {
detail: DashboardDetail;
targetDate: string;
marketScan?: MarketScan | null;
hideTitle?: boolean;
}) {
return (
<ProbabilityDistribution
detail={detail}
targetDate={targetDate}
marketScan={marketScan}
hideTitle={hideTitle}
/>
);
}
export function FutureModelForecastPanel({
detail,
targetDate,
hideTitle = true,
}: {
detail: DashboardDetail;
targetDate: string;
hideTitle?: boolean;
}) {
return (
<ModelForecast detail={detail} targetDate={targetDate} hideTitle={hideTitle} />
);
}
@@ -0,0 +1,63 @@
import clsx from "clsx";
export type FutureSyncStatusItem = {
key: string;
state: "ready" | "syncing";
label: string;
note: string;
};
export function FutureRefreshLock({ locale }: { locale: string }) {
return (
<div
className="future-v2-refresh-lock"
role="status"
aria-live="assertive"
>
<span className="future-v2-refresh-spinner" aria-hidden="true" />
<div>
<strong>
{locale === "en-US"
? "Refreshing latest intraday data"
: "正在刷新最新日内数据"}
</strong>
<p>
{locale === "en-US"
? "Old cached readings are temporarily locked to prevent misjudgement. The analysis will unlock after the latest anchor observation, model layer, and probability layer are ready."
: "旧缓存读数已临时锁定,避免误判。最新锚点观测、模型层和概率层就绪后会自动解锁。"}
</p>
</div>
</div>
);
}
export function FutureSyncStatusStrip({
items,
compact = false,
}: {
items: readonly FutureSyncStatusItem[];
compact?: boolean;
}) {
return (
<section
className={clsx("future-v2-sync-strip", compact && "future-v2-sync-strip-compact")}
aria-live="polite"
>
{items.map((item) => (
<div
key={item.key}
className={clsx(
"future-v2-sync-chip",
item.state === "syncing" && "syncing",
)}
>
<span className="future-v2-sync-dot" aria-hidden="true" />
<div className="future-v2-sync-copy">
<strong>{item.label}</strong>
<span>{item.note}</span>
</div>
</div>
))}
</section>
);
}
@@ -0,0 +1,26 @@
import {
Cloud,
CloudFog,
CloudLightning,
CloudRain,
CloudSnow,
CloudSun,
Search,
Sun,
Wind,
} from "lucide-react";
export function WeatherIcon({ emoji, size = 32 }: { emoji: string; size?: number }) {
if (emoji === "☀️") return <Sun size={size} color="#facc15" />;
if (emoji === "⛅" || emoji === "🌤️")
return <CloudSun size={size} color="#4DA3FF" />;
if (emoji === "☁️") return <Cloud size={size} color="#9FB2C7" />;
if (emoji === "🌧️" || emoji === "🌦️")
return <CloudRain size={size} color="#60a5fa" />;
if (emoji === "⛈️") return <CloudLightning size={size} color="#c084fc" />;
if (emoji === "❄️" || emoji === "🌨️")
return <CloudSnow size={size} color="#7dd3fc" />;
if (emoji === "🌫️") return <CloudFog size={size} color="#a1a1aa" />;
if (emoji === "💨") return <Wind size={size} color="#cbd5e1" />;
return <Search size={size} color="#6B7A90" />;
}
@@ -0,0 +1,281 @@
import clsx from "clsx";
import type { CSSProperties } from "react";
import type { getTodayPaceView } from "@/lib/pace-utils";
import { WeatherIcon } from "./FutureForecastModalWeatherIcon";
type Locale = string;
type TodayPaceView = NonNullable<ReturnType<typeof getTodayPaceView>>;
type WeatherSummaryView = {
weatherIcon: string;
weatherText: string;
};
type DaylightProgressView = {
phase: string;
percent: number;
};
export type FuturePaceSignalItem = {
label: string;
tone: "cyan" | "blue" | "amber";
status: string;
value: string;
note: string;
};
export function FutureAnchorStatusCard({
locale,
currentTempText,
weatherSummary,
obsTime,
daylightProgress,
sunrise,
sunset,
topObservedTemp,
tempSymbol,
gapToBaseBucket,
pathStatus,
}: {
locale: Locale;
currentTempText: string;
weatherSummary: WeatherSummaryView;
obsTime?: string | null;
daylightProgress: DaylightProgressView | null;
sunrise?: string | null;
sunset?: string | null;
topObservedTemp: number | string | null | undefined;
tempSymbol: string;
gapToBaseBucket: number | null;
pathStatus: string;
}) {
const observedHigh = topObservedTemp ?? "--";
return (
<section className="future-v2-card future-v2-hero-card">
<div className="future-v2-card-head">
<h3 className="future-v2-hero-title">
{locale === "en-US" ? "Anchor Status" : "锚点状态"}
</h3>
<div className="future-v2-card-kicker">
{locale === "en-US"
? "Settlement anchor and current clock"
: "结算锚点与当前时钟"}
</div>
</div>
<div className="future-v2-hero-main">
<div className="future-v2-hero-temp">{currentTempText}</div>
<div className="future-v2-hero-divider" />
<div className="future-v2-hero-weather">
<span className="future-v2-hero-icon">
<WeatherIcon emoji={weatherSummary.weatherIcon} size={42} />
</span>
<span>{weatherSummary.weatherText}</span>
</div>
</div>
<div className="future-v2-hero-obs">@{obsTime || "--"}</div>
{daylightProgress ? (
<div className="future-v2-daylight">
<div className="future-v2-daylight-head">
<span>{locale === "en-US" ? "Solar Window" : "昼夜进度"}</span>
<strong>
{daylightProgress.phase} {Math.round(daylightProgress.percent)}%
</strong>
</div>
<div
className="future-v2-daylight-bar"
style={
{
"--daylight-progress": `${daylightProgress.percent}%`,
} as CSSProperties & { "--daylight-progress": string }
}
/>
<div className="future-v2-daylight-times">
<span>{sunrise || "--"}</span>
<span>{sunset || "--"}</span>
</div>
</div>
) : null}
<div className="future-v2-mini-grid">
<FutureMiniMetric
label={locale === "en-US" ? "High so far" : "日内已见高点"}
value={`${observedHigh}${tempSymbol}`}
/>
<FutureMiniMetric
label={locale === "en-US" ? "Anchor clock" : "锚点时钟"}
value={obsTime || "--"}
/>
<FutureMiniMetric
label={locale === "en-US" ? "Gap to base" : "距基准档"}
value={
gapToBaseBucket != null
? `${gapToBaseBucket.toFixed(1)}${tempSymbol}`
: "--"
}
/>
<FutureMiniMetric
label={locale === "en-US" ? "Path state" : "路径状态"}
value={pathStatus}
/>
<FutureMiniMetric
label={locale === "en-US" ? "Sunrise" : "日出时间"}
value={sunrise || "--"}
/>
<FutureMiniMetric
label={locale === "en-US" ? "Sunset" : "日落时间"}
value={sunset || "--"}
/>
</div>
</section>
);
}
export function FuturePaceCard({
locale,
paceView,
tempSymbol,
signalItems,
}: {
locale: Locale;
paceView: TodayPaceView;
tempSymbol: string;
signalItems: FuturePaceSignalItem[];
}) {
return (
<section className="future-v2-card future-v2-pace-card future-v2-focus-card">
<div className="future-v2-card-head">
<h4 className="future-v2-card-title">
{locale === "en-US" ? "Current Pace" : "当前节奏"}
</h4>
<div className="future-v2-card-kicker">
{locale === "en-US"
? "Expected now vs airport anchor"
: "此刻应到 vs 机场锚点"}
</div>
</div>
<div className="future-v2-pace-head">
<span className="future-v2-pace-kicker">{paceView.kicker}</span>
<FutureSignalTag tone={paceView.biasTone}>{paceView.badge}</FutureSignalTag>
</div>
<div
className={clsx(
"future-v2-pace-delta",
paceView.biasTone === "cold" && "cold",
paceView.biasTone === "neutral" && "neutral",
paceView.biasTone === "warm" && "warm",
)}
>
{paceView.deltaText}
</div>
<div className="future-v2-pace-summary">{paceView.summary}</div>
<div className="future-v2-pace-meter">
<span className="future-v2-pace-meter-midline" />
<span
className={clsx(
"future-v2-pace-meter-fill",
paceView.biasTone === "cold" && "cold",
paceView.biasTone === "neutral" && "neutral",
paceView.biasTone === "warm" && "warm",
)}
style={
{
"--pace-left": `${paceView.meterLeft}%`,
"--pace-width": `${paceView.meterWidth}%`,
} as CSSProperties & {
"--pace-left": string;
"--pace-width": string;
}
}
/>
</div>
<div className="future-v2-mini-grid future-v2-mini-grid-tight">
<FutureMiniMetric
label={locale === "en-US" ? "Expected now" : "预期此刻"}
value={`${paceView.expectedNow.toFixed(1)}${tempSymbol}`}
/>
<FutureMiniMetric
label={paceView.observedLabel}
value={`${paceView.observedNow.toFixed(1)}${tempSymbol}`}
/>
<FutureMiniMetric
label={paceView.paceAdjustedLabel}
value={
paceView.paceAdjustedHigh != null
? `${paceView.paceAdjustedHigh.toFixed(1)}${tempSymbol}`
: "--"
}
/>
<FutureMiniMetric
label={locale === "en-US" ? "Peak window" : "峰值窗口"}
value={paceView.peakWindowText}
/>
</div>
{signalItems.length ? (
<div className="future-v2-pace-signal-grid">
{signalItems.map((item) => (
<div key={item.label} className="future-v2-pace-signal-card">
<div className="future-v2-signal-head">
<span>{item.label}</span>
<FutureSignalTag tone={item.tone}>{item.status}</FutureSignalTag>
</div>
<strong>{item.value}</strong>
<div className="future-v2-pace-signal-note">{item.note}</div>
</div>
))}
</div>
) : null}
</section>
);
}
export function FuturePaceLoadingCard({ locale }: { locale: Locale }) {
return (
<section className="future-v2-card future-v2-support-card">
<div className="future-v2-card-head">
<h4 className="future-v2-card-title">
{locale === "en-US" ? "Current Pace" : "当前节奏"}
</h4>
<div className="future-v2-card-kicker">
{locale === "en-US"
? "Backfilling intraday pace context"
: "正在补齐日内节奏上下文"}
</div>
</div>
<div className="future-trend-summary future-trend-summary-muted">
{locale === "en-US"
? "Expected-now pace, boundary risk, and airport-vs-network cues are loading in the background."
: "预期此刻节奏、边界风险和机场对比站网信号正在后台补齐。"}
</div>
</section>
);
}
function FutureMiniMetric({ label, value }: { label: string; value: string }) {
return (
<div className="future-v2-mini-item">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function FutureSignalTag({
tone,
children,
}: {
tone: string;
children: string;
}) {
return (
<em
className={clsx(
"future-v2-signal-tag",
(tone === "cold" || tone === "cyan") && "cyan",
(tone === "neutral" || tone === "blue") && "blue",
(tone === "warm" || tone === "amber") && "amber",
)}
>
{children}
</em>
);
}
@@ -0,0 +1,87 @@
"use client";
import { formatConfidenceLabel } from "./FutureForecastModal.utils";
export function FutureForecastTodayDecisionBrief({
anchorRuleText,
anchorSourceLabel,
baseCaseBucket,
confidence,
displayName,
downsideBucket,
gapToBaseText,
locale,
meteorologyHeadline,
nextObservationLabel,
nextObservationTime,
pathStatus,
settlementStationName,
upsideBucket,
}: {
anchorRuleText: string;
anchorSourceLabel: string;
baseCaseBucket: string;
confidence: string | null | undefined;
displayName: string;
downsideBucket: string | null | undefined;
gapToBaseText: string;
locale: string;
meteorologyHeadline: string;
nextObservationLabel: string;
nextObservationTime: string;
pathStatus: string;
settlementStationName: string;
upsideBucket: string | null | undefined;
}) {
return (
<section className="future-v2-meteorology-brief">
<div className="future-v2-meteorology-copy">
<div className="future-v2-anchor-row">
<div className="modal-section-kicker">
{locale === "en-US" ? "Professional meteorology read" : "专业气象判断"}
</div>
<span className="future-v2-anchor-source">{anchorSourceLabel}</span>
</div>
<h3>{meteorologyHeadline}</h3>
<p className="future-v2-anchor-rule">{anchorRuleText}</p>
<div className="future-v2-meteorology-meta">
<span>
{locale === "en-US" ? "Confidence" : "置信度"} ·{" "}
{formatConfidenceLabel(confidence, locale)}
</span>
<span>
{locale === "en-US" ? "Path state" : "路径状态"} · {pathStatus}
</span>
<span>
{nextObservationLabel} · {nextObservationTime}
</span>
</div>
</div>
<div className="future-v2-decision-rail" aria-label={displayName}>
<div className="future-v2-decision-anchor">
<span>{locale === "en-US" ? "Anchor" : "锚点"}</span>
<strong>{settlementStationName}</strong>
<small>{anchorSourceLabel}</small>
</div>
<div className="future-v2-decision-grid">
<div>
<span>{locale === "en-US" ? "Base" : "基准"}</span>
<strong>{baseCaseBucket || "--"}</strong>
</div>
<div>
<span>{locale === "en-US" ? "Upside" : "上修"}</span>
<strong>{upsideBucket || "--"}</strong>
</div>
<div>
<span>{locale === "en-US" ? "Downside" : "下修"}</span>
<strong>{downsideBucket || "--"}</strong>
</div>
<div>
<span>{locale === "en-US" ? "Gap to base" : "距基准还差"}</span>
<strong>{gapToBaseText}</strong>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,129 @@
"use client";
import clsx from "clsx";
import {
formatSignalDirection,
formatSignalStrength,
localizedText,
signalTone,
} from "./FutureForecastModal.utils";
type MeteorologySignal = {
direction?: string | null;
label?: string | null;
label_en?: string | null;
strength?: string | null;
summary?: string | null;
summary_en?: string | null;
};
export function FutureForecastTodayEvidenceGrid({
airportMetarAnchor,
confirmationRules,
invalidationRules,
locale,
meteorologySignals,
modelSummary,
}: {
airportMetarAnchor: boolean;
confirmationRules: string[];
invalidationRules: string[];
locale: string;
meteorologySignals: MeteorologySignal[];
modelSummary: string;
}) {
const fallbackInvalidationRule =
locale === "en-US"
? "If observations stop tracking the expected curve, wait for the next refresh."
: "若实测不再贴近预期曲线,等待下一次刷新确认。";
const fallbackConfirmationRule =
locale === "en-US"
? airportMetarAnchor
? "Keep watching the next anchor METAR report."
: "Keep watching the next official anchor observation."
: airportMetarAnchor
? "继续观察下一次锚点 METAR 报文。"
: "继续观察下一次官方锚点观测。";
return (
<div className="future-v2-meteorology-grid">
<section className="future-modal-section future-v2-evidence-panel">
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Evidence chain" : "气象证据链"}
</div>
<h3>{locale === "en-US" ? "Signal Contributions" : "信号贡献"}</h3>
</div>
<div className="future-v2-evidence-list">
{meteorologySignals.length > 0 ? (
meteorologySignals.map((signal, index) => (
<div
key={`${signal.label || "signal"}-${index}`}
className={clsx("future-v2-evidence-row", signalTone(signal))}
>
<div className="future-v2-evidence-head">
<strong>
{localizedText(locale, signal.label, signal.label_en) || "--"}
</strong>
<span>
{formatSignalDirection(signal.direction, locale)} ·{" "}
{formatSignalStrength(signal.strength, locale)}
</span>
</div>
<p>
{localizedText(locale, signal.summary, signal.summary_en) ||
"--"}
</p>
</div>
))
) : (
<div className="future-text-block">
{locale === "en-US"
? "Meteorology signals are still loading."
: "气象信号仍在加载。"}
</div>
)}
</div>
</section>
<section className="future-modal-section future-v2-rule-panel">
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Failure modes" : "失效条件"}
</div>
<h3>
{locale === "en-US" ? "What Downgrades the Read" : "什么会让判断降级"}
</h3>
</div>
<ul className="future-v2-rule-list">
{(invalidationRules.length > 0
? invalidationRules
: [fallbackInvalidationRule]
).map((rule, index) => (
<li key={`${rule}-${index}`}>{rule}</li>
))}
</ul>
</section>
<section className="future-modal-section future-v2-rule-panel">
<div className="modal-section-heading">
<div className="modal-section-kicker">
{locale === "en-US" ? "Confirmation" : "确认条件"}
</div>
<h3>
{locale === "en-US" ? "What Confirms the Path" : "什么会确认主路径"}
</h3>
</div>
<ul className="future-v2-rule-list">
{(confirmationRules.length > 0
? confirmationRules
: [fallbackConfirmationRule]
).map((rule, index) => (
<li key={`${rule}-${index}`}>{rule}</li>
))}
</ul>
<div className="future-v2-model-note">{modelSummary}</div>
</section>
</div>
);
}
@@ -0,0 +1,163 @@
"use client";
import type { ChartConfiguration } from "chart.js";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getHistorySummary } from "@/lib/dashboard-utils";
export function HistoryChart() {
const store = useDashboardStore();
const { locale } = useI18n();
const { data } = useHistoryData();
const isNoaaSettlement =
store.selectedDetail?.current?.settlement_source === "noaa" ||
store.selectedDetail?.current?.settlement_source_label === "NOAA";
const noaaStationCode = String(
store.selectedDetail?.current?.station_code ||
store.selectedDetail?.risk?.icao ||
"NOAA",
)
.trim()
.toUpperCase();
const summary = useMemo(
() => getHistorySummary(data, store.selectedDetail?.local_date),
[data, store.selectedDetail?.local_date],
);
const hasMgm =
store.selectedCity === "ankara" &&
summary.mgmSeriesComplete &&
summary.mgms.some((value) => value != null);
const hasBestBaseline =
Boolean(summary.bestModelName) &&
summary.bestModelName !== "MGM" &&
summary.bestModelSeries.some((value) => value != null);
const canvasRef = useChart(() => {
const datasets: NonNullable<
ChartConfiguration<"line">["data"]
>["datasets"] = [
{
backgroundColor: "rgba(248, 113, 113, 0.1)",
borderColor: "#f87171",
borderWidth: 2,
data: summary.actuals,
label: isNoaaSettlement
? locale === "en-US"
? `NOAA Settled High (${noaaStationCode})`
: `NOAA 结算最高温 (${noaaStationCode})`
: locale === "en-US"
? "Observed High"
: "实测最高温",
pointBackgroundColor: "#f87171",
pointBorderColor: "#fff",
pointHoverRadius: 7,
pointRadius: 5,
tension: 0.2,
},
{
backgroundColor: "transparent",
borderColor: "#34d399",
borderDash: [5, 4],
borderWidth: 2,
data: summary.debs,
label: locale === "en-US" ? "DEB Fusion" : "DEB 融合",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
},
];
if (hasMgm) {
datasets.push({
backgroundColor: "transparent",
borderColor: "#fb923c",
borderWidth: 2,
data: summary.mgms,
label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
});
}
if (hasBestBaseline) {
datasets.push({
backgroundColor: "transparent",
borderColor: "#60a5fa",
borderDash: [4, 3],
borderWidth: 2,
data: summary.bestModelSeries,
label:
locale === "en-US"
? `Best Baseline (${summary.bestModelName})`
: `最佳单模型 (${summary.bestModelName})`,
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
});
}
return {
data: {
datasets,
labels: summary.dates,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
boxHeight: 12,
boxWidth: 34,
color: "#94a3b8",
font: { family: "Inter", size: 14 },
padding: 18,
},
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.9)",
borderColor: "rgba(255, 255, 255, 0.1)",
borderWidth: 1,
bodyFont: { family: "Inter", size: 13 },
titleFont: { family: "Inter", size: 13, weight: 600 },
callbacks: {
label: (ctx) =>
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
},
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [hasBestBaseline, hasMgm, isNoaaSettlement, noaaStationCode, summary, locale]);
if (!summary.recentData.length) return null;
return (
<div className="history-chart-wrapper">
<canvas ref={canvasRef} />
</div>
);
}
@@ -0,0 +1,530 @@
/* History modal styles moved out of Dashboard.module.css. */
.root :global(.history-btn) {
background: rgba(34, 211, 238, 0.1);
color: var(--accent-cyan);
border: 1px solid rgba(34, 211, 238, 0.3);
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-left: 8px;
}
.root :global(.history-btn:hover) {
background: rgba(34, 211, 238, 0.2);
border-color: var(--accent-cyan);
}
.root :global(.history-btn:disabled) {
opacity: 0.45;
cursor: not-allowed;
}
.root :global(.modal-content.history-modal) {
width: min(96vw, 1180px);
max-width: 1180px;
max-height: calc(100vh - 32px);
}
.root :global(.history-modal .modal-body) {
padding: 24px 28px 28px;
}
.root :global(.history-modal .history-stats) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.root :global(.history-modal .h-stat-card) {
min-width: 0;
padding: 16px 18px;
border-radius: 12px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.035) 0%,
rgba(255, 255, 255, 0.02) 100%
);
}
.root :global(.history-modal .h-stat-card .label) {
font-size: 12px;
margin-bottom: 8px;
}
.root :global(.history-modal .h-stat-card .val) {
font-size: 32px;
line-height: 1.1;
letter-spacing: -0.02em;
}
.root :global(.history-modal .history-chart-wrapper) {
height: 420px;
border: 1px solid var(--border-subtle);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 12px 14px 8px;
}
.root :global(.history-modal .history-chart-wrapper canvas) {
width: 100% !important;
height: 100% !important;
}
.root :global(.history-modal .history-chart-loading) {
background:
linear-gradient(
90deg,
rgba(255, 255, 255, 0.025),
rgba(96, 165, 250, 0.08),
rgba(255, 255, 255, 0.025)
);
background-size: 220% 100%;
animation: history-chart-loading 1.4s ease-in-out infinite;
}
@keyframes history-chart-loading {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
.root :global(.history-modal .history-modal-loading) {
display: flex;
align-items: center;
justify-content: center;
min-height: 420px;
}
.root :global(.history-modal .history-fetch-loading) {
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
width: min(100%, 420px);
padding: 24px 26px;
border-radius: 24px;
border: 1px solid rgba(96, 165, 250, 0.16);
background: linear-gradient(
180deg,
rgba(10, 18, 36, 0.72) 0%,
rgba(8, 14, 30, 0.5) 100%
);
box-shadow:
0 18px 48px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
.root :global(.history-modal .history-fetch-scan) {
position: relative;
width: 84px;
height: 84px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(14, 165, 233, 0.08) 0%,
rgba(14, 165, 233, 0.02) 62%,
transparent 100%
);
}
.root :global(.history-modal .history-fetch-ring) {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(56, 189, 248, 0.16);
}
.root :global(.history-modal .history-fetch-ring-1) {
transform: scale(0.7);
}
.root :global(.history-modal .history-fetch-ring-2) {
transform: scale(0.92);
}
.root :global(.history-modal .history-fetch-sweep) {
position: absolute;
inset: 5px;
border-radius: 50%;
background: conic-gradient(
from 120deg,
rgba(34, 211, 238, 0) 0deg,
rgba(34, 211, 238, 0.04) 220deg,
rgba(56, 189, 248, 0.78) 300deg,
rgba(253, 224, 71, 0.32) 332deg,
rgba(34, 211, 238, 0) 360deg
);
animation: history-fetch-spin 2.2s linear infinite;
}
.root :global(.history-modal .history-fetch-core) {
position: absolute;
top: 50%;
left: 50%;
width: 12px;
height: 12px;
border-radius: 50%;
transform: translate(-50%, -50%);
background: rgba(103, 232, 249, 0.95);
box-shadow: 0 0 18px rgba(34, 211, 238, 0.42);
}
.root :global(.history-modal .history-fetch-bars) {
display: flex;
align-items: end;
gap: 7px;
height: 22px;
}
.root :global(.history-modal .history-fetch-bar) {
width: 8px;
border-radius: 999px;
background: linear-gradient(
180deg,
rgba(250, 204, 21, 0.18) 0%,
rgba(251, 146, 60, 0.9) 100%
);
animation: history-fetch-pulse 1.6s ease-in-out infinite;
}
.root :global(.history-modal .history-fetch-bar-1) {
height: 10px;
}
.root :global(.history-modal .history-fetch-bar-2) {
height: 18px;
animation-delay: 0.18s;
}
.root :global(.history-modal .history-fetch-bar-3) {
height: 14px;
animation-delay: 0.34s;
}
.root :global(.history-modal .history-fetch-bar-4) {
height: 20px;
animation-delay: 0.5s;
}
.root :global(.history-modal .history-fetch-lines) {
position: relative;
width: 180px;
height: 22px;
overflow: hidden;
}
.root :global(.history-modal .history-fetch-line) {
position: absolute;
left: -26%;
height: 1px;
border-radius: 999px;
background: linear-gradient(
90deg,
rgba(34, 211, 238, 0) 0%,
rgba(125, 211, 252, 0.72) 48%,
rgba(34, 211, 238, 0) 100%
);
animation: history-fetch-flow 2.4s linear infinite;
}
.root :global(.history-modal .history-fetch-line-1) {
top: 4px;
width: 122px;
}
.root :global(.history-modal .history-fetch-line-2) {
top: 11px;
width: 168px;
animation-delay: -0.8s;
}
.root :global(.history-modal .history-fetch-line-3) {
top: 18px;
width: 138px;
animation-delay: -1.4s;
}
.root :global(.history-modal .history-fetch-copy) {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.root :global(.history-modal .history-fetch-copy strong) {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(103, 232, 249, 0.94);
}
.root :global(.history-modal .history-fetch-copy span) {
max-width: 300px;
font-size: 12px;
line-height: 1.5;
color: rgba(203, 213, 225, 0.82);
}
@keyframes history-fetch-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes history-fetch-pulse {
0%,
100% {
transform: scaleY(0.86);
opacity: 0.8;
}
50% {
transform: scaleY(1.08);
opacity: 1;
}
}
@keyframes history-fetch-flow {
from {
transform: translateX(0);
}
to {
transform: translateX(150%);
}
}
.root :global(.history-modal .history-peak-reference) {
margin-top: 18px;
padding: 14px 16px;
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 16px;
background: rgba(15, 23, 42, 0.42);
}
.root :global(.history-modal .history-model-reference) {
margin-top: 18px;
padding: 14px 16px;
border: 1px solid rgba(34, 211, 238, 0.18);
border-radius: 16px;
background: rgba(8, 47, 73, 0.2);
}
.root :global(.history-modal .history-peak-reference-title) {
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
margin-bottom: 10px;
}
.root :global(.history-modal .history-model-reference-scroll),
.root :global(.history-modal .history-peak-reference-scroll) {
display: grid;
gap: 8px;
max-height: 360px;
overflow-y: auto;
padding-right: 6px;
scrollbar-width: thin;
}
.root :global(.history-modal .history-model-reference-scroll) {
scrollbar-color: rgba(34, 211, 238, 0.5) rgba(15, 23, 42, 0.32);
}
.root :global(.history-modal .history-peak-reference-scroll) {
scrollbar-color: rgba(96, 165, 250, 0.5) rgba(15, 23, 42, 0.32);
}
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar) {
width: 8px;
}
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar-track) {
background: rgba(15, 23, 42, 0.32);
border-radius: 999px;
}
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar-thumb) {
background: linear-gradient(
180deg,
rgba(56, 189, 248, 0.72) 0%,
rgba(96, 165, 250, 0.52) 100%
);
border-radius: 999px;
}
.root :global(.history-modal .history-peak-reference-row),
.root :global(.history-modal .history-model-reference-row) {
display: grid;
grid-template-columns: minmax(72px, 88px) 1fr;
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
}
.root :global(.history-modal .history-peak-reference-date) {
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
}
.root :global(.history-modal .history-model-reference-body) {
display: grid;
gap: 8px;
min-width: 0;
}
.root :global(.history-modal .history-model-reference-summary) {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
color: var(--text-secondary);
font-size: 12px;
}
.root :global(.history-modal .history-model-reference-summary strong) {
color: var(--text-primary);
}
.root :global(.history-modal .history-model-reference-models) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.root :global(.history-modal .history-model-reference-model) {
display: grid;
gap: 3px;
min-width: 0;
padding: 8px 10px;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 8px;
background: rgba(15, 23, 42, 0.38);
color: var(--text-secondary);
font-size: 11px;
}
.root :global(.history-modal .history-model-name) {
overflow: hidden;
color: var(--accent-cyan);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.root :global(.history-modal .history-model-error) {
color: var(--text-muted);
}
.root :global(.history-modal .history-peak-reference-meta) {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.7;
}
@media (max-width: 980px) {
.root :global(.history-modal .modal-body) {
padding: 18px;
}
.root :global(.history-modal .history-stats) {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.root :global(.history-modal .history-chart-wrapper) {
height: 360px;
}
.root :global(.history-modal .history-modal-loading) {
min-height: 340px;
}
.root :global(.history-modal .history-fetch-loading) {
width: min(100%, 360px);
}
.root :global(.history-modal .history-peak-reference-scroll),
.root :global(.history-modal .history-model-reference-scroll) {
max-height: 320px;
}
.root :global(.history-modal .history-model-reference-models) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.root :global(.modal-content.history-modal) {
width: min(100vw - 12px, 100%);
max-width: none;
}
.root :global(.history-modal .history-stats) {
grid-template-columns: 1fr;
}
.root :global(.history-modal .h-stat-card .val) {
font-size: 28px;
}
.root :global(.history-modal .history-chart-wrapper) {
height: 300px;
padding: 8px 10px 6px;
}
.root :global(.history-modal .history-modal-loading) {
min-height: 280px;
}
.root :global(.history-modal .history-fetch-loading) {
width: 100%;
padding: 18px 16px;
}
.root :global(.history-modal .history-fetch-lines) {
width: 150px;
}
.root :global(.history-modal .history-fetch-scan) {
width: 72px;
height: 72px;
}
.root :global(.history-modal .history-peak-reference),
.root :global(.history-modal .history-model-reference) {
padding: 12px;
}
.root :global(.history-modal .history-peak-reference-scroll),
.root :global(.history-modal .history-model-reference-scroll) {
max-height: 260px;
padding-right: 2px;
}
.root :global(.history-modal .history-peak-reference-row),
.root :global(.history-modal .history-model-reference-row) {
grid-template-columns: 1fr;
gap: 6px;
}
.root :global(.history-modal .history-model-reference-models) {
grid-template-columns: 1fr;
}
}
+11 -156
View File
@@ -1,167 +1,22 @@
"use client";
import type { ChartConfiguration } from "chart.js";
import dynamic from "next/dynamic";
import { useMemo } from "react";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import { getHistorySummary } from "@/lib/dashboard-utils";
function HistoryChart() {
const store = useDashboardStore();
const { locale } = useI18n();
const { data } = useHistoryData();
const isNoaaSettlement =
store.selectedDetail?.current?.settlement_source === "noaa" ||
store.selectedDetail?.current?.settlement_source_label === "NOAA";
const noaaStationCode = String(
store.selectedDetail?.current?.station_code ||
store.selectedDetail?.risk?.icao ||
"NOAA",
)
.trim()
.toUpperCase();
const summary = useMemo(
() => getHistorySummary(data, store.selectedDetail?.local_date),
[data, store.selectedDetail?.local_date],
);
const hasMgm =
store.selectedCity === "ankara" &&
summary.mgmSeriesComplete &&
summary.mgms.some((value) => value != null);
const hasBestBaseline =
Boolean(summary.bestModelName) &&
summary.bestModelName !== "MGM" &&
summary.bestModelSeries.some((value) => value != null);
const canvasRef = useChart(() => {
const datasets: NonNullable<
ChartConfiguration<"line">["data"]
>["datasets"] = [
{
backgroundColor: "rgba(248, 113, 113, 0.1)",
borderColor: "#f87171",
borderWidth: 2,
data: summary.actuals,
label: isNoaaSettlement
? locale === "en-US"
? `NOAA Settled High (${noaaStationCode})`
: `NOAA 结算最高温 (${noaaStationCode})`
: locale === "en-US"
? "Observed High"
: "实测最高温",
pointBackgroundColor: "#f87171",
pointBorderColor: "#fff",
pointHoverRadius: 7,
pointRadius: 5,
tension: 0.2,
},
{
backgroundColor: "transparent",
borderColor: "#34d399",
borderDash: [5, 4],
borderWidth: 2,
data: summary.debs,
label: locale === "en-US" ? "DEB Fusion" : "DEB 融合",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
},
];
if (hasMgm) {
datasets.push({
backgroundColor: "transparent",
borderColor: "#fb923c",
borderWidth: 2,
data: summary.mgms,
label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报",
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
});
}
if (hasBestBaseline) {
datasets.push({
backgroundColor: "transparent",
borderColor: "#60a5fa",
borderDash: [4, 3],
borderWidth: 2,
data: summary.bestModelSeries,
label:
locale === "en-US"
? `Best Baseline (${summary.bestModelName})`
: `最佳单模型 (${summary.bestModelName})`,
pointHoverRadius: 6,
pointRadius: 4,
tension: 0.2,
});
}
return {
data: {
datasets,
labels: summary.dates,
},
options: {
interaction: { intersect: false, mode: "index" },
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
boxHeight: 12,
boxWidth: 34,
color: "#94a3b8",
font: { family: "Inter", size: 14 },
padding: 18,
},
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.9)",
borderColor: "rgba(255, 255, 255, 0.1)",
borderWidth: 1,
bodyFont: { family: "Inter", size: 13 },
titleFont: { family: "Inter", size: 13, weight: 600 },
callbacks: {
label: (ctx) =>
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
},
},
},
responsive: true,
scales: {
x: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
y: {
grid: { color: "rgba(255,255,255,0.04)" },
ticks: {
color: "#64748b",
font: { family: "Inter", size: 12 },
padding: 8,
},
},
},
},
type: "line",
} satisfies ChartConfiguration<"line">;
}, [hasBestBaseline, hasMgm, isNoaaSettlement, noaaStationCode, summary, locale]);
if (!summary.recentData.length) return null;
return (
<div className="history-chart-wrapper">
<canvas ref={canvasRef} />
</div>
);
}
const HistoryChart = dynamic(
() =>
import("@/components/dashboard/HistoryChart").then(
(module) => module.HistoryChart,
),
{
loading: () => <div className="history-chart-wrapper history-chart-loading" />,
ssr: false,
},
);
export function HistoryModal() {
const store = useDashboardStore();
@@ -0,0 +1,283 @@
"use client";
import clsx from "clsx";
import { useI18n } from "@/hooks/useI18n";
import { CityDetail } from "@/lib/dashboard-types";
import { getModelView } from "@/lib/model-utils";
function EmptyState({ text }: { text: string }) {
return (
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>
);
}
type ModelMetadata = NonNullable<
NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"]
>["model_metadata"];
function getModelGroupMeta(
name: string,
metadata: ModelMetadata,
locale: string,
) {
const meta = metadata?.[name] || {};
const tier = String(meta.tier || "").toLowerCase();
const upperName = String(name || "").toUpperCase();
if (tier.includes("aifs") || upperName.includes("AIFS")) {
return {
key: "aifs",
label: locale === "en-US" ? "AIFS model" : "AIFS 模型",
order: 1,
tone: "blue",
};
}
if (
tier.includes("europe") ||
upperName.includes("ICON-EU") ||
upperName.includes("ICON-D2")
) {
return {
key: "europe",
label: locale === "en-US" ? "Europe high-resolution" : "欧洲高分辨率",
order: 2,
tone: "cyan",
};
}
if (
tier.includes("north_america") ||
upperName === "RDPS" ||
upperName === "HRDPS"
) {
return {
key: "north-america",
label:
locale === "en-US" ? "North America high-resolution" : "北美高分辨率",
order: 3,
tone: "amber",
};
}
return {
key: "global",
label: locale === "en-US" ? "Global baseline" : "全球基准",
order: 0,
tone: "neutral",
};
}
function formatModelMetaLine(
name: string,
metadata: ModelMetadata,
locale: string,
) {
const meta = metadata?.[name] || {};
const provider = String(meta.provider || "").trim();
const model = String(meta.model || "").trim();
const horizon = String(meta.horizon || "").trim();
const resolution = Number(meta.resolution_km);
const parts = [
provider,
model && model !== name ? model : "",
Number.isFinite(resolution)
? `${resolution}${locale === "en-US" ? " km" : " 公里"}`
: "",
horizon,
].filter(Boolean);
return parts.join(" · ");
}
export function ModelForecast({
detail,
hideTitle = false,
targetDate,
}: {
detail: CityDetail;
hideTitle?: boolean;
targetDate?: string | null;
}) {
const { locale, t } = useI18n();
const view = getModelView(detail, targetDate);
const modelsMap = { ...view.models };
const modelMetadata =
detail.source_forecasts?.open_meteo_multi_model?.model_metadata || {};
const modelEntries = Object.entries(modelsMap).filter(
([, value]) =>
value !== null && value !== undefined && Number.isFinite(Number(value)),
);
const hasSingleModelOnly = modelEntries.length === 1;
// 如果没有任何数值,给出提示
if (modelEntries.length === 0) {
return (
<section className="models-section">
{!hideTitle && <h3>{t("section.models")}</h3>}
<div className="model-bars">
<EmptyState text={t("section.noModels")} />
</div>
</section>
);
}
const numericValues = modelEntries.map(([, value]) => Number(value));
const comparisonValues =
view.deb != null ? [...numericValues, Number(view.deb)] : numericValues;
const minValue = comparisonValues.length
? Math.min(...comparisonValues) - 1
: 0;
const maxValue = comparisonValues.length
? Math.max(...comparisonValues) + 1
: 1;
const range = Math.max(maxValue - minValue, 1);
const sortedEntries = modelEntries.sort(
(a, b) => Number(b[1] || 0) - Number(a[1] || 0),
);
const groupedEntries = sortedEntries
.reduce(
(acc, [name, value]) => {
const group = getModelGroupMeta(name, modelMetadata, locale);
const existing = acc.find((item) => item.key === group.key);
const entry = {
metaLine: formatModelMetaLine(name, modelMetadata, locale),
name,
value: Number(value),
};
if (existing) {
existing.entries.push(entry);
} else {
acc.push({ ...group, entries: [entry] });
}
return acc;
},
[] as Array<{
entries: Array<{ metaLine: string; name: string; value: number }>;
key: string;
label: string;
order: number;
tone: string;
}>,
)
.sort((a, b) => a.order - b.order);
const spread =
numericValues.length >= 2
? Math.max(...numericValues) - Math.min(...numericValues)
: null;
const metadataSource =
detail.source_forecasts?.open_meteo_multi_model?.provider === "open-meteo"
? "Open-Meteo"
: null;
return (
<section className="models-section">
{!hideTitle && <h3>{t("section.models")}</h3>}
<div className="model-bars">
<div className="model-stack-summary">
<span>
{locale === "en-US" ? "Available models" : "可用模型"} ·{" "}
<strong>{modelEntries.length}</strong>
</span>
<span>
{locale === "en-US" ? "Spread" : "分歧"} ·{" "}
<strong>
{spread != null
? `${spread.toFixed(1)}${detail.temp_symbol}`
: "--"}
</strong>
</span>
{metadataSource && (
<span>
{locale === "en-US" ? "API" : "接口"} ·{" "}
<strong>{metadataSource}</strong>
</span>
)}
</div>
{hasSingleModelOnly && (
<div
style={{
color: "var(--text-secondary)",
fontSize: "11px",
marginBottom: "8px",
}}
>
{locale === "en-US"
? "Single-model fallback: waiting for the rest of the model cluster."
: "当前处于单模型回退,其他模型结果还没回传。"}
</div>
)}
{groupedEntries.map((group) => (
<div
key={group.key}
className={clsx("model-group", `model-group-${group.tone}`)}
>
<div className="model-group-heading">
<span>{group.label}</span>
<em>{group.entries.length}</em>
</div>
{group.entries.map(({ metaLine, name, value }) => {
const width = ((value - minValue) / range) * 100;
const debLine =
view.deb != null
? ((Number(view.deb) - minValue) / range) * 100
: null;
return (
<div key={name} className="model-row model-row-rich">
<div className="model-name" title={metaLine || name}>
<strong>{name}</strong>
{metaLine && <span>{metaLine}</span>}
</div>
<div className="model-bar-track">
<div
className="model-bar-fill"
style={{ width: `${width}%` }}
/>
<span className="model-bar-value">
{value}
{detail.temp_symbol}
</span>
{debLine != null && (
<div
className="model-deb-line"
style={{ left: `${debLine}%` }}
/>
)}
</div>
</div>
);
})}
</div>
))}
{view.deb != null && (
<div
className="model-row"
style={{
borderTop: "1px solid rgba(255,255,255,0.06)",
marginTop: "6px",
paddingTop: "6px",
}}
>
<div
className="model-name"
style={{ color: "var(--accent-cyan)", fontWeight: 700 }}
>
DEB
</div>
<div className="model-bar-track">
<div
className="model-bar-fill deb"
style={{
width: `${((Number(view.deb) - minValue) / range) * 100}%`,
}}
/>
<span className="model-bar-value deb">
{Number(view.deb)}
{detail.temp_symbol}
</span>
</div>
</div>
)}
</div>
</section>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
export { getWindowPhaseMeta, type PhaseMeta } from "./opportunity-window-phase";
export { getLocalizedRowText } from "./opportunity-copy";
export {
getAiMeta,
getExclusionReasons,
getOpportunityStrength,
getRecommendationReasons,
getRiskHints,
getShortAiConclusion,
} from "./opportunity-ai-meta";
export {
getDebDistanceSummary,
getMetarConflictSummary,
getModelSupportSummary,
} from "./opportunity-evidence-summary";
export type { V4CityForecast, V4TradeDecision } from "./opportunity-v4-types";
export {
getForecastContractFit,
getForecastRangeLabel,
getPaceDecisionTail,
getPaceDeviationRead,
getPaceSignalLabel,
getV4CityForecast,
median,
} from "./opportunity-v4-forecast";
export {
getForecastFitMeta,
getThresholdDecision,
getV4DecisionLabel,
getV4TradeDecision,
} from "./opportunity-v4-decision";
export {
getDecisionReasonItems,
getForecastRiskItems,
} from "./opportunity-v4-risk";
export {
getDetailForRow,
getDetailViewDate,
normalizeLookupKey,
} from "./opportunity-detail";
export {
bucketMatchesRow,
buildOpportunityGroups,
getBucketDisplayLabel,
getBucketText,
getDetailBucketEventProbability,
type OpportunityGroup,
} from "./opportunity-groups";
export {
formatModelClusterRange,
formatModelSources,
getModelSourceSummary,
} from "./opportunity-model-summary";
export {
formatAction,
formatMinuteSpan,
formatPercent,
formatQuoteCents,
formatTemperatureDelta,
formatThreshold,
formatTradeSide,
formatWindowMinutes,
normalizeProbability,
} from "./opportunity-format";
export {
extractNumbers,
getTargetRange,
normalizeBucketLabel,
} from "./opportunity-target";
export {
formatPeakWindowTiming,
getMetarGate,
getMetarObservationContext,
getObservationSortMinutes,
firstNonEmptyPoints,
normalizeObservationPoints,
type ObservationPoint,
} from "./opportunity-observation";
export {
decodeMetarWeatherToken,
decodeRawMetarCloud,
decodeRawMetarVisibility,
decodeRawMetarWeather,
formatAirportReportRead,
formatAirportWeatherRead,
getAirportWeatherInputs,
} from "./opportunity-airport-read";
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,956 @@
"use client";
import clsx from "clsx";
import { useMemo } from "react";
import { useI18n } from "@/hooks/useI18n";
import {
CityDetail,
MarketScan,
MarketTopBucket,
ProbabilityBucket,
} from "@/lib/dashboard-types";
import { getModelView, getProbabilityView } from "@/lib/model-utils";
function EmptyState({ text }: { text: string }) {
return (
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>
);
}
function toPercent(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
return `${(numeric * 100).toFixed(1)}%`;
}
function toPriceCents(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
const normalized = numeric > 1 ? numeric / 100 : numeric;
const cents = normalized * 100;
const rounded = Math.round(cents * 10) / 10;
const text = Number.isInteger(rounded)
? String(rounded.toFixed(0))
: String(rounded);
return `${text}c`;
}
function parseTempFromText(value: unknown) {
const text = String(value || "");
const match = text.match(/(-?\d+(?:\.\d+)?)/);
if (!match) return null;
const numeric = Number(match[1]);
return Number.isFinite(numeric) ? numeric : null;
}
function getBucketTemp(bucket: ProbabilityBucket) {
if (bucket.value != null) {
const byValue = Number(bucket.value);
if (Number.isFinite(byValue)) return byValue;
}
return parseTempFromText(bucket.label || bucket.bucket || bucket.range);
}
function getMarketYesPrice(scan?: MarketScan | null) {
if (scan?.market_price != null) {
const preferred = Number(scan.market_price);
if (Number.isFinite(preferred)) return preferred;
}
if (scan?.yes_token?.implied_probability != null) {
const implied = Number(scan.yes_token.implied_probability);
if (Number.isFinite(implied)) return implied;
}
return null;
}
function isFahrenheitSymbol(symbol?: string | null) {
return String(symbol || "")
.toUpperCase()
.includes("F");
}
function displayTempToMarketCelsius(
value: number | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
if (value == null || !Number.isFinite(value)) return null;
if (isFahrenheitSymbol(detail.temp_symbol)) {
return ((value - 32) * 5) / 9;
}
return value;
}
function formatBucketDisplayLabel(
bucket: ProbabilityBucket,
detail: Pick<CityDetail, "temp_symbol">,
) {
let bucketLabel = bucket.label || `${bucket.value}${detail.temp_symbol}`;
if (!bucketLabel) return "";
let str = String(bucketLabel).toUpperCase().replace(/\s+/g, "");
const symbol = detail.temp_symbol || "°C";
if (isFahrenheitSymbol(symbol)) {
str = str.replace(/℃/g, "°F").replace(/°C/g, "°F");
} else {
str = str.replace(/℃/g, "°C").replace(/°F/g, "°C");
}
str = str.replace(/°?C($|\+|-)/g, "°C$1");
str = str.replace(/°?F($|\+|-)/g, "°F$1");
if (!/[°℃][CF]/.test(str) && /[0-9]/.test(str)) {
str += symbol;
}
return str;
}
function getMarketBucketUnit(bucket?: MarketTopBucket | null) {
return String(bucket?.unit || "").toUpperCase();
}
function isMarketBucketAbove(bucket?: MarketTopBucket | null) {
const text =
`${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
.toLowerCase()
.replace(/\s+/g, "");
return (
text.includes("+") ||
text.includes("orhigher") ||
text.includes("or-higher")
);
}
function isMarketBucketBelow(bucket?: MarketTopBucket | null) {
const text =
`${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
.toLowerCase()
.replace(/\s+/g, "");
return (
text.includes("<=") || text.includes("orlower") || text.includes("or-lower")
);
}
function findMarketBucketForDisplayTemp(
buckets: MarketTopBucket[],
displayTemp: number | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
if (displayTemp == null || !Number.isFinite(displayTemp)) return null;
let best: MarketTopBucket | null = null;
let bestDelta = Number.POSITIVE_INFINITY;
for (const bucket of buckets) {
const bucketUnit = String(bucket.unit || "").toUpperCase();
const compareTemp =
bucketUnit === "F"
? displayTemp
: displayTempToMarketCelsius(displayTemp, detail);
if (compareTemp == null) continue;
const lower = bucket.lower != null ? Number(bucket.lower) : null;
const upper = bucket.upper != null ? Number(bucket.upper) : null;
if (
lower != null &&
upper != null &&
Number.isFinite(lower) &&
Number.isFinite(upper) &&
compareTemp >= lower - 0.01 &&
compareTemp <= upper + 0.01
) {
return bucket;
}
const rawTemp = bucket.temp ?? bucket.value ?? null;
if (rawTemp == null) continue;
const candidateTemp = Number(rawTemp);
if (!Number.isFinite(candidateTemp)) continue;
const delta = Math.abs(candidateTemp - compareTemp);
if (delta < bestDelta) {
best = bucket;
bestDelta = delta;
}
}
const tolerance = isFahrenheitSymbol(detail.temp_symbol) ? 0.56 : 0.26;
return best && bestDelta <= tolerance ? best : null;
}
function marketBucketContainsDisplayTemp(
bucket: MarketTopBucket | null,
displayTemp: number | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
if (!bucket || displayTemp == null || !Number.isFinite(displayTemp))
return false;
const bucketUnit = getMarketBucketUnit(bucket);
const compareTemp =
bucketUnit === "F"
? displayTemp
: displayTempToMarketCelsius(displayTemp, detail);
if (compareTemp == null) return false;
const lower = bucket.lower != null ? Number(bucket.lower) : null;
const upper = bucket.upper != null ? Number(bucket.upper) : null;
if (lower != null && !Number.isFinite(lower)) return false;
if (upper != null && !Number.isFinite(upper)) return false;
if (lower != null && upper != null) {
return compareTemp >= lower - 0.01 && compareTemp <= upper + 0.01;
}
if (lower != null && isMarketBucketAbove(bucket)) {
return compareTemp >= lower - 0.01;
}
if (lower != null && isMarketBucketBelow(bucket)) {
return compareTemp <= lower + 0.01;
}
const reference = bucket.temp ?? bucket.value ?? lower;
const numeric = reference != null ? Number(reference) : null;
if (numeric == null || !Number.isFinite(numeric)) return false;
const tolerance = bucketUnit === "F" ? 0.56 : 0.26;
return Math.abs(compareTemp - numeric) <= tolerance;
}
function getAggregatedModelProbabilityForMarketBucket(
probabilities: ProbabilityBucket[],
bucket: MarketTopBucket | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
if (!bucket) return null;
let total = 0;
let matched = 0;
for (const probabilityBucket of probabilities) {
const temp = getBucketTemp(probabilityBucket);
if (!marketBucketContainsDisplayTemp(bucket, temp, detail)) continue;
const probability = Number(probabilityBucket.probability);
if (!Number.isFinite(probability)) continue;
total += probability;
matched += 1;
}
return matched > 0 ? Math.max(0, Math.min(1, total)) : null;
}
type ProbabilityDisplayRow = {
key: string;
label: string;
probability: number;
marketBucket?: MarketTopBucket | null;
};
function formatMarketBucketDisplayLabel(
bucket: MarketTopBucket,
detail: Pick<CityDetail, "temp_symbol">,
) {
const label = String(bucket.label || "").trim();
if (label) {
const unit = getMarketBucketUnit(bucket);
let normalized = label.toUpperCase().replace(/\s+/g, "");
if (unit === "F" || isFahrenheitSymbol(detail.temp_symbol)) {
normalized = normalized
.replace(/ORHIGHER/g, "+")
.replace(/ORLOWER/g, "-")
.replace(/℃/g, "°F")
.replace(/°C/g, "°F")
.replace(/(?<=\d)F/g, "°F");
} else {
normalized = normalized
.replace(/ORHIGHER/g, "+")
.replace(/ORLOWER/g, "-")
.replace(/℃/g, "°C")
.replace(/°F/g, "°C")
.replace(/(?<=\d)C/g, "°C");
}
return normalized.replace(/\+/g, "+");
}
const unit =
getMarketBucketUnit(bucket) === "F" ||
isFahrenheitSymbol(detail.temp_symbol)
? "°F"
: "°C";
const lower = bucket.lower != null ? Number(bucket.lower) : null;
const upper = bucket.upper != null ? Number(bucket.upper) : null;
if (
lower != null &&
upper != null &&
Number.isFinite(lower) &&
Number.isFinite(upper)
) {
return `${lower}-${upper}${unit}`;
}
const value = bucket.value ?? bucket.temp ?? lower;
const numeric = value != null ? Number(value) : null;
if (numeric != null && Number.isFinite(numeric)) {
return isMarketBucketAbove(bucket)
? `${numeric}${unit}+`
: `${numeric}${unit}`;
}
return "--";
}
type ModelMetadata = NonNullable<
NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"]
>["model_metadata"];
function normalizeModelNameForVote(name: string) {
return String(name || "")
.trim()
.toLowerCase()
.replace(/[\s_/-]/g, "");
}
function getModelVoteFamily(name: string) {
const normalized = normalizeModelNameForVote(name);
if (["icon", "iconeu", "icond2"].includes(normalized)) return "dwd_icon";
if (["gem", "gdps", "rdps", "hrdps"].includes(normalized)) return "eccc_gem";
if (["ecmwfaifs", "aifs"].includes(normalized)) return "ecmwf_aifs";
if (normalized === "ecmwf") return "ecmwf_ifs";
return normalized || name;
}
function getModelVotePriority(name: string) {
const normalized = normalizeModelNameForVote(name);
return (
{
icond2: 40,
iconeu: 30,
icon: 20,
hrdps: 40,
rdps: 35,
gdps: 30,
gem: 20,
ecmwfaifs: 30,
ecmwf: 30,
gfs: 30,
jma: 30,
mgm: 45,
nws: 45,
openmeteo: 15,
}[normalized] || 10
);
}
function getRoundedModelVoteDistribution(
detail: CityDetail,
targetDate?: string | null,
) {
const view = getModelView(detail, targetDate);
const representatives = new Map<
string,
{ name: string; priority: number; value: number }
>();
Object.entries(view.models || {}).forEach(([name, rawValue]) => {
const normalized = normalizeModelNameForVote(name);
if (normalized === "lgbm" || normalized.includes("meteoblue")) return;
const value = Number(rawValue);
if (!Number.isFinite(value)) return;
const family = getModelVoteFamily(name);
const priority = getModelVotePriority(name);
const current = representatives.get(family);
if (!current || priority > current.priority) {
representatives.set(family, { name, priority, value });
}
});
const bucketMap = new Map<number, { count: number; models: string[] }>();
representatives.forEach(({ name, value }) => {
const rounded = Math.round(value);
const row = bucketMap.get(rounded) || { count: 0, models: [] };
row.count += 1;
row.models.push(name);
bucketMap.set(rounded, row);
});
const total = representatives.size;
const rows = Array.from(bucketMap.entries())
.map(([value, row]) => ({
count: row.count,
models: row.models,
percent: total > 0 ? row.count / total : 0,
value,
}))
.sort((a, b) => b.count - a.count || b.value - a.value);
return {
rows,
total,
};
}
function normalizeMarketProbability(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
return Math.max(0, Math.min(1, numeric));
}
function normalizeSignedProbability(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
if (Math.abs(numeric) > 1) return numeric / 100;
return numeric;
}
function formatSignedPercent(value?: number | null, digits = 1) {
const normalized = normalizeSignedProbability(value);
if (normalized == null) return "--";
const percent = normalized * 100;
const sign = percent > 0 ? "+" : "";
return `${sign}${percent.toFixed(digits)}%`;
}
function getMarketTopBuckets(scan?: MarketScan | null) {
const buckets = Array.isArray(scan?.top_buckets) ? scan.top_buckets : [];
if (!buckets.length) return [];
return buckets
.map((item) => ({
...item,
probability: normalizeMarketProbability(item.probability),
}))
.filter(
(item): item is MarketTopBucket & { probability: number } =>
item.probability != null,
);
}
function getMarketAllBuckets(scan?: MarketScan | null) {
const buckets = Array.isArray(scan?.all_buckets)
? scan.all_buckets
: Array.isArray(scan?.top_buckets)
? scan.top_buckets
: [];
if (!buckets.length) return [];
return buckets
.map((item) => ({
...item,
probability: normalizeMarketProbability(item.probability),
}))
.filter(
(item): item is MarketTopBucket & { probability: number } =>
item.probability != null,
);
}
function getMarketTopBucketKey(bucket: MarketTopBucket) {
if (bucket?.value != null) {
const valueNum = Number(bucket.value);
if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`;
}
if (bucket?.temp != null) {
const tempNum = Number(bucket.temp);
if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`;
}
const parsed = parseTempFromText(bucket?.label);
if (parsed != null) return `l:${parsed.toFixed(2)}`;
return `s:${String(bucket?.slug || bucket?.question || bucket?.label || "")}`;
}
function hasLgbmModel(detail: CityDetail, targetDate?: string | null) {
const view = getModelView(detail, targetDate);
return Object.keys(view.models || {}).some((name) =>
normalizeModelNameForVote(name).includes("lgbm"),
);
}
function formatProbabilityEngineLabel(
detail: CityDetail,
targetDate: string | null | undefined,
locale: string,
) {
const view = getProbabilityView(detail, targetDate);
if (hasLgbmModel(detail, targetDate)) {
return locale === "en-US" ? "LGBM-calibrated probability" : "LGBM 校准概率";
}
const engine = String(view.engine || "")
.trim()
.toLowerCase();
const calibrationMode = String(view.calibrationMode || "")
.trim()
.toLowerCase();
if (engine === "emos" || calibrationMode.includes("emos")) {
return locale === "en-US" ? "EMOS-calibrated probability" : "EMOS 校准概率";
}
return locale === "en-US" ? "Model probability" : "模型概率";
}
export function ProbabilityDistribution({
detail,
hideTitle = false,
targetDate,
marketScan,
}: {
detail: CityDetail;
hideTitle?: boolean;
targetDate?: string | null;
marketScan?: MarketScan | null;
}) {
const { locale, t } = useI18n();
const view = getProbabilityView(detail, targetDate);
const modelView = getModelView(detail, targetDate);
const marketYesPrice = getMarketYesPrice(marketScan);
const marketYesText = toPercent(marketYesPrice);
const isToday = !targetDate || targetDate === detail.local_date;
const probabilityEngineLabel = formatProbabilityEngineLabel(
detail,
targetDate,
locale,
);
const hasLgbmProbability = hasLgbmModel(detail, targetDate);
const modelVoteView = useMemo(
() => getRoundedModelVoteDistribution(detail, targetDate),
[detail, targetDate],
);
const modelVoteHint = modelVoteView.rows
.slice(0, 2)
.map(
(row) =>
`${row.value}${detail.temp_symbol} ${row.count}/${modelVoteView.total}`,
)
.join(" · ");
const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
const marketAllBuckets = isToday ? getMarketAllBuckets(marketScan) : [];
const sortedMarketTopBuckets = useMemo(() => {
const sorted = [...marketTopBuckets].sort(
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
);
const deduped: Array<MarketTopBucket & { probability: number }> = [];
const seenKeys = new Set<string>();
for (const row of sorted) {
const key = getMarketTopBucketKey(row);
if (seenKeys.has(key)) continue;
seenKeys.add(key);
deduped.push(row);
if (deduped.length >= 4) break;
}
return deduped;
}, [marketTopBuckets]);
const useMarketTopBuckets =
marketScan?.available && sortedMarketTopBuckets.length >= 2;
const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability);
const topProbability = [...(view.probabilities || [])].sort(
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
)[0];
const topProbabilityText = toPercent(topProbability?.probability);
const topProbabilityLabel = topProbability
? formatBucketDisplayLabel(topProbability, detail)
: null;
const topProbabilityTemp = topProbability
? getBucketTemp(topProbability)
: null;
const probabilitiesForMarketContracts =
view.probabilitiesAll?.length > 0
? view.probabilitiesAll
: view.probabilities || [];
const marketContractRows = useMemo<ProbabilityDisplayRow[]>(() => {
if (!isToday || !marketScan?.available || marketAllBuckets.length === 0) {
return [];
}
const rows: ProbabilityDisplayRow[] = [];
const seenKeys = new Set<string>();
for (const marketBucket of marketAllBuckets) {
const probability = getAggregatedModelProbabilityForMarketBucket(
probabilitiesForMarketContracts,
marketBucket,
detail,
);
const key =
marketBucket.slug ||
marketBucket.label ||
`${marketBucket.lower ?? marketBucket.value ?? marketBucket.temp}-${marketBucket.upper ?? ""}`;
if (seenKeys.has(key)) continue;
seenKeys.add(key);
rows.push({
key,
label: formatMarketBucketDisplayLabel(marketBucket, detail),
probability: probability ?? 0,
marketBucket,
});
}
return rows;
}, [
detail,
isToday,
marketAllBuckets,
marketScan?.available,
probabilitiesForMarketContracts,
]);
const modelProbabilityRows = useMemo<ProbabilityDisplayRow[]>(
() =>
(view.probabilities || []).slice(0, 6).map((bucket, index) => {
const bucketTemp = getBucketTemp(bucket);
return {
key: `${bucket.label || bucket.value || index}`,
label: formatBucketDisplayLabel(bucket, detail),
probability: Number(bucket.probability || 0),
marketBucket: findMarketBucketForDisplayTemp(
marketAllBuckets,
bucketTemp,
detail,
),
};
}),
[detail, marketAllBuckets, view.probabilities],
);
const probabilityRows =
marketContractRows.length > 0
? marketContractRows.slice(0, 8)
: modelProbabilityRows;
const topContractRow =
marketContractRows.length > 0
? marketContractRows.reduce((best, row) =>
row.probability > best.probability ? row : best,
)
: null;
const displayTopLabel = topContractRow?.label || topProbabilityLabel || null;
const displayTopProbability =
topContractRow?.probability ??
(topProbability?.probability != null
? Number(topProbability.probability)
: null);
const displayTopProbabilityText = toPercent(displayTopProbability);
const displayUsesMarketBuckets = marketContractRows.length > 0;
const linkedMarketBucket = useMemo(() => {
if (topContractRow?.marketBucket) return topContractRow.marketBucket;
if (topProbabilityTemp == null) return null;
return findMarketBucketForDisplayTemp(
marketAllBuckets,
topProbabilityTemp,
detail,
);
}, [detail, marketAllBuckets, topContractRow, topProbabilityTemp]);
const priceAnalysis = marketScan?.price_analysis;
const yesPriceView = priceAnalysis?.yes;
const noPriceView = priceAnalysis?.no;
const linkedMarketAsk =
linkedMarketBucket?.yes_buy ??
linkedMarketBucket?.market_price ??
yesPriceView?.ask ??
null;
const linkedNoAsk = linkedMarketBucket?.no_buy ?? noPriceView?.ask ?? null;
const linkedContractLabel =
topContractRow?.label ||
(linkedMarketBucket
? formatMarketBucketDisplayLabel(linkedMarketBucket, detail)
: null) ||
topProbabilityLabel ||
null;
const aggregatedMarketProbability =
getAggregatedModelProbabilityForMarketBucket(
probabilitiesForMarketContracts,
linkedMarketBucket,
detail,
);
const linkedMarketProbability =
topContractRow?.probability ??
aggregatedMarketProbability ??
(topProbability?.probability != null
? Number(topProbability.probability)
: null);
const linkedMarketProbabilityText = toPercent(linkedMarketProbability);
const linkedMarketEdge =
linkedMarketProbability != null && linkedMarketAsk != null
? linkedMarketProbability - Number(linkedMarketAsk)
: null;
const linkedNoEdge =
linkedMarketProbability != null && linkedNoAsk != null
? 1 - linkedMarketProbability - Number(linkedNoAsk)
: null;
const linkedBestSide =
linkedMarketBucket && linkedNoEdge != null && linkedMarketEdge != null
? linkedNoEdge > linkedMarketEdge
? "no"
: "yes"
: null;
const linkedBestAsk = linkedBestSide === "no" ? linkedNoAsk : linkedMarketAsk;
const linkedBestEdge =
linkedBestSide === "no" ? linkedNoEdge : linkedMarketEdge;
const preferredPriceView = linkedMarketBucket
? {
ask: linkedBestAsk,
edge: linkedBestEdge,
}
: priceAnalysis?.best_side === "no"
? noPriceView
: yesPriceView;
const preferredSideLabel = linkedMarketBucket
? linkedBestSide === "no"
? "NO"
: "YES"
: priceAnalysis?.best_side === "no"
? locale === "en-US"
? "NO"
: "NO"
: locale === "en-US"
? "YES"
: "YES";
const yesDisplayPrice = linkedMarketBucket
? linkedMarketAsk
: yesPriceView?.ask;
const noDisplayPrice = linkedMarketBucket ? linkedNoAsk : noPriceView?.ask;
const yesDisplayEdge = linkedMarketBucket
? linkedMarketEdge
: yesPriceView?.edge;
const noDisplayEdge = linkedMarketBucket ? linkedNoEdge : noPriceView?.edge;
const hasPriceAnalysis =
isToday &&
(Boolean(priceAnalysis?.available) ||
Boolean(marketScan) ||
Boolean(topProbability));
const lockEdge = normalizeSignedProbability(priceAnalysis?.lock?.edge);
const lockAvailable = Boolean(
priceAnalysis?.lock?.available && lockEdge != null,
);
const quoteSource =
linkedMarketBucket?.quote_source ||
marketScan?.yes_token?.quote_source ||
marketScan?.no_token?.quote_source ||
null;
const quoteAgeMs =
linkedMarketBucket?.quote_age_ms ??
marketScan?.yes_token?.quote_age_ms ??
marketScan?.no_token?.quote_age_ms;
const quoteSourceLabel =
quoteSource === "polymarket_ws"
? locale === "en-US"
? `WS live${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}s` : ""}`
: `WS 实时${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}` : ""}`
: locale === "en-US"
? "CLOB fallback"
: "CLOB 兜底";
const actionableEdge = normalizeSignedProbability(preferredPriceView?.edge);
const linkedContractOverpriced =
Boolean(linkedMarketBucket) &&
linkedBestSide === "no" &&
linkedMarketProbability != null &&
linkedMarketAsk != null &&
linkedMarketEdge != null &&
linkedMarketEdge < 0 &&
linkedNoEdge != null &&
linkedNoEdge > 0;
const linkedContractOverpay =
linkedContractOverpriced &&
linkedMarketProbability != null &&
linkedMarketAsk != null
? Number(linkedMarketAsk) - linkedMarketProbability
: null;
const actionText = !marketScan
? locale === "en-US"
? "Waiting"
: "等待"
: !marketScan.available
? locale === "en-US"
? "No market"
: "无盘口"
: actionableEdge == null
? locale === "en-US"
? "No quote"
: "无报价"
: actionableEdge >= 0.02
? linkedContractOverpriced
? locale === "en-US"
? "Overpriced"
: "市场偏贵"
: locale === "en-US"
? `Watch ${preferredSideLabel}`
: `可关注 ${preferredSideLabel}`
: actionableEdge > 0
? linkedContractOverpriced
? locale === "en-US"
? "Slightly overpriced"
: "略偏贵"
: locale === "en-US"
? `Small ${preferredSideLabel}`
: `${preferredSideLabel} 优势较小`
: locale === "en-US"
? "No clear edge"
: "暂无优势";
const actionNote =
linkedContractOverpriced && linkedContractOverpay != null
? locale === "en-US"
? `YES above model by ${formatSignedPercent(linkedContractOverpay)}`
: `YES 高于模型 ${formatSignedPercent(linkedContractOverpay)}`
: actionableEdge != null && actionableEdge >= 0.02
? locale === "en-US"
? `${formatSignedPercent(actionableEdge)} vs ask`
: `相对买价 ${formatSignedPercent(actionableEdge)}`
: locale === "en-US"
? `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`
: `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`;
return (
<section className="prob-section">
{!hideTitle && <h3>{t("section.probability")}</h3>}
<div className="prob-bars">
<div className="prob-calibration-head">
<div>
<span className="prob-source-chip">{probabilityEngineLabel}</span>
<strong>
{displayTopLabel && displayTopProbabilityText
? locale === "en-US"
? displayUsesMarketBuckets
? `${displayTopLabel} is the top displayed contract bucket at ${displayTopProbabilityText}`
: `${displayTopLabel} is the top single bucket at ${displayTopProbabilityText}`
: displayUsesMarketBuckets
? `${displayTopLabel} 为当前显示分布最高,${displayTopProbabilityText}`
: `${displayTopLabel} 单点最高,${displayTopProbabilityText}`
: locale === "en-US"
? "Awaiting calibrated buckets"
: "等待校准概率桶"}
</strong>
</div>
<p>
{hasLgbmProbability
? locale === "en-US"
? "LGBM is the learned intraday adjustment; raw model points below are only diagnostic."
: "LGBM 作为日内学习校准项;下方原始模型落点仅用于诊断。"
: locale === "en-US"
? "Using the calibrated probability distribution; raw model points below are not probabilities."
: "使用校准后的概率分布;下方原始模型落点不是概率。"}
</p>
</div>
{marketScan?.available && (topMarketBucketText || marketYesText) && (
<div
style={{
color: "var(--text-secondary)",
fontSize: "11px",
marginBottom: "6px",
}}
>
{useMarketTopBuckets
? locale === "en-US"
? `Market reference only: top traded bucket ${topMarketBucketText}`
: `市场仅作参考:最高交易温度桶 ${topMarketBucketText}`
: locale === "en-US"
? `Market reference only: this bucket ${marketYesText}`
: `市场仅作参考:该温度桶 ${marketYesText}`}
</div>
)}
<div className="prob-distribution-panel">
<div className="prob-distribution-head">
<span>
{locale === "en-US"
? "EMOS probability distribution"
: "EMOS 概率分布"}
</span>
<em>
{marketContractRows.length > 0
? locale === "en-US"
? "market buckets are aggregated from single-degree EMOS buckets"
: "市场合约桶由单点 EMOS 概率聚合"
: locale === "en-US"
? "calibrated temperature buckets"
: "校准后的温度桶"}
</em>
</div>
{probabilityRows.length === 0 ? (
<EmptyState text={t("section.noProb")} />
) : (
probabilityRows.map((row, index) => {
const probability = Math.round(
Number(row.probability || 0) * 100,
);
return (
<div key={`${row.key || index}`} className="prob-row">
<div className="prob-label">{row.label}</div>
<div className="prob-bar-track">
<div
className={clsx("prob-bar-fill", `rank-${index}`)}
style={{ width: `${Math.max(probability, 8)}%` }}
>
{probability}%
</div>
</div>
</div>
);
})
)}
</div>
{hasPriceAnalysis && (
<div className="prob-price-card">
<div className="prob-price-head">
<span>
{locale === "en-US" ? "Win-rate reference" : "胜率参考"}
</span>
<strong>
{!marketScan
? locale === "en-US"
? "Waiting for market context"
: "等待市场参照"
: !marketScan.available
? locale === "en-US"
? "No matched active market"
: "未匹配到活跃盘口"
: locale === "en-US"
? `${linkedContractLabel || topProbabilityLabel || "Temperature bucket"} · model ${linkedMarketProbabilityText || topProbabilityText || "--"}`
: `${linkedContractLabel || topProbabilityLabel || "温度桶"} · 模型 ${linkedMarketProbabilityText || topProbabilityText || "--"}`}
</strong>
</div>
<div className="prob-price-grid">
<div>
<span>
{locale === "en-US" ? "Bucket" : "温度桶"}
</span>
<strong>
{linkedContractLabel || topProbabilityLabel || "--"}
</strong>
<em>
{linkedMarketProbabilityText || topProbabilityText || "--"}
</em>
</div>
<div>
<span>{locale === "en-US" ? "DEB" : "DEB"}</span>
<strong>
{modelView.deb != null && Number.isFinite(Number(modelView.deb))
? `${Number(modelView.deb).toFixed(1)}${detail.temp_symbol}`
: "--"}
</strong>
<em>{locale === "en-US" ? "final fused forecast" : "最终融合预测"}</em>
</div>
<div>
<span>{locale === "en-US" ? "Model support" : "模型支持"}</span>
<strong>{modelVoteHint || "--"}</strong>
<em>{locale === "en-US" ? "raw model agreement" : "原始模型一致性"}</em>
</div>
<div>
<span>{locale === "en-US" ? "Market role" : "盘口角色"}</span>
<strong>{locale === "en-US" ? "Reference only" : "仅作参考"}</strong>
<em>{quoteSourceLabel}</em>
</div>
</div>
<p>
{locale === "en-US"
? "This card follows the same rule as AI forecast: DEB first, model agreement second, METAR conflict check before settlement."
: "该卡片与 AI 预测口径一致:先看 DEB,再看模型支持,最后检查 METAR 是否冲突。"}
</p>
</div>
)}
{modelVoteHint && (
<div className="prob-model-hint">
<span>
{locale === "en-US" ? "Raw model points" : "原始模型落点"}
</span>
<strong>{modelVoteHint}</strong>
<em>
{locale === "en-US"
? "diagnostic only; EMOS and contract rows use calibrated probabilities"
: "仅作诊断;EMOS 与合约行使用校准概率"}
</em>
</div>
)}
</div>
</section>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,651 @@
/* Scan terminal scroll containers, map/calendar/settings views, table rows, and AI analysis workspace. */
.root :global(.scan-detail-panel) {
scrollbar-width: none;
-ms-overflow-style: none;
}
.root :global(.scan-map-view),
.root :global(.scan-calendar-view),
.root :global(.scan-settings-view) {
flex: 1;
min-height: 0;
}
.root :global(.scan-map-view) {
display: flex;
flex-direction: column;
gap: 12px;
}
.root :global(.scan-map-shell) {
position: relative;
flex: 1;
min-height: 520px;
border: 1px solid rgba(90, 123, 166, 0.12);
border-radius: 18px;
overflow: hidden;
background: rgba(8, 17, 30, 0.8);
}
.root :global(.scan-map-shell .map) {
position: absolute;
inset: 0;
top: 0;
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
border-radius: 18px;
box-shadow: none;
}
.root :global(.scan-map-shell .leaflet-container) {
width: 100%;
height: 100%;
background: #060d18;
}
.root :global(.scan-map-caption) {
color: #8fa4c3;
font-size: 13px;
}
.root :global(.scan-summary-card.wide) {
grid-column: span 1;
}
.root :global(.scan-summary-label) {
font-size: 13px;
color: #8fa4c3;
}
.root :global(.scan-summary-value) {
margin-top: 10px;
font-size: 28px;
font-weight: 800;
color: #eef7ff;
}
.root :global(.scan-settings-view) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.root :global(.scan-settings-copy) {
margin-top: 10px;
color: #c8d8ee;
font-size: 14px;
line-height: 1.6;
}
.root :global(.scan-table-row) {
display: grid;
grid-template-columns: minmax(220px, 1.1fr) minmax(128px, 0.68fr) minmax(230px, 1.08fr) minmax(150px, 0.78fr) 92px;
gap: 10px;
align-items: center;
min-height: 132px;
padding: 14px 18px;
border: none;
border-bottom: 1px solid rgba(90, 123, 166, 0.08);
border-radius: 0;
background: linear-gradient(180deg, rgba(10, 20, 35, 0.92), rgba(8, 17, 29, 0.92));
text-align: left;
}
.root :global(.scan-table-row > *),
.root :global(.scan-table-header > *) {
min-width: 0;
}
.root :global(.scan-table-row:hover) {
transform: none;
background: linear-gradient(180deg, rgba(11, 24, 40, 0.96), rgba(8, 18, 31, 0.96));
}
.root :global(.scan-table-row.selected) {
background: linear-gradient(180deg, rgba(7, 34, 35, 0.92), rgba(8, 19, 29, 0.92));
box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.48);
}
.root :global(.scan-edge-cell) {
display: flex;
align-items: center;
justify-content: center;
}
.root :global(.scan-city-cell) {
display: block;
}
.root :global(.scan-city-copy) {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.root :global(.scan-city-name) {
font-size: 18px;
font-weight: 800;
}
.root :global(.scan-city-sub) {
font-size: 14px;
color: #a5bad5;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.root :global(.scan-time-cell) {
display: flex;
flex-direction: column;
gap: 10px;
}
.root :global(.scan-time-main) {
font-size: 16px;
font-weight: 700;
}
.root :global(.scan-time-remaining) {
font-size: 14px;
color: #9eb3ce;
}
.root :global(.scan-phase-badge),
.root :global(.scan-status-badge) {
width: fit-content;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid rgba(255, 255, 255, 0.12);
text-transform: none;
}
.root :global(.scan-phase-badge.red) {
color: #ff7f7f;
background: rgba(255, 93, 93, 0.1);
}
.root :global(.scan-phase-badge.green) {
color: #34e49f;
background: rgba(23, 217, 139, 0.12);
}
.root :global(.scan-phase-badge.blue) {
color: #7cb5ff;
background: rgba(63, 140, 255, 0.12);
}
.root :global(.scan-phase-badge.amber) {
color: #ffc14b;
background: rgba(255, 176, 32, 0.12);
}
.root :global(.scan-distribution-preview) {
display: block;
min-width: 0;
}
.root :global(.scan-model-compare) {
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border: 1px solid rgba(86, 120, 168, 0.16);
border-radius: 12px;
background: rgba(13, 27, 45, 0.7);
}
.root :global(.scan-model-primary) {
display: grid;
grid-template-columns: minmax(76px, 1fr) auto auto;
gap: 8px;
align-items: baseline;
}
.root :global(.scan-model-primary span),
.root :global(.scan-model-rows b) {
color: rgba(146, 168, 197, 0.92);
font-size: 11px;
font-weight: 800;
}
.root :global(.scan-model-primary strong) {
color: #22eb98;
font-size: 18px;
font-weight: 900;
line-height: 1;
}
.root :global(.scan-model-primary em) {
color: #d7e7fb;
font-size: 13px;
font-style: normal;
font-weight: 800;
white-space: nowrap;
}
.root :global(.scan-model-rows) {
display: flex;
flex-direction: column;
gap: 5px;
}
.root :global(.scan-model-rows span) {
display: grid;
grid-template-columns: 42px minmax(54px, auto) minmax(0, 1fr);
gap: 8px;
align-items: center;
min-width: 0;
}
.root :global(.scan-model-rows strong) {
color: #eef7ff;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
}
.root :global(.scan-model-rows em) {
overflow: hidden;
color: #9fb4cf;
font-size: 12px;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.root :global(.scan-trade-cell) {
display: flex;
flex-direction: column;
gap: 8px;
}
.root :global(.scan-trade-main) {
font-size: 17px;
font-weight: 800;
overflow-wrap: anywhere;
}
.root :global(.scan-trade-main.buy) {
color: #26e896;
}
.root :global(.scan-trade-main.sell) {
color: #ff6969;
}
.root :global(.scan-trade-sub),
.root :global(.scan-trade-note) {
font-size: 14px;
color: #a5bad5;
}
.root :global(.scan-edge-cell) {
font-size: 18px;
font-weight: 800;
}
.root :global(.scan-edge-cell.positive) {
color: #1de28f;
}
.root :global(.scan-edge-cell.negative) {
color: #ff6868;
}
.root :global(.scan-row-fav) {
display: none;
}
.root :global(.scan-view-all-wrap) {
display: flex;
justify-content: center;
padding: 16px;
}
.root :global(.scan-view-all-button) {
padding: 12px 18px;
font-weight: 700;
}
.root :global(.scan-ai-log-panel) {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px;
border: 1px solid rgba(68, 100, 150, 0.18);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(9, 23, 38, 0.92), rgba(7, 17, 30, 0.92)),
radial-gradient(circle at 0 0, rgba(34, 211, 238, 0.12), transparent 34%);
}
.root :global(.scan-ai-log-panel.compact) {
flex: 0 0 auto;
display: block;
margin-top: 10px;
padding: 0;
overflow: hidden;
border-radius: 14px;
background: rgba(7, 17, 30, 0.72);
}
.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 42px;
padding: 0 14px;
color: #9fb4cf;
font-size: 12px;
font-weight: 900;
cursor: pointer;
list-style: none;
}
.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary::-webkit-details-marker) {
display: none;
}
.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary span) {
color: #c7d8ef;
}
.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary strong) {
color: #7fb4ff;
font-size: 12px;
font-weight: 900;
}
.root :global(.scan-ai-log-panel.compact[open] .scan-ai-log-list) {
padding: 0 12px 12px;
}
.root :global(.scan-ai-analysis-view) {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 520px;
}
.root :global(.scan-ai-analysis-view.empty) {
min-height: 620px;
}
.root :global(.scan-ai-summary-card) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
border: 1px solid rgba(34, 211, 238, 0.22);
border-radius: 18px;
background:
linear-gradient(135deg, rgba(8, 32, 52, 0.94), rgba(8, 21, 38, 0.94)),
radial-gradient(circle at 0 0, rgba(45, 212, 191, 0.15), transparent 32%);
}
.root :global(.scan-ai-summary-card strong) {
color: #ecf8ff;
font-size: 16px;
font-weight: 900;
}
.root :global(.scan-ai-summary-card p) {
margin: 6px 0 0;
color: #a6bbd7;
font-size: 13px;
font-weight: 700;
line-height: 1.55;
}
.root :global(.scan-ai-inline-button) {
min-height: 42px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 16px;
border: 1px solid rgba(45, 212, 191, 0.3);
border-radius: 12px;
color: #d9fff6;
background: linear-gradient(135deg, rgba(13, 148, 136, 0.36), rgba(8, 47, 73, 0.4));
font-size: 13px;
font-weight: 900;
cursor: pointer;
}
.root :global(.scan-ai-inline-button:disabled) {
cursor: not-allowed;
opacity: 0.48;
}
.root :global(.scan-ai-log-panel.in-tab) {
margin: 0;
}
.root :global(.scan-ai-city-list) {
display: flex;
flex-direction: column;
gap: 12px;
}
.root :global(.scan-ai-city-card) {
border: 1px solid rgba(68, 100, 150, 0.2);
border-radius: 18px;
overflow: hidden;
background: rgba(10, 24, 42, 0.82);
}
.root :global(.scan-ai-city-head) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 16px 18px;
background: rgba(15, 34, 57, 0.9);
}
.root :global(.scan-ai-city-head strong) {
color: #f2f8ff;
font-size: 17px;
font-weight: 900;
}
.root :global(.scan-ai-city-head p) {
margin: 6px 0 0;
color: #9fb4d2;
font-size: 13px;
font-weight: 700;
line-height: 1.45;
}
.root :global(.scan-ai-city-head span) {
flex: 0 0 auto;
padding: 5px 9px;
border-radius: 999px;
color: #68f3c8;
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.2);
font-size: 11px;
font-weight: 900;
}
.root :global(.scan-ai-cluster-note) {
margin: 0 18px 14px;
padding: 10px 12px;
border: 1px solid rgba(34, 211, 238, 0.18);
border-radius: 12px;
color: #b7cbe6;
background: rgba(6, 18, 32, 0.5);
font-size: 12px;
font-weight: 800;
line-height: 1.45;
}
.root :global(.scan-ai-contracts) {
display: flex;
flex-direction: column;
padding: 0 12px 12px;
}
.root :global(.scan-ai-contract) {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto;
gap: 10px 16px;
align-items: center;
padding: 12px;
border-top: 1px solid rgba(68, 100, 150, 0.16);
}
.root :global(.scan-ai-contract b) {
display: block;
color: #edf7ff;
font-size: 13px;
font-weight: 900;
}
.root :global(.scan-ai-contract small) {
display: block;
margin-top: 3px;
color: #8fa8c7;
font-size: 11px;
font-weight: 800;
}
.root :global(.scan-ai-contract > span) {
justify-self: end;
padding: 5px 9px;
border-radius: 999px;
color: #a8bedc;
background: rgba(68, 100, 150, 0.16);
font-size: 11px;
font-weight: 900;
}
.root :global(.scan-ai-contract.approve > span) {
color: #23f0a8;
background: rgba(16, 185, 129, 0.14);
}
.root :global(.scan-ai-contract.veto > span) {
color: #ff8a8a;
background: rgba(248, 113, 113, 0.12);
}
.root :global(.scan-ai-contract.downgrade > span),
.root :global(.scan-ai-contract.watchlist > span) {
color: #ffd166;
background: rgba(245, 158, 11, 0.12);
}
.root :global(.scan-ai-contract p) {
grid-column: 1 / -1;
margin: 0;
color: #9fb4d2;
font-size: 12px;
font-weight: 700;
line-height: 1.45;
}
.root :global(.scan-ai-log-head) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.root :global(.scan-ai-log-head strong) {
color: #e9f5ff;
font-size: 13px;
font-weight: 900;
}
.root :global(.scan-ai-log-head span) {
color: #8fa8c7;
font-size: 12px;
font-weight: 800;
}
.root :global(.scan-ai-log-list) {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
}
.root :global(.scan-ai-log-list::-webkit-scrollbar) {
height: 5px;
}
.root :global(.scan-ai-log-list::-webkit-scrollbar-thumb) {
background: rgba(88, 125, 180, 0.26);
border-radius: 999px;
}
.root :global(.scan-ai-log-item) {
display: grid;
grid-template-columns: auto minmax(180px, 1fr);
gap: 8px;
min-width: 260px;
padding: 9px 10px;
border: 1px solid rgba(68, 100, 150, 0.16);
border-radius: 12px;
background: rgba(14, 29, 48, 0.76);
}
.root :global(.scan-ai-log-item b) {
display: block;
color: #edf7ff;
font-size: 12px;
font-weight: 900;
line-height: 1.25;
}
.root :global(.scan-ai-log-item small) {
display: block;
margin-top: 3px;
color: #9fb4d2;
font-size: 11px;
font-weight: 700;
line-height: 1.35;
}
.root :global(.scan-ai-log-time) {
color: #7892b1;
font-size: 11px;
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.root :global(.scan-ai-log-item.info) {
border-color: rgba(56, 189, 248, 0.22);
}
.root :global(.scan-ai-log-item.success) {
border-color: rgba(16, 185, 129, 0.32);
}
.root :global(.scan-ai-log-item.warning) {
border-color: rgba(245, 158, 11, 0.34);
}
.root :global(.scan-ai-log-item.error) {
border-color: rgba(248, 113, 113, 0.34);
}
.root :global(.scan-ai-log-empty) {
color: #8fa8c7;
font-size: 12px;
font-weight: 800;
}
@@ -13,6 +13,8 @@
border-radius: 18px;
background: rgba(8, 17, 30, 0.8);
overflow: hidden;
content-visibility: auto;
contain-intrinsic-size: 460px;
}
.root :global(.scan-calendar-group-head) {
@@ -1,6 +1,7 @@
"use client";
import clsx from "clsx";
import dynamic from "next/dynamic";
import Link from "next/link";
import {
LogIn,
@@ -17,7 +18,15 @@ import {
useState,
} from "react";
import styles from "./Dashboard.module.css";
import dashboardHomeStyles from "./DashboardHomeIntelligence.module.css";
import dashboardMapStyles from "./DashboardMap.module.css";
import dashboardModalGuideStyles from "./DashboardModalGuide.module.css";
import dashboardShellStyles from "./DashboardShell.module.css";
import detailChromeStyles from "./DetailPanelChrome.module.css";
import detailContentStyles from "./DetailPanelContent.module.css";
import detailSectionsStyles from "./DetailPanelSections.module.css";
import futureForecastModalStyles from "./FutureForecastModal.module.css";
import historyModalStyles from "./HistoryModal.module.css";
import modalChromeStyles from "./ModalChrome.module.css";
import scanTerminalCalendarStyles from "./ScanTerminalCalendar.module.css";
import scanTerminalCardStyles from "./ScanTerminalCard.module.css";
@@ -25,9 +34,12 @@ import scanTerminalLightThemeStyles from "./ScanTerminalLightTheme.module.css";
import scanTerminalMobileStyles from "./ScanTerminalMobile.module.css";
import scanTerminalOpportunityStyles from "./ScanTerminalOpportunity.module.css";
import scanTerminalStyles from "./ScanTerminal.module.css";
import { DetailPanel as CityDetailPanel } from "@/components/dashboard/DetailPanel";
import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal";
import { MapCanvas } from "@/components/dashboard/MapCanvas";
import scanTerminalBoardStyles from "./ScanTerminalBoard.module.css";
import scanTerminalDetailStyles from "./ScanTerminalDetail.module.css";
import scanTerminalFiltersStyles from "./ScanTerminalFilters.module.css";
import scanTerminalListStyles from "./ScanTerminalList.module.css";
import scanTerminalShellStyles from "./ScanTerminalShell.module.css";
import scanTerminalStateStyles from "./ScanTerminalState.module.css";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import {
DashboardStoreProvider,
@@ -59,6 +71,30 @@ import {
type ContentView = "opportunities" | "analysis" | "map" | "calendar";
const CityDetailPanel = dynamic(
() =>
import("@/components/dashboard/DetailPanel").then(
(module) => module.DetailPanel,
),
{ ssr: false },
);
const FutureForecastModal = dynamic(
() =>
import("@/components/dashboard/FutureForecastModal").then(
(module) => module.FutureForecastModal,
),
{ ssr: false },
);
const MapCanvas = dynamic(
() =>
import("@/components/dashboard/MapCanvas").then(
(module) => module.MapCanvas,
),
{ ssr: false },
);
function ScanTerminalScreen() {
const store = useDashboardStore();
const { locale, toggleLocale } = useI18n();
@@ -85,14 +121,28 @@ function ScanTerminalScreen() {
const lastMapSelectedCityRef = useRef<string>("");
const scanTerminalRootClassName = clsx(
styles.root,
dashboardHomeStyles.root,
dashboardMapStyles.root,
dashboardShellStyles.root,
dashboardModalGuideStyles.root,
scanTerminalStyles.root,
scanTerminalShellStyles.root,
scanTerminalFiltersStyles.root,
scanTerminalListStyles.root,
scanTerminalBoardStyles.root,
scanTerminalDetailStyles.root,
scanTerminalStateStyles.root,
scanTerminalOpportunityStyles.root,
scanTerminalCardStyles.root,
scanTerminalCalendarStyles.root,
scanTerminalMobileStyles.root,
scanTerminalLightThemeStyles.root,
detailChromeStyles.root,
detailContentStyles.root,
detailSectionsStyles.root,
modalChromeStyles.root,
futureForecastModalStyles.root,
historyModalStyles.root,
themeMode === "light" && "light",
);
@@ -0,0 +1,353 @@
/* Scan terminal right-side detail panel styles for the current decision desk. */
.root :global(.scan-detail-panel) {
width: auto;
min-width: 0;
padding: 18px 16px;
border-left: 1px solid rgba(82, 114, 161, 0.18);
}
.root :global(.scan-detail-header) {
padding: 0 0 16px;
justify-content: space-between;
border-bottom: none;
margin-bottom: 10px;
}
.root :global(.scan-detail-top) {
display: flex;
gap: 0;
align-items: center;
}
.root :global(.scan-detail-title-wrap) {
display: flex;
flex-direction: column;
gap: 6px;
}
.root :global(.scan-detail-icon-button) {
width: 40px;
height: 40px;
display: grid;
place-items: center;
}
.root :global(.scan-detail-hero-placeholder) {
width: 66px;
height: 66px;
border-radius: 14px;
background: linear-gradient(135deg, #314d7b, #0d2038 60%, #08131f);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.root :global(.scan-detail-city-name) {
font-size: 22px;
font-weight: 800;
}
.root :global(.scan-detail-city-sub) {
font-size: 14px;
color: #a4bad6;
}
.root :global(.scan-detail-volume-row) {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.root :global(.scan-detail-volume-big) {
font-size: 28px;
font-weight: 800;
}
.root :global(.scan-detail-volume-caption) {
margin-top: 4px;
font-size: 14px;
color: #90a7c4;
}
.root :global(.scan-detail-action-button) {
padding: 11px 14px;
font-weight: 700;
}
.root :global(.scan-detail-primary-actions) {
display: flex;
margin-bottom: 16px;
}
.root :global(.scan-detail-analysis-button) {
width: 100%;
border: 1px solid rgba(23, 217, 139, 0.3);
border-radius: 14px;
background: rgba(23, 217, 139, 0.12);
color: #27ea98;
padding: 12px 14px;
font-size: 14px;
font-weight: 800;
text-align: center;
}
.root :global(.scan-detail-analysis-button:hover) {
background: rgba(23, 217, 139, 0.18);
border-color: rgba(23, 217, 139, 0.4);
}
.root :global(.scan-detail-section) {
padding: 16px 0;
border-bottom: 1px solid rgba(90, 123, 166, 0.12);
}
.root :global(.scan-detail-section-title) {
font-size: 16px;
font-weight: 800;
color: #e8f2ff;
text-transform: none;
letter-spacing: 0;
margin-bottom: 12px;
}
.root :global(.scan-kv-list) {
display: flex;
flex-direction: column;
gap: 8px;
}
.root :global(.scan-kv) {
display: flex;
justify-content: space-between;
gap: 14px;
padding: 4px 0;
color: #dbe7f8;
}
.root :global(.scan-kv span:first-child) {
color: #91a8c6;
}
.root :global(.scan-kv strong) {
font-size: 15px;
}
.root :global(.scan-kv strong.warn) {
color: #ffbf39;
}
.root :global(.scan-kv strong.danger) {
color: #ff7f7f;
}
.root :global(.scan-timeline-head) {
display: flex;
justify-content: space-between;
color: #a1b6d2;
font-size: 13px;
}
.root :global(.scan-timeline-bar) {
position: relative;
height: 8px;
margin-top: 8px;
border-radius: 999px;
background: linear-gradient(90deg, #e83c3c 0 72%, rgba(255, 255, 255, 0.18) 72% 100%);
}
.root :global(.scan-timeline-knob) {
position: absolute;
top: 50%;
width: 16px;
height: 16px;
border-radius: 50%;
background: #dfe8f6;
box-shadow: 0 0 0 4px rgba(223, 232, 246, 0.12);
transform: translate(-50%, -50%);
}
.root :global(.scan-timeline-caption) {
margin-top: 8px;
color: #a1b6d2;
font-size: 13px;
text-align: right;
}
.root :global(.scan-chart-legend) {
display: flex;
gap: 16px;
margin-bottom: 14px;
color: #a8bdd8;
font-size: 13px;
}
.root :global(.scan-chart-legend .dot) {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.root :global(.scan-chart-legend .dot.green) {
background: #1ce393;
}
.root :global(.scan-chart-legend .dot.blue) {
background: #4ca2ff;
}
.root :global(.scan-chart-bars) {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 8px;
height: 170px;
padding: 8px 4px 0;
}
.root :global(.scan-chart-group) {
flex: 1;
display: flex;
justify-content: center;
align-items: flex-end;
gap: 6px;
position: relative;
}
.root :global(.scan-chart-group.highlighted .scan-chart-label) {
color: #22eb98;
font-weight: 800;
}
.root :global(.scan-chart-col) {
width: 20px;
border-radius: 8px 8px 0 0;
}
.root :global(.scan-chart-col.model) {
background: linear-gradient(180deg, #1ce393, #0b8d5a);
}
.root :global(.scan-chart-col.market) {
background: linear-gradient(180deg, #4ca2ff, #2263cf);
}
.root :global(.scan-chart-label) {
position: absolute;
bottom: -24px;
left: 50%;
transform: translateX(-50%);
color: #93a8c4;
font-size: 13px;
}
.root :global(.scan-trade-cards) {
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.root :global(.scan-trade-card) {
padding: 16px;
border-radius: 16px;
background: rgba(11, 24, 42, 0.92);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.root :global(.scan-trade-card.buy) {
box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.22);
}
.root :global(.scan-trade-card.sell) {
box-shadow: inset 0 0 0 1px rgba(255, 93, 93, 0.22);
}
.root :global(.scan-trade-card-title) {
font-size: 16px;
font-weight: 800;
}
.root :global(.scan-trade-card.buy .scan-trade-card-title) {
color: #20e391;
}
.root :global(.scan-trade-card.sell .scan-trade-card-title) {
color: #ff7070;
}
.root :global(.scan-trade-card p) {
margin: 10px 0 0;
color: #d5e1f5;
font-size: 14px;
}
.root :global(.scan-trade-card p.positive) {
color: #20e391;
}
.root :global(.scan-trade-card p.negative) {
color: #ff7070;
}
.root :global(.scan-detail-score-block) {
margin-top: 18px;
padding-top: 16px;
}
.root :global(.scan-detail-score-head) {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
}
.root :global(.scan-detail-score-label-text) {
font-size: 16px;
font-weight: 800;
}
.root :global(.scan-detail-score-meta) {
margin-top: 6px;
color: #89a0bf;
font-size: 13px;
}
.root :global(.scan-detail-score-value) {
font-size: 40px;
font-weight: 800;
}
.root :global(.scan-detail-score-value.green) {
color: #23e694;
}
.root :global(.scan-detail-score-value.yellow) {
color: #ffbe26;
}
.root :global(.scan-detail-score-value.red) {
color: #ff6f86;
}
.root :global(.scan-detail-score-value span) {
font-size: 22px;
color: #9fb5d2;
}
.root :global(.scan-detail-score-line) {
height: 6px;
margin-top: 10px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.root :global(.scan-detail-score-line span) {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #1be392, #12b46d);
box-shadow: 0 0 18px rgba(27, 227, 146, 0.22);
}
@@ -0,0 +1,241 @@
/* Scan terminal sidebar filters, mode tabs, range controls, toggles, and CTA.
Imported after ScanTerminal.module.css so these current-desk overrides win over legacy base rules. */
.root :global(.scan-filter-panel) {
width: auto;
min-width: 0;
border-right: 1px solid rgba(159, 178, 199, 0.14);
padding: 18px 14px;
gap: 18px;
overflow: hidden auto;
}
.root :global(.scan-sidebar-brand) {
display: flex;
align-items: center;
gap: 0;
padding: 8px 4px 18px;
border-bottom: 1px solid rgba(118, 146, 188, 0.12);
}
.root :global(.scan-sidebar-brand-mark) {
width: 42px;
height: 42px;
border-radius: 14px;
display: grid;
place-items: center;
color: #042113;
background: radial-gradient(circle at 30% 30%, #29f2ab, #0da46a 60%, #073c28 100%);
box-shadow: 0 0 24px rgba(23, 217, 139, 0.3);
}
.root :global(.scan-sidebar-brand-name) {
font-size: 30px;
font-weight: 800;
letter-spacing: 0;
color: #eef5ff;
}
.root :global(.scan-filter-heading) {
display: flex;
align-items: center;
justify-content: space-between;
margin: 6px 8px 8px;
font-size: 14px;
font-weight: 600;
color: #b8c7dd;
}
.root :global(.scan-mode-tabs) {
gap: 10px;
}
.root :global(.scan-mode-tab) {
align-items: flex-start;
gap: 12px;
padding: 14px;
border-radius: 14px;
border: 1px solid rgba(87, 119, 166, 0.14);
background: rgba(11, 24, 42, 0.95);
color: #dbe7f8;
}
.root :global(.scan-mode-tab:hover) {
background: rgba(13, 28, 48, 0.98);
border-color: rgba(87, 119, 166, 0.24);
}
.root :global(.scan-mode-tab.active) {
background: linear-gradient(180deg, rgba(15, 34, 59, 1), rgba(9, 19, 34, 1));
border-color: rgba(78, 222, 151, 0.7);
color: #f3fbff;
box-shadow: inset 0 0 0 1px rgba(78, 222, 151, 0.35), 0 0 0 2px rgba(7, 199, 119, 0.08);
}
.root :global(.scan-mode-tab.active::before) {
display: none;
}
.root :global(.scan-mode-icon) {
width: 28px;
height: 28px;
border-radius: 10px;
display: grid;
place-items: center;
flex-shrink: 0;
background: rgba(63, 140, 255, 0.12);
color: #6cb1ff;
}
.root :global(.scan-mode-tab.active .scan-mode-icon) {
background: rgba(23, 217, 139, 0.14);
color: #2df3a5;
}
.root :global(.scan-mode-copy) {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.root :global(.scan-mode-tab-label) {
font-size: 15px;
font-weight: 700;
color: inherit;
}
.root :global(.scan-mode-tab-sub) {
font-size: 12px;
line-height: 1.4;
color: #8fa4c3;
}
.root :global(.scan-range-card),
.root :global(.scan-filter-row) {
background: rgba(11, 24, 42, 0.92);
border: 1px solid rgba(87, 119, 166, 0.12);
border-radius: 12px;
padding: 12px 14px;
}
.root :global(.scan-range-card) {
display: flex;
flex-direction: column;
gap: 10px;
}
.root :global(.scan-filter-row-title),
.root :global(.scan-filter-row-label) {
font-size: 14px;
color: #d9e7f8;
}
.root :global(.scan-filter-row) {
gap: 10px;
}
.root :global(.scan-filter-row.inline) {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.root :global(.scan-range-track-wrap) {
position: relative;
height: 20px;
}
.root :global(.scan-range-track-wrap::before) {
content: "";
position: absolute;
inset: 7px 0;
border-radius: 999px;
background: linear-gradient(90deg, #1c2f4e, #2d4f84);
}
.root :global(.scan-range-slider) {
position: absolute;
inset: 0;
width: 100%;
height: 20px;
background: transparent;
pointer-events: none;
}
.root :global(.scan-range-slider::-webkit-slider-thumb) {
width: 16px;
height: 16px;
border-radius: 50%;
border: none;
background: #4d93ff;
box-shadow: 0 0 0 4px rgba(77, 147, 255, 0.16);
pointer-events: auto;
}
.root :global(.scan-range-slider::-moz-range-thumb) {
width: 16px;
height: 16px;
border-radius: 50%;
border: none;
background: #4d93ff;
box-shadow: 0 0 0 4px rgba(77, 147, 255, 0.16);
pointer-events: auto;
}
.root :global(.scan-range-slider::-webkit-slider-runnable-track),
.root :global(.scan-range-slider::-moz-range-track) {
background: transparent;
}
.root :global(.scan-range-labels) {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #aec0d9;
}
.root :global(.scan-select) {
border-radius: 10px;
padding: 10px 12px;
border-color: rgba(87, 119, 166, 0.12);
background: rgba(10, 22, 38, 0.95);
color: #d9e8fb;
}
.root :global(.scan-toggle) {
width: 42px;
height: 24px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: linear-gradient(90deg, #0e2840, #12324d);
}
.root :global(.scan-toggle.active) {
background: linear-gradient(90deg, #0e2840, #1f7f57);
border-color: rgba(39, 234, 152, 0.28);
}
.root :global(.scan-toggle-knob) {
top: 2px;
left: 3px;
width: 18px;
height: 18px;
background: #8ca5c8;
}
.root :global(.scan-toggle.active .scan-toggle-knob) {
left: 21px;
background: #1bf1a2;
box-shadow: 0 0 10px rgba(27, 241, 162, 0.45);
}
.root :global(.scan-cta-button) {
border-radius: 14px;
padding: 15px;
margin-top: auto;
background: linear-gradient(180deg, #1ddb8d, #0eb96d);
box-shadow: 0 8px 22px rgba(14, 185, 109, 0.22);
font-size: 16px;
font-weight: 800;
}
@@ -0,0 +1,544 @@
/* Scan terminal KPI, list tabs, table rows, v4 analysis, and AI workspace.
Kept separate from the shell/filter CSS so dense decision-board styles can evolve independently. */
.root :global(.scan-kpi-bar) {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.root :global(.scan-kpi-card) {
min-height: 124px;
padding: 18px;
border-radius: 16px;
border: 1px solid rgba(92, 124, 170, 0.12);
background: linear-gradient(180deg, rgba(14, 28, 48, 0.92), rgba(11, 21, 37, 0.94));
transform: none;
}
.root :global(.scan-kpi-card:hover) {
transform: translateY(-2px);
}
.root :global(.scan-kpi-card::after) {
display: none;
}
.root :global(.scan-kpi-card.green .scan-kpi-label) {
color: #17d98b;
}
.root :global(.scan-kpi-card.cyan .scan-kpi-label) {
color: #37d6ff;
}
.root :global(.scan-kpi-card.purple .scan-kpi-label) {
color: #a35cff;
}
.root :global(.scan-kpi-card.blue .scan-kpi-label) {
color: #49a3ff;
}
.root :global(.scan-kpi-card.red .scan-kpi-label) {
color: #ff647c;
}
.root :global(.scan-kpi-card.amber .scan-kpi-label) {
color: #ffb84f;
}
.root :global(.scan-kpi-card.orange .scan-kpi-label) {
color: #F59E0B;
}
.root :global(.scan-kpi-label) {
font-size: 13px;
text-transform: none;
letter-spacing: 0;
}
.root :global(.scan-kpi-value) {
margin-top: 10px;
font-size: 28px;
}
.root :global(.scan-kpi-note) {
margin-top: 8px;
font-size: 14px;
color: #a8bedb;
}
.root :global(.scan-list-section) {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.root :global(.scan-list-header) {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.root :global(.scan-list-tabs) {
display: flex;
gap: 24px;
}
.root :global(.scan-list-tabs button) {
border: none;
background: transparent;
font-size: 16px;
font-weight: 700;
color: #91a7c4;
cursor: pointer;
}
.root :global(.scan-list-tabs button:disabled) {
opacity: 0.42;
cursor: not-allowed;
}
.root :global(.scan-list-tabs button.active) {
color: #fff;
}
.root :global(.scan-list-status) {
display: flex;
gap: 10px;
align-items: center;
}
.root :global(.scan-status-chip) {
display: inline-flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid rgba(98, 126, 167, 0.22);
background: rgba(10, 22, 38, 0.92);
color: #c4d4e9;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.02em;
}
.root :global(.scan-status-chip.live) {
border-color: rgba(55, 214, 255, 0.28);
background: rgba(55, 214, 255, 0.1);
color: #71e6ff;
}
.root :global(.scan-status-chip.stale) {
border-color: rgba(255, 184, 79, 0.24);
background: rgba(255, 184, 79, 0.1);
color: #ffc765;
}
.root :global(.scan-status-chip.ai) {
border-color: rgba(94, 234, 212, 0.28);
background: rgba(20, 184, 166, 0.12);
color: #87fff1;
}
.root :global(.scan-status-chip.focus) {
border-color: rgba(255, 176, 32, 0.3);
background: rgba(255, 176, 32, 0.12);
color: #ffd078;
}
.root :global(button.scan-status-chip.refresh) {
cursor: pointer;
}
.root :global(button.scan-status-chip.refresh:disabled) {
cursor: wait;
opacity: 0.68;
}
.root :global(.scan-status-chip .spin) {
animation: spin 1s linear infinite;
}
.root :global(.scan-table-shell) {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid rgba(90, 123, 166, 0.12);
border-radius: 18px;
background: rgba(8, 17, 30, 0.8);
overflow: hidden;
}
.root :global(.scan-table-shell.empty) {
min-height: 340px;
}
.root :global(.scan-table-banner) {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid rgba(95, 124, 168, 0.12);
background: linear-gradient(180deg, rgba(255, 184, 79, 0.08), rgba(255, 184, 79, 0.03));
}
.root :global(.scan-table-banner strong) {
font-size: 13px;
font-weight: 800;
color: #ffd27f;
}
.root :global(.scan-table-banner span) {
font-size: 13px;
color: #c7d4e7;
}
.root :global(.scan-table-header) {
display: grid;
grid-template-columns: minmax(220px, 1.1fr) minmax(128px, 0.68fr) minmax(230px, 1.08fr) minmax(150px, 0.78fr) 92px;
gap: 10px;
align-items: center;
padding: 14px 18px;
background: rgba(11, 23, 40, 0.98);
border-bottom: 1px solid rgba(90, 123, 166, 0.12);
color: #92a8c5;
font-size: 13px;
font-weight: 600;
text-transform: none;
letter-spacing: 0;
}
.root :global(.scan-table-body) {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
}
.root :global(.scan-v4-analysis) {
grid-column: auto;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin: 0 14px 14px;
padding: 14px;
border-color: rgba(56, 189, 248, 0.18);
border-radius: 14px;
background:
linear-gradient(180deg, rgba(7, 20, 36, 0.96), rgba(5, 15, 28, 0.98)),
radial-gradient(circle at 4% 0%, rgba(45, 212, 191, 0.11), transparent 28%);
}
.root :global(.scan-v4-analysis section) {
padding: 13px;
border-color: rgba(92, 132, 188, 0.14);
background: rgba(10, 25, 43, 0.7);
}
.root :global(.scan-v4-analysis p),
.root :global(.scan-v4-analysis ul) {
color: #b8c9e3;
}
.root :global(.scan-v4-analysis .scan-v4-evidence) {
grid-column: 1 / -1;
}
.root :global(.scan-v4-analysis .scan-v4-decision) {
grid-column: 1 / -1;
border-color: rgba(45, 212, 191, 0.24);
background:
linear-gradient(180deg, rgba(6, 31, 43, 0.86), rgba(8, 23, 38, 0.86)),
radial-gradient(circle at 0 0, rgba(20, 184, 166, 0.12), transparent 32%);
}
.root :global(.scan-v4-analysis .scan-v4-decision.downgrade),
.root :global(.scan-v4-analysis .scan-v4-decision.watchlist) {
border-color: rgba(245, 158, 11, 0.24);
}
.root :global(.scan-v4-analysis .scan-v4-decision.veto) {
border-color: rgba(248, 113, 113, 0.28);
}
.root :global(.scan-v4-decision p b) {
color: #ecfeff;
font-weight: 950;
}
.root :global(.scan-v4-metar-summary) {
margin-top: 9px;
color: #8fb3d8;
font-size: 12px;
font-weight: 800;
line-height: 1.45;
}
.root :global(.scan-v4-evidence > div),
.root :global(.scan-v4-model-sources) {
gap: 8px;
}
.root :global(.scan-v4-analysis) {
display: flex;
flex-direction: column;
gap: 13px;
margin: 0 14px 14px;
padding: 15px 16px 16px;
border: 1px solid rgba(56, 189, 248, 0.16);
border-radius: 12px;
background:
linear-gradient(180deg, rgba(7, 20, 36, 0.96), rgba(5, 15, 28, 0.98)),
radial-gradient(circle at 4% 0%, rgba(45, 212, 191, 0.08), transparent 30%);
}
.root :global(.scan-v4-analysis section),
.root :global(.scan-v4-analysis section:first-child),
.root :global(.scan-v4-analysis .scan-v4-evidence) {
grid-column: auto;
min-width: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.root :global(.scan-v4-heading) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(90, 123, 166, 0.14);
}
.root :global(.scan-v4-heading > div) {
display: grid;
gap: 7px;
min-width: 0;
}
.root :global(.scan-v4-heading strong),
.root :global(.scan-v4-current b),
.root :global(.scan-v4-brief-grid strong) {
color: #6FB7FF;
font-size: 12px;
font-weight: 950;
letter-spacing: 0.02em;
}
.root :global(.scan-v4-heading p) {
margin: 0;
color: #d2e1f4;
font-size: 14px;
font-weight: 800;
line-height: 1.55;
}
.root :global(.scan-v4-decision-pill) {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 0 12px;
border: 1px solid rgba(45, 212, 191, 0.3);
border-radius: 999px;
background: rgba(20, 184, 166, 0.12);
color: #7dfbe7;
font-size: 12px;
font-weight: 950;
white-space: nowrap;
}
.root :global(.scan-v4-decision-pill.downgrade),
.root :global(.scan-v4-decision-pill.watchlist) {
border-color: rgba(245, 158, 11, 0.34);
background: rgba(245, 158, 11, 0.12);
color: #ffd166;
}
.root :global(.scan-v4-decision-pill.veto) {
border-color: rgba(248, 113, 113, 0.34);
background: rgba(248, 113, 113, 0.12);
color: #ff8585;
}
.root :global(.scan-v4-current) {
display: grid;
gap: 6px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(90, 123, 166, 0.12);
}
.root :global(.scan-v4-current span) {
color: #c8d8ec;
font-size: 13px;
font-weight: 850;
line-height: 1.5;
}
.root :global(.scan-v4-current small) {
color: #8fb3d8;
font-size: 12px;
font-weight: 800;
line-height: 1.45;
}
.root :global(.scan-v4-brief-grid) {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(260px, 0.85fr);
gap: 26px;
}
.root :global(.scan-v4-brief-grid section:first-child) {
grid-column: auto;
}
.root :global(.scan-v4-brief-grid ul) {
display: grid;
gap: 8px;
margin: 8px 0 0;
padding: 0;
list-style: none;
}
.root :global(.scan-v4-brief-grid li) {
position: relative;
padding-left: 16px;
color: #aebfd8;
font-size: 12px;
font-weight: 800;
line-height: 1.48;
}
.root :global(.scan-v4-brief-grid li::before) {
content: "";
position: absolute;
top: 0.65em;
left: 0;
width: 5px;
height: 5px;
border-radius: 999px;
background: #4DA3FF;
box-shadow: 0 0 10px rgba(45, 212, 191, 0.45);
}
.root :global(.scan-v4-brief-grid section:nth-child(2) li::before) {
background: #f59e0b;
box-shadow: 0 0 10px rgba(245, 158, 11, 0.35);
}
.root :global(details.scan-v4-evidence) {
padding-top: 11px;
border-top: 1px solid rgba(90, 123, 166, 0.12);
}
.root :global(details.scan-v4-evidence summary) {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 0 11px;
border: 1px solid rgba(65, 118, 216, 0.28);
border-radius: 8px;
background: rgba(10, 30, 58, 0.52);
color: #8fbdff;
font-size: 12px;
font-weight: 950;
cursor: pointer;
list-style: none;
}
.root :global(details.scan-v4-evidence summary::-webkit-details-marker) {
display: none;
}
.root :global(details.scan-v4-evidence summary::after) {
content: "v";
color: #8fbdff;
font-size: 12px;
}
.root :global(details.scan-v4-evidence[open] summary::after) {
content: "^";
}
.root :global(details.scan-v4-evidence > div) {
margin-top: 10px;
}
.root :global(.scan-forecast-desk) {
gap: 16px;
padding: 16px 18px 20px;
}
.root :global(.scan-ai-workspace) {
flex: 1;
min-height: 0;
max-height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid rgba(77, 163, 255, 0.16);
border-radius: 18px;
background: #0b1220;
padding: 18px;
}
.root :global(.scan-ai-workspace::-webkit-scrollbar) {
width: 8px;
}
.root :global(.scan-ai-workspace::-webkit-scrollbar-thumb) {
border-radius: 999px;
background: rgba(77, 163, 255, 0.28);
}
.root :global(.scan-ai-workspace.empty) {
display: grid;
place-items: center;
}
.root :global(.scan-ai-workspace-head) {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: flex-end;
margin-bottom: 18px;
padding: 0 2px;
}
.root :global(.scan-ai-workspace-head span) {
display: block;
color: #4da3ff;
font-size: 12px;
font-weight: 800;
letter-spacing: 0;
}
.root :global(.scan-ai-workspace-head strong) {
display: block;
color: #e6edf3;
font-size: 22px;
line-height: 1.2;
margin-top: 4px;
}
.root :global(.scan-ai-workspace-head p) {
max-width: 560px;
margin: 0;
color: #9fb2c7;
font-size: 13px;
line-height: 1.55;
text-align: right;
}
@@ -978,6 +978,8 @@
border-radius: 18px;
background: rgba(13, 17, 23, 0.38);
padding: 14px;
content-visibility: auto;
contain-intrinsic-size: 520px;
}
.root :global(.scan-opportunity-lane-head) {
@@ -0,0 +1,249 @@
/* Scan terminal shell, topbar, upgrade announcement, and shared action buttons.
Extracted from ScanTerminal.module.css to keep terminal layout CSS maintainable. */
.root :global(.scan-topbar) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 0 18px;
border-bottom: 1px solid rgba(118, 146, 188, 0.1);
}
.root :global(.scan-topbar-title) {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.root :global(.scan-topbar-title strong) {
margin: 0;
font-size: 28px;
line-height: 1.08;
letter-spacing: -0.04em;
color: #f3f8ff;
}
.root :global(.scan-topbar-title span) {
color: #8fa4c3;
font-size: 14px;
line-height: 1.5;
}
.root :global(.scan-topbar-actions) {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 12px;
color: #b4c8e2;
font-size: 14px;
}
.root :global(.scan-locale-switch) {
height: 36px;
padding: 3px;
border-radius: 12px;
border: 1px solid rgba(99, 132, 180, 0.18);
background: rgba(8, 19, 34, 0.9);
color: #a8bdd8;
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.root :global(.scan-locale-switch span) {
min-width: 34px;
height: 28px;
padding: 0 8px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.root :global(.scan-locale-switch span.active) {
color: #eef7ff;
background: rgba(63, 140, 255, 0.16);
box-shadow: inset 0 0 0 1px rgba(63, 140, 255, 0.22);
}
.root :global(.scan-topbar-time) {
color: #b4c8e2;
font-variant-numeric: tabular-nums;
}
.root :global(.scan-upgrade-announcement) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
margin: -4px 0 6px;
border: 1px solid rgba(77, 163, 255, 0.2);
border-radius: 18px;
background:
radial-gradient(circle at top left, rgba(77, 163, 255, 0.18), transparent 36%),
linear-gradient(135deg, rgba(18, 33, 58, 0.96), rgba(15, 23, 42, 0.92));
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.2);
padding: 16px 18px;
}
.root :global(.scan-upgrade-announcement-copy) {
min-width: 0;
}
.root :global(.scan-upgrade-announcement-copy span) {
color: #86efac;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.root :global(.scan-upgrade-announcement-copy strong) {
display: block;
margin-top: 5px;
color: #f3f8ff;
font-size: 18px;
line-height: 1.25;
}
.root :global(.scan-upgrade-announcement-copy p) {
margin: 6px 0 0;
color: #b4c8e2;
font-size: 13px;
line-height: 1.55;
}
.root :global(.scan-upgrade-announcement ul) {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.root :global(.scan-upgrade-announcement li) {
border: 1px solid rgba(77, 163, 255, 0.22);
border-radius: 999px;
background: rgba(77, 163, 255, 0.1);
color: #d7e5f7;
font-size: 12px;
font-weight: 850;
padding: 7px 10px;
}
.root :global(.scan-ghost-button),
.root :global(.scan-cta-ghost),
.root :global(.scan-theme-button),
.root :global(.scan-detail-action-button),
.root :global(.scan-detail-icon-button),
.root :global(.scan-view-all-button) {
border: 1px solid rgba(99, 132, 180, 0.18);
background: rgba(8, 19, 34, 0.9);
color: #d7e5f7;
border-radius: 12px;
}
.root :global(.scan-ghost-button),
.root :global(.scan-cta-ghost) {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 11px 14px;
font-weight: 700;
}
.root :global(.scan-primary-button) {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 11px 16px;
border: 1px solid rgba(77, 163, 255, 0.42);
border-radius: 12px;
background: linear-gradient(180deg, #4DA3FF, #3B82F6);
color: #FFFFFF;
font-size: 14px;
font-weight: 900;
text-decoration: none;
box-shadow: 0 10px 24px rgba(59, 130, 246, 0.22);
cursor: pointer;
}
.root :global(.scan-primary-button:hover:not(:disabled)) {
filter: brightness(1.06);
}
.root :global(.scan-primary-button:disabled) {
cursor: not-allowed;
opacity: 0.68;
}
.root :global(.scan-ai-button) {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 11px 14px;
border: 1px solid rgba(77, 163, 255, 0.3);
border-radius: 12px;
background: linear-gradient(180deg, rgba(77, 163, 255, 0.18), rgba(59, 130, 246, 0.12));
color: #CFE6FF;
font-size: 13px;
font-weight: 900;
cursor: pointer;
}
.root :global(.scan-ai-button:hover:not(:disabled)) {
border-color: rgba(111, 183, 255, 0.5);
background: linear-gradient(180deg, rgba(77, 163, 255, 0.26), rgba(59, 130, 246, 0.18));
}
.root :global(.scan-ai-button:disabled) {
cursor: not-allowed;
opacity: 0.55;
}
.root :global(.scan-cta-ghost) {
color: #6FB7FF;
border-color: rgba(23, 217, 139, 0.3);
background: rgba(23, 217, 139, 0.12);
}
.root :global(.scan-account-button) {
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #d7e5f7;
text-decoration: none;
border: 1px solid rgba(99, 132, 180, 0.18);
background: rgba(8, 19, 34, 0.9);
border-radius: 12px;
}
.root :global(.scan-account-button:hover) {
border-color: rgba(63, 140, 255, 0.28);
background: rgba(12, 26, 44, 0.96);
}
.root :global(.scan-theme-button) {
width: 36px;
height: 36px;
display: inline-grid;
place-items: center;
color: #d7e5f7;
cursor: pointer;
}
.root :global(.scan-ghost-button .spin) {
animation: spin 1s linear infinite;
}
@@ -0,0 +1,242 @@
/* Scan terminal empty, loading, and skeleton signal states. */
.root :global(.scan-empty-state) {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 48px 32px;
text-align: center;
}
.root :global(.scan-empty-title) {
font-size: 20px;
font-weight: 800;
color: #e8f2ff;
}
.root :global(.scan-empty-copy) {
margin-top: 10px;
font-size: 14px;
color: #8fa4c3;
line-height: 1.6;
}
.root :global(.scan-loading-state) {
flex: 1;
min-height: 520px;
display: grid;
place-items: center;
padding: 48px 32px;
text-align: center;
}
.root :global(.scan-loading-signal) {
position: relative;
width: min(100%, 420px);
display: grid;
justify-items: center;
gap: 18px;
padding: 28px 30px;
border: 1px solid rgba(125, 211, 252, 0.14);
border-radius: 28px;
background:
radial-gradient(circle at 18% 18%, rgba(77, 163, 255, 0.18), transparent 34%),
linear-gradient(145deg, rgba(12, 20, 36, 0.92), rgba(5, 11, 22, 0.74));
box-shadow:
0 22px 70px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
overflow: hidden;
}
.root :global(.scan-loading-signal::before) {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 0%,
rgba(125, 211, 252, 0.08) 42%,
rgba(34, 197, 94, 0.1) 50%,
transparent 58%
);
transform: translateX(-120%);
animation: scan-loading-sweep 2.6s ease-in-out infinite;
pointer-events: none;
}
.root :global(.scan-loading-signal.compact) {
width: min(100%, 340px);
gap: 12px;
padding: 18px 20px;
border-radius: 22px;
background:
radial-gradient(circle at 20% 20%, rgba(77, 163, 255, 0.14), transparent 36%),
rgba(10, 18, 32, 0.62);
box-shadow: none;
}
.root :global(.scan-loading-decision-flow) {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 16px minmax(70px, 1fr) 16px minmax(70px, 1fr) 16px;
align-items: center;
width: min(100%, 300px);
}
.root :global(.scan-loading-node) {
width: 16px;
height: 16px;
border: 1px solid rgba(226, 232, 240, 0.16);
border-radius: 999px;
background: #0b1220;
box-shadow: 0 0 0 5px rgba(77, 163, 255, 0.05);
}
.root :global(.scan-loading-node.hot) {
background: linear-gradient(135deg, #f97316, #fde047);
box-shadow: 0 0 24px rgba(251, 146, 60, 0.26);
}
.root :global(.scan-loading-node.market) {
background: linear-gradient(135deg, #38bdf8, #4da3ff);
box-shadow: 0 0 24px rgba(56, 189, 248, 0.26);
}
.root :global(.scan-loading-node.action) {
background: linear-gradient(135deg, #22c55e, #1be392);
box-shadow: 0 0 24px rgba(34, 197, 94, 0.3);
animation: scan-loading-node-breathe 1.8s ease-in-out infinite;
}
.root :global(.scan-loading-rail) {
position: relative;
height: 2px;
overflow: hidden;
background: rgba(148, 163, 184, 0.16);
}
.root :global(.scan-loading-rail i) {
position: absolute;
inset: 0;
width: 48%;
border-radius: 999px;
background: linear-gradient(90deg, transparent, #7dd3fc, #22c55e);
transform: translateX(-110%);
animation: scan-loading-rail-flow 1.55s ease-in-out infinite;
}
.root :global(.scan-loading-rail:nth-of-type(4) i) {
animation-delay: 0.45s;
}
.root :global(.scan-loading-copy-block) {
position: relative;
z-index: 1;
display: grid;
gap: 7px;
justify-items: center;
}
.root :global(.scan-loading-copy-block strong) {
color: #e6edf3;
font-size: 18px;
font-weight: 900;
letter-spacing: -0.02em;
}
.root :global(.scan-loading-copy-block span) {
max-width: 320px;
color: #9fb2c7;
font-size: 13px;
font-weight: 650;
line-height: 1.55;
}
.root :global(.scan-loading-signal-bars) {
position: relative;
z-index: 1;
display: flex;
align-items: end;
gap: 5px;
height: 28px;
}
.root :global(.scan-loading-signal-bars span) {
width: 7px;
min-height: 8px;
border-radius: 999px 999px 4px 4px;
background: linear-gradient(180deg, #7dd3fc, #4da3ff 52%, #1be392);
opacity: 0.42;
transform-origin: center bottom;
animation: scan-loading-bars 1.35s ease-in-out infinite;
}
.root :global(.scan-loading-signal-bars span:nth-child(1)) {
height: 12px;
}
.root :global(.scan-loading-signal-bars span:nth-child(2)) {
height: 22px;
animation-delay: 0.16s;
}
.root :global(.scan-loading-signal-bars span:nth-child(3)) {
height: 16px;
animation-delay: 0.32s;
}
.root :global(.scan-loading-signal-bars span:nth-child(4)) {
height: 25px;
animation-delay: 0.48s;
}
@keyframes scan-loading-sweep {
0% {
transform: translateX(-130%);
}
46%,
100% {
transform: translateX(130%);
}
}
@keyframes scan-loading-rail-flow {
0% {
transform: translateX(-110%);
opacity: 0.15;
}
45% {
opacity: 1;
}
100% {
transform: translateX(220%);
opacity: 0.15;
}
}
@keyframes scan-loading-node-breathe {
0%,
100% {
transform: scale(0.92);
filter: saturate(0.9);
}
50% {
transform: scale(1.08);
filter: saturate(1.25);
}
}
@keyframes scan-loading-bars {
0%,
100% {
opacity: 0.38;
transform: scaleY(0.72);
}
50% {
opacity: 0.95;
transform: scaleY(1);
}
}
@@ -0,0 +1,213 @@
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { formatQuoteCents } from "./opportunity-format";
import { getLocalizedRowText } from "./opportunity-copy";
import { getModelSourceSummary } from "./opportunity-model-summary";
export function getOpportunityStrength(edgePercent?: number | null, locale = "zh-CN") {
const edge = Number(edgePercent);
const normalized = Number.isFinite(edge) ? edge : 0;
if (normalized >= 20) {
return {
label: locale === "en-US" ? "High confidence" : "高胜率",
tone: "strong",
};
}
if (normalized >= 10) {
return {
label: locale === "en-US" ? "Medium confidence" : "中等胜率",
tone: "medium",
};
}
return {
label: locale === "en-US" ? "Watch" : "观察",
tone: "watch",
};
}
export function getShortAiConclusion(
row: ScanOpportunityRow,
locale: string,
_edgePercent?: number | null,
strengthLabel?: string,
) {
const directReason =
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
getLocalizedRowText(
row,
locale,
row.ai_watchlist_reason_zh,
row.ai_watchlist_reason_en,
);
if (directReason) return directReason;
const cityThesis = getLocalizedRowText(
row,
locale,
row.ai_city_thesis_zh,
row.ai_city_thesis_en,
);
if (cityThesis) return cityThesis;
const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol);
if (locale === "en-US") {
return `${strengthLabel || "Watch"}: AI should validate against ${modelBasis}.`;
}
return `${strengthLabel || "观察"}AI 需结合${modelBasis}确认。`;
}
export function getRiskHints(
row: ScanOpportunityRow,
locale: string,
modelProbability?: number | null,
) {
const hints: string[] = [];
const spread = Number(row.spread);
if (Number.isFinite(spread) && spread > 0.03) {
hints.push(
locale === "en-US"
? `Wide spread ${formatQuoteCents(spread)} may distort the displayed market price.`
: `盘口价差 ${formatQuoteCents(spread)} 偏宽,可能扭曲市场价格参考。`,
);
}
const quoteAgeSeconds =
row.quote_age_ms != null && Number.isFinite(Number(row.quote_age_ms))
? Math.round(Number(row.quote_age_ms) / 1000)
: null;
if (quoteAgeSeconds != null && quoteAgeSeconds > 60) {
hints.push(
locale === "en-US"
? `Quote age ${quoteAgeSeconds}s; refresh before acting.`
: `报价已 ${quoteAgeSeconds}s,执行前需要刷新。`,
);
}
if (row.trend_alignment === false) {
hints.push(
locale === "en-US"
? "Intraday trend does not fully support this direction."
: "日内趋势未完全支持该方向。",
);
}
if (row.cluster_adjusted) {
hints.push(
locale === "en-US"
? "Tail bucket was cluster-adjusted; bucket confidence may be overstated."
: "尾部桶已做模型集群折扣,温度桶信心可能偏乐观。",
);
}
if (modelProbability != null && modelProbability < 10) {
hints.push(
locale === "en-US"
? "Low model probability makes the setup sensitive to calibration error."
: "模型概率偏低,校准误差会显著影响判断。",
);
}
if (!hints.length) {
hints.push(
locale === "en-US"
? "Main residual risk is late observation updates or a shifted peak window."
: "主要残余风险是后续实测升温或峰值窗口漂移。",
);
}
return hints;
}
export function getRecommendationReasons(
row: ScanOpportunityRow,
locale: string,
_edgePercent?: number | null,
price?: number | null,
) {
const reasons: string[] = [];
const aiReason = getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en);
if (aiReason && String(row.ai_decision || "").toLowerCase() === "approve") {
reasons.push(aiReason);
}
const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol);
reasons.push(
locale === "en-US"
? `AI uses ${modelBasis} with market ask ${formatQuoteCents(price)} only as downstream bucket context.`
: `AI 以${modelBasis}为主,市场买价 ${formatQuoteCents(price)} 只作下游温度桶参考。`,
);
if (row.peak_alignment_score != null) {
reasons.push(
locale === "en-US"
? `Peak alignment score ${Number(row.peak_alignment_score).toFixed(2)} supports checking this bucket.`
: `峰值对齐分 ${Number(row.peak_alignment_score).toFixed(2)},支持把该桶纳入检查。`,
);
}
return reasons.slice(0, 3);
}
export function getExclusionReasons(
row: ScanOpportunityRow,
locale: string,
edgePercent?: number | null,
) {
const decision = String(row.ai_decision || "").toLowerCase();
const aiReason =
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
getLocalizedRowText(
row,
locale,
row.ai_watchlist_reason_zh,
row.ai_watchlist_reason_en,
);
if (decision === "veto" || decision === "downgrade" || decision === "watchlist") {
return [
aiReason ||
(locale === "en-US"
? "AI did not classify this row as the primary forecast bucket."
: "AI 未把该合约列为主预测桶。"),
];
}
if (edgePercent != null && Number(edgePercent) < 10) {
return [
locale === "en-US"
? "This bucket is not the current forecast center."
: "该桶不是当前预测中枢。",
];
}
return [
locale === "en-US"
? "No hard veto in the current AI/rule snapshot."
: "当前 AI/规则快照没有硬性排除项。",
];
}
export function getAiMeta(row: ScanOpportunityRow, locale: string) {
const decision = String(row.ai_decision || "").toLowerCase();
if (decision === "veto") {
return {
label: locale === "en-US" ? "AI veto" : "AI 排除",
tone: "veto",
reason: locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en,
};
}
if (decision === "downgrade") {
return {
label: locale === "en-US" ? "AI downgrade" : "AI 降级",
tone: "downgrade",
reason: locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en,
};
}
if (row.ai_rank != null || decision === "approve") {
return {
label: locale === "en-US" ? `AI pick ${row.ai_rank || ""}`.trim() : `AI 推荐 ${row.ai_rank || ""}`.trim(),
tone: "approve",
reason:
(locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en) ||
row.ai_model_cluster_note ||
null,
};
}
if (decision === "watchlist") {
return {
label: locale === "en-US" ? "AI watch" : "AI 观察",
tone: "downgrade",
reason:
locale === "en-US"
? row.ai_watchlist_reason_en || row.ai_watchlist_reason_zh
: row.ai_watchlist_reason_zh || row.ai_watchlist_reason_en,
};
}
return null;
}
@@ -0,0 +1,274 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import { formatTemperatureValue } from "@/lib/temperature-utils";
export function decodeRawMetarCloud(rawMetar?: string | null, locale = "zh-CN") {
const raw = String(rawMetar || "").toUpperCase();
const matches = Array.from(raw.matchAll(/\b(FEW|SCT|BKN|OVC)(\d{3})?\b/g));
if (!matches.length) return "";
const coverText: Record<string, { zh: string; en: string }> = {
FEW: { zh: "少云", en: "few" },
SCT: { zh: "散云", en: "scattered" },
BKN: { zh: "多云", en: "broken" },
OVC: { zh: "阴天", en: "overcast" },
};
return matches
.slice(0, 3)
.map((match) => {
const cover = coverText[match[1]] || { zh: match[1], en: match[1] };
const base = match[2] ? `${Number(match[2]) * 100}ft` : "";
return locale === "en-US"
? [cover.en, base].filter(Boolean).join(" ")
: [cover.zh, base].filter(Boolean).join(" ");
})
.join(locale === "en-US" ? ", " : "、");
}
export function decodeRawMetarVisibility(rawMetar?: string | null) {
const raw = String(rawMetar || "").toUpperCase();
if (/\b9999\b/.test(raw)) return "10km+";
const meterMatch = raw.match(/\b(\d{4})\b/);
if (meterMatch) return `${Number(meterMatch[1]) / 1000}km`;
return "";
}
export function decodeMetarWeatherToken(token?: string | null, locale = "zh-CN") {
const raw = String(token || "").trim().toUpperCase();
if (!raw) return "";
const isEn = locale === "en-US";
const intensity = raw.startsWith("-")
? isEn
? "light "
: "轻"
: raw.startsWith("+")
? isEn
? "heavy "
: "强"
: "";
const cleaned = raw.replace(/^[+-]/, "");
const descriptors: Record<string, { zh: string; en: string }> = {
VC: { zh: "附近", en: "nearby " },
SH: { zh: "阵性", en: "showery " },
TS: { zh: "雷暴性", en: "thunderstorm " },
FZ: { zh: "冻", en: "freezing " },
BL: { zh: "吹扬", en: "blowing " },
DR: { zh: "低吹", en: "drifting " },
MI: { zh: "浅层", en: "shallow " },
BC: { zh: "碎片状", en: "patches of " },
PR: { zh: "部分", en: "partial " },
};
const phenomena: Record<string, { zh: string; en: string }> = {
DZ: { zh: "毛毛雨", en: "drizzle" },
RA: { zh: "雨", en: "rain" },
SN: { zh: "雪", en: "snow" },
SG: { zh: "米雪", en: "snow grains" },
IC: { zh: "冰晶", en: "ice crystals" },
PL: { zh: "冰粒", en: "ice pellets" },
GR: { zh: "冰雹", en: "hail" },
GS: { zh: "小冰雹", en: "small hail" },
UP: { zh: "未知降水", en: "unknown precipitation" },
BR: { zh: "薄雾", en: "mist" },
FG: { zh: "雾", en: "fog" },
FU: { zh: "烟", en: "smoke" },
VA: { zh: "火山灰", en: "volcanic ash" },
DU: { zh: "浮尘", en: "dust" },
SA: { zh: "沙", en: "sand" },
HZ: { zh: "霾", en: "haze" },
PY: { zh: "喷雾", en: "spray" },
PO: { zh: "尘卷风", en: "dust whirls" },
SQ: { zh: "飑", en: "squall" },
FC: { zh: "漏斗云", en: "funnel cloud" },
SS: { zh: "沙暴", en: "sandstorm" },
DS: { zh: "尘暴", en: "duststorm" },
};
const descriptorText = Object.entries(descriptors)
.filter(([code]) => cleaned.includes(code))
.map(([, text]) => (isEn ? text.en : text.zh))
.join("");
const phenomenonText = Object.entries(phenomena)
.filter(([code]) => cleaned.includes(code))
.map(([, text]) => (isEn ? text.en : text.zh))
.join(isEn ? " / " : "、");
if (!phenomenonText) return "";
return `${intensity}${descriptorText}${phenomenonText}`;
}
export function decodeRawMetarWeather(rawMetar?: string | null, locale = "zh-CN") {
const raw = String(rawMetar || "").toUpperCase();
const matches = Array.from(
raw.matchAll(/\b([+-]?(?:VC)?(?:MI|PR|BC|DR|BL|SH|TS|FZ)?(?:DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS))\b/g),
);
return Array.from(
new Set(
matches
.map((match) => decodeMetarWeatherToken(match[1], locale))
.filter(Boolean),
),
).join(locale === "en-US" ? ", " : "、");
}
export function getAirportWeatherInputs(row: ScanOpportunityRow, detail: CityDetail | null) {
const context = row.metar_context || {};
const airport: Partial<NonNullable<CityDetail["airport_current"]>> =
detail?.airport_current || {};
const rawMetar = String(context.airport_raw_metar || airport.raw_metar || "").trim();
return {
cloud: String(context.airport_cloud_desc || airport.cloud_desc || "").trim(),
rawMetar,
visibility:
context.airport_visibility_mi != null && Number.isFinite(Number(context.airport_visibility_mi))
? Number(context.airport_visibility_mi)
: airport.visibility_mi != null && Number.isFinite(Number(airport.visibility_mi))
? Number(airport.visibility_mi)
: null,
weather: String(context.airport_wx_desc || airport.wx_desc || "").trim(),
windSpeed:
context.airport_wind_speed_kt != null && Number.isFinite(Number(context.airport_wind_speed_kt))
? Number(context.airport_wind_speed_kt)
: airport.wind_speed_kt != null && Number.isFinite(Number(airport.wind_speed_kt))
? Number(airport.wind_speed_kt)
: null,
};
}
export function formatAirportWeatherRead(
row: ScanOpportunityRow,
detail: CityDetail | null,
locale: string,
) {
const isEn = locale === "en-US";
const inputs = getAirportWeatherInputs(row, detail);
const decodedCloud = inputs.cloud || decodeRawMetarCloud(inputs.rawMetar, locale);
const decodedWeather =
decodeMetarWeatherToken(inputs.weather, locale) ||
inputs.weather ||
decodeRawMetarWeather(inputs.rawMetar, locale);
const visibilityText =
inputs.visibility != null ? `${inputs.visibility.toFixed(1)}mi` : decodeRawMetarVisibility(inputs.rawMetar);
const cloudRaw = `${inputs.cloud} ${inputs.rawMetar}`.toUpperCase();
const weatherRaw = `${inputs.weather} ${inputs.rawMetar}`.toUpperCase();
const suppressors: string[] = [];
const supporters: string[] = [];
if (/(RA|DZ|SN|TS|SH|FG|BR|HZ|OVC|BKN)/.test(weatherRaw) || /(OVC|BKN)/.test(cloudRaw)) {
suppressors.push(
isEn
? "cloud, precipitation or restricted visibility can suppress solar heating"
: "云雨、薄雾或低能见度会压制太阳辐射升温",
);
}
if (inputs.visibility != null && inputs.visibility < 6) {
suppressors.push(
isEn
? `visibility is only ${visibilityText}, so the airport path may warm more slowly`
: `能见度仅 ${visibilityText},机场路径可能升温偏慢`,
);
}
if (/(FEW|SCT)/.test(cloudRaw) && !/(RA|DZ|SN|TS|FG|BR|HZ|OVC|BKN)/.test(weatherRaw)) {
supporters.push(
isEn
? "few or scattered clouds do not block the heating path materially"
: "少云或散云对日间升温压制不明显",
);
}
if (inputs.windSpeed != null && inputs.windSpeed >= 15) {
suppressors.push(
isEn
? "stronger wind mixing can change the airport temperature path"
: "风速偏大,边界层混合可能改写机场温度路径",
);
} else if (inputs.windSpeed != null && inputs.windSpeed <= 5 && !suppressors.length) {
supporters.push(
isEn
? "light wind leaves the temperature path mainly driven by local sunshine"
: "风速较弱,温度路径更取决于本地日照",
);
}
const descriptors = [
decodedWeather ? (isEn ? `weather ${decodedWeather}` : `天气 ${decodedWeather}`) : null,
decodedCloud ? (isEn ? `cloud ${decodedCloud}` : `云况 ${decodedCloud}`) : null,
visibilityText ? (isEn ? `visibility ${visibilityText}` : `能见度 ${visibilityText}`) : null,
].filter(Boolean);
const read = suppressors[0] || supporters[0];
if (!descriptors.length && !read) return null;
const prefix = isEn ? "Airport weather read" : "机场气象解读";
const evidence = descriptors.length ? `${descriptors.join(isEn ? ", " : "")}` : "";
return `${prefix}${evidence}${read || (isEn ? "no clear weather suppression signal yet" : "暂未看到明确天气压温信号")}`;
}
export function formatAirportReportRead(
row: ScanOpportunityRow,
detail: CityDetail | null,
locale: string,
tempSymbol?: string | null,
) {
const isEn = locale === "en-US";
const context = row.metar_context || {};
const airport: Partial<NonNullable<CityDetail["airport_current"]>> =
detail?.airport_current || {};
const station =
context.station ||
detail?.risk?.icao ||
airport.station_code ||
null;
const obsTime =
context.airport_obs_time ||
context.last_time ||
airport.obs_time ||
row.metar_status?.last_observation_time ||
null;
const temp =
context.airport_current_temp != null && Number.isFinite(Number(context.airport_current_temp))
? Number(context.airport_current_temp)
: airport.temp != null && Number.isFinite(Number(airport.temp))
? Number(airport.temp)
: null;
const windSpeed =
context.airport_wind_speed_kt != null && Number.isFinite(Number(context.airport_wind_speed_kt))
? Number(context.airport_wind_speed_kt)
: airport.wind_speed_kt != null && Number.isFinite(Number(airport.wind_speed_kt))
? Number(airport.wind_speed_kt)
: null;
const windDir =
context.airport_wind_dir != null && Number.isFinite(Number(context.airport_wind_dir))
? Number(context.airport_wind_dir)
: airport.wind_dir != null && Number.isFinite(Number(airport.wind_dir))
? Number(airport.wind_dir)
: null;
const cloud = String(context.airport_cloud_desc || airport.cloud_desc || "").trim();
const weather = String(context.airport_wx_desc || airport.wx_desc || "").trim();
const rawMetar = String(context.airport_raw_metar || airport.raw_metar || "").trim();
const decodedCloud = cloud || decodeRawMetarCloud(rawMetar, locale);
const decodedWeather =
decodeMetarWeatherToken(weather, locale) ||
weather ||
decodeRawMetarWeather(rawMetar, locale);
const visibility =
context.airport_visibility_mi != null && Number.isFinite(Number(context.airport_visibility_mi))
? Number(context.airport_visibility_mi)
: airport.visibility_mi != null && Number.isFinite(Number(airport.visibility_mi))
? Number(airport.visibility_mi)
: null;
const decodedVisibility = visibility != null ? `${visibility.toFixed(1)}mi` : decodeRawMetarVisibility(rawMetar);
const parts: string[] = [];
if (temp != null) parts.push(formatTemperatureValue(temp, tempSymbol, { digits: 1 }));
if (windSpeed != null) {
parts.push(
windDir != null
? isEn
? `wind ${Math.round(windDir)}°/${Math.round(windSpeed)}kt`
: `${Math.round(windDir)}°/${Math.round(windSpeed)}kt`
: isEn
? `wind ${Math.round(windSpeed)}kt`
: `${Math.round(windSpeed)}kt`,
);
}
if (decodedCloud) parts.push(isEn ? `cloud ${decodedCloud}` : `云况 ${decodedCloud}`);
if (decodedWeather) parts.push(isEn ? `weather ${decodedWeather}` : `天气 ${decodedWeather}`);
if (decodedVisibility) parts.push(isEn ? `visibility ${decodedVisibility}` : `能见度 ${decodedVisibility}`);
if (!parts.length) return null;
const prefix = isEn ? "Latest airport METAR read" : "最新机场报文解读";
const head = [station, obsTime].filter(Boolean).join(" ");
return `${prefix}${head ? ` ${head}` : ""}${parts.join("")}`;
}
@@ -0,0 +1,10 @@
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
export function getLocalizedRowText(
row: ScanOpportunityRow,
locale: string,
zh?: string | null,
en?: string | null,
) {
return locale === "en-US" ? en || zh || null : zh || en || null;
}
@@ -0,0 +1,37 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
export function normalizeLookupKey(value?: string | null) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, "");
}
export function getDetailForRow(
row: Pick<ScanOpportunityRow, "city" | "city_display_name" | "display_name">,
cityDetailsByName?: Record<string, CityDetail>,
) {
if (!cityDetailsByName) return null;
const rowKeys = [row.city, row.city_display_name, row.display_name]
.map(normalizeLookupKey)
.filter(Boolean);
return (
Object.entries(cityDetailsByName).find(([name, detail]) => {
const detailKeys = [name, detail.name, detail.display_name]
.map(normalizeLookupKey)
.filter(Boolean);
return rowKeys.some((key) => detailKeys.includes(key));
})?.[1] || null
);
}
export function getDetailViewDate(detail: CityDetail, row?: ScanOpportunityRow | null) {
if (!row) return detail.local_date;
const rawDate = row.selected_date || row.local_date || "";
const phase = String(row.window_phase || "").toLowerCase();
if ((phase === "tomorrow" || phase === "week_ahead") && rawDate) return rawDate;
if (!rawDate || rawDate === detail.local_date || row.local_date === detail.local_date) {
return detail.local_date;
}
return detail.local_date || rawDate;
}
@@ -0,0 +1,127 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import { formatTemperatureValue } from "@/lib/temperature-utils";
import { formatTemperatureDelta } from "./opportunity-format";
import { getMetarObservationContext } from "./opportunity-observation";
import { getTargetRange } from "./opportunity-target";
export function getDebDistanceSummary(
row: ScanOpportunityRow,
locale: string,
tempSymbol?: string | null,
) {
const deb =
row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
? Number(row.deb_prediction)
: null;
if (deb == null) return locale === "en-US" ? "DEB pending" : "DEB 待确认";
const { lower, upper } = getTargetRange(row);
if (lower != null && upper == null) {
const delta = deb - lower;
if (Math.abs(delta) < 0.05) return locale === "en-US" ? "DEB on threshold" : "DEB 贴近阈值";
return delta >= 0
? locale === "en-US"
? `DEB above by ${formatTemperatureDelta(delta, tempSymbol)}`
: `DEB 高于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`
: locale === "en-US"
? `DEB below by ${formatTemperatureDelta(delta, tempSymbol)}`
: `DEB 低于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`;
}
if (upper != null && lower == null) {
const delta = deb - upper;
if (Math.abs(delta) < 0.05) return locale === "en-US" ? "DEB on threshold" : "DEB 贴近阈值";
return delta <= 0
? locale === "en-US"
? `DEB below by ${formatTemperatureDelta(delta, tempSymbol)}`
: `DEB 低于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`
: locale === "en-US"
? `DEB above by ${formatTemperatureDelta(delta, tempSymbol)}`
: `DEB 高于阈值 ${formatTemperatureDelta(delta, tempSymbol)}`;
}
if (lower != null && upper != null) {
if (deb >= lower && deb <= upper) return locale === "en-US" ? "DEB inside bucket" : "DEB 位于桶内";
const nearest = deb < lower ? lower : upper;
const delta = deb - nearest;
return deb < lower
? locale === "en-US"
? `DEB below bucket by ${formatTemperatureDelta(delta, tempSymbol)}`
: `DEB 低于桶 ${formatTemperatureDelta(delta, tempSymbol)}`
: locale === "en-US"
? `DEB above bucket by ${formatTemperatureDelta(delta, tempSymbol)}`
: `DEB 高于桶 ${formatTemperatureDelta(delta, tempSymbol)}`;
}
return locale === "en-US"
? `DEB ${formatTemperatureValue(deb, tempSymbol, { digits: 1 })}`
: `DEB ${formatTemperatureValue(deb, tempSymbol, { digits: 1 })}`;
}
export function getModelSupportSummary(
row: ScanOpportunityRow,
locale: string,
) {
const sources = Object.values(row.model_cluster_sources || {})
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
const deb =
row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
? Number(row.deb_prediction)
: null;
if (!sources.length || deb == null) return locale === "en-US" ? "Models pending" : "模型待确认";
const { lower, upper } = getTargetRange(row);
let supports = 0;
if (lower != null && upper == null) {
supports = sources.filter((value) => (deb >= lower ? value >= lower : value < lower)).length;
} else if (upper != null && lower == null) {
supports = sources.filter((value) => (deb <= upper ? value <= upper : value > upper)).length;
} else if (lower != null && upper != null) {
if (deb >= lower && deb <= upper) {
supports = sources.filter((value) => value >= lower && value <= upper).length;
} else if (deb < lower) {
supports = sources.filter((value) => value < lower).length;
} else {
supports = sources.filter((value) => value > upper).length;
}
} else {
const tolerance = 1;
supports = sources.filter((value) => Math.abs(value - deb) <= tolerance).length;
}
return locale === "en-US"
? `${supports}/${sources.length} models support DEB`
: `${supports}/${sources.length} 模型支持 DEB`;
}
export function getMetarConflictSummary(
row: ScanOpportunityRow,
detail: CityDetail | null,
locale: string,
) {
const obs = getMetarObservationContext(row, detail);
if (obs.stale || obs.maxTemp == null) return locale === "en-US" ? "METAR pending" : "METAR 待确认";
const deb =
row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
? Number(row.deb_prediction)
: null;
const { lower, upper } = getTargetRange(row);
if (deb == null || (lower == null && upper == null)) {
return locale === "en-US" ? "METAR read only" : "METAR 仅参考";
}
const phase = String(row.window_phase || "").toLowerCase();
const peakPending =
phase === "early_today" ||
phase === "setup_today" ||
(row.minutes_until_peak_start != null && Number(row.minutes_until_peak_start) > 0);
if (lower != null && upper == null) {
if (deb < lower && obs.maxTemp >= lower) return locale === "en-US" ? "METAR conflicts" : "METAR 冲突";
if (deb >= lower && obs.maxTemp < lower && peakPending) return locale === "en-US" ? "Await peak" : "等待峰值";
return locale === "en-US" ? "METAR no conflict" : "METAR 未冲突";
}
if (upper != null && lower == null) {
if (deb <= upper && obs.maxTemp > upper) return locale === "en-US" ? "METAR conflicts" : "METAR 冲突";
if (deb > upper && obs.maxTemp <= upper && peakPending) return locale === "en-US" ? "Await peak" : "等待峰值";
return locale === "en-US" ? "METAR no conflict" : "METAR 未冲突";
}
if (lower != null && upper != null && deb >= lower && deb <= upper) {
if (obs.maxTemp > upper) return locale === "en-US" ? "METAR above bucket" : "METAR 已越过桶";
if (obs.maxTemp < lower && peakPending) return locale === "en-US" ? "Await peak" : "等待峰值";
}
return locale === "en-US" ? "METAR no conflict" : "METAR 未冲突";
}
@@ -0,0 +1,124 @@
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import {
formatTemperatureValue,
normalizeTemperatureLabel,
} from "@/lib/temperature-utils";
import { getTargetRange } from "./opportunity-target";
export function formatPercent(value?: number | null, signed = false) {
if (value == null || Number.isNaN(Number(value))) return "--";
const numeric = Number(value);
return `${signed && numeric >= 0 ? "+" : ""}${numeric.toFixed(1)}%`;
}
export function normalizeProbability(value?: number | null) {
if (value == null) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
return Math.max(0, Math.min(1, numeric > 1 ? numeric / 100 : numeric));
}
export function formatWindowMinutes(value: number | null | undefined, locale: string) {
if (value == null || !Number.isFinite(Number(value))) return "--";
const minutes = Math.max(0, Math.round(Number(value)));
const hours = Math.floor(minutes / 60);
const remains = minutes % 60;
if (locale === "en-US") {
if (hours <= 0) return `${remains}m left`;
return `${hours}h ${remains}m left`;
}
if (hours <= 0) return `剩余 ${remains} 分钟`;
return `剩余 ${hours}h ${remains}m`;
}
export function formatMinuteSpan(value: number | null | undefined, locale: string) {
if (value == null || !Number.isFinite(Number(value))) return "--";
const minutes = Math.max(0, Math.round(Number(value)));
const hours = Math.floor(minutes / 60);
const remains = minutes % 60;
if (locale === "en-US") {
if (hours <= 0) return `${remains}m`;
return `${hours}h ${remains}m`;
}
if (hours <= 0) return `${remains} 分钟`;
return `${hours}h ${remains}m`;
}
export function formatAction(
row: ScanOpportunityRow,
locale: string,
tempSymbol?: string | null,
) {
return formatTradeSide(row, locale, tempSymbol);
}
export function formatQuoteCents(value?: number | null) {
if (value == null || Number.isNaN(Number(value))) return "--";
const cents = Number(value) * 100;
const text =
cents < 1 || cents >= 99 || Math.abs(cents - Math.round(cents)) >= 0.05
? cents.toFixed(1)
: Math.round(cents).toFixed(0);
return `${text.replace(/\.0$/, "")}¢`;
}
export function formatTradeSide(
row: ScanOpportunityRow,
locale: string,
tempSymbol?: string | null,
) {
const side = String(row.side || "").toLowerCase();
const isEn = locale === "en-US";
const { lower, upper } = getTargetRange(row);
const threshold =
lower != null && upper == null
? formatTemperatureValue(lower, tempSymbol)
: upper != null && lower == null
? formatTemperatureValue(upper, tempSymbol)
: null;
if (threshold && lower != null && upper == null) {
if (side === "yes") return isEn ? `High reaches ${threshold}` : `最高温达到 ${threshold}`;
if (side === "no") return isEn ? `High stays below ${threshold}` : `最高温低于 ${threshold}`;
}
if (threshold && upper != null && lower == null) {
if (side === "yes") return isEn ? `High stays at/below ${threshold}` : `最高温不高于 ${threshold}`;
if (side === "no") return isEn ? `High exceeds ${threshold}` : `最高温高于 ${threshold}`;
}
if (lower != null && upper != null && Math.abs(lower - upper) > 0.01) {
const range = `${formatTemperatureValue(lower, tempSymbol)} ~ ${formatTemperatureValue(upper, tempSymbol)}`;
if (side === "yes") return isEn ? `High lands in ${range}` : `最高温落在 ${range}`;
if (side === "no") return isEn ? `High avoids ${range}` : `最高温不在 ${range}`;
}
const bucket = formatThreshold(row, tempSymbol);
if (side === "yes") return isEn ? `High lands on ${bucket}` : `最高温落在 ${bucket}`;
if (side === "no") return isEn ? `High avoids ${bucket}` : `最高温不落在 ${bucket}`;
if (row.action) {
return normalizeTemperatureLabel(
String(row.action).replace(String(row.target_label || ""), ""),
tempSymbol,
)
.replace(/\s+/g, " ")
.trim()
.toUpperCase();
}
return locale === "en-US" ? "WATCH" : "观察";
}
export function formatThreshold(row: ScanOpportunityRow, tempSymbol?: string | null) {
const targetLabel = normalizeTemperatureLabel(row.target_label, tempSymbol);
if (targetLabel) return targetLabel;
if (row.target_lower != null && row.target_upper != null) {
return `${formatTemperatureValue(Number(row.target_lower), tempSymbol)} ~ ${formatTemperatureValue(Number(row.target_upper), tempSymbol)}`;
}
if (row.target_threshold != null) {
return formatTemperatureValue(Number(row.target_threshold), tempSymbol);
}
if (row.target_value != null) {
return formatTemperatureValue(Number(row.target_value), tempSymbol);
}
return "--";
}
export function formatTemperatureDelta(value: number, tempSymbol?: string | null) {
return formatTemperatureValue(Math.abs(value), tempSymbol, { digits: 1 });
}
@@ -0,0 +1,185 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
import { getModelView, getProbabilityView } from "@/lib/model-utils";
import {
formatTemperatureValue,
normalizeTemperatureSymbol,
} from "@/lib/temperature-utils";
import { getWindowPhaseMeta, type PhaseMeta } from "./opportunity-window-phase";
import {
getDetailForRow,
getDetailViewDate,
} from "./opportunity-detail";
import {
formatThreshold,
normalizeProbability,
} from "./opportunity-format";
import { formatModelClusterRange } from "./opportunity-model-summary";
import {
extractNumbers,
getTargetRange,
normalizeBucketLabel,
} from "./opportunity-target";
export function getBucketText(bucket: { label?: string | null; bucket?: string | null; range?: string | null }) {
return [bucket.label, bucket.bucket, bucket.range]
.map((value) => String(value || "").trim())
.filter(Boolean);
}
export function bucketMatchesRow(
bucket: {
label?: string | null;
bucket?: string | null;
range?: string | null;
value?: number | string | null;
},
row: ScanOpportunityRow,
tempSymbol?: string | null,
) {
const targetLabel = normalizeBucketLabel(row.target_label, tempSymbol);
const bucketLabels = getBucketText(bucket).map((label) =>
normalizeBucketLabel(label, tempSymbol),
);
if (targetLabel && bucketLabels.some((label) => label === targetLabel)) {
return true;
}
const rawTargetLabel = String(row.target_label || "");
const targetNumbers = extractNumbers(rawTargetLabel);
const targetValue =
row.target_value ?? row.target_threshold ?? row.target_lower ?? row.target_upper ?? targetNumbers[0] ?? null;
if (targetValue == null || !Number.isFinite(Number(targetValue))) return false;
const bucketNumbers = [
...(bucket.value != null && Number.isFinite(Number(bucket.value))
? [Number(bucket.value)]
: []),
...getBucketText(bucket).flatMap(extractNumbers),
];
const matchesNumber = bucketNumbers.some(
(value) => Math.abs(Number(value) - Number(targetValue)) < 0.05,
);
if (!matchesNumber) return false;
const targetIsUpper =
/(\+|以上|or\s*above|above|greater|>=|≥)/i.test(rawTargetLabel) ||
(row.target_lower != null && row.target_upper == null);
const targetIsLower =
/(<=|≤|below|or\s*below|以下)/i.test(rawTargetLabel) ||
(row.target_upper != null && row.target_lower == null);
const bucketRaw = getBucketText(bucket).join(" ");
const bucketIsUpper = /(\+|以上|or\s*above|above|greater|>=|≥|inf|∞)/i.test(bucketRaw);
const bucketIsLower = /(<=|≤|below|or\s*below|以下|-inf|-∞)/i.test(bucketRaw);
if (targetIsUpper || bucketIsUpper) return targetIsUpper === bucketIsUpper;
if (targetIsLower || bucketIsLower) return targetIsLower === bucketIsLower;
return true;
}
export function getDetailBucketEventProbability(
detail: CityDetail | null,
row: ScanOpportunityRow,
tempSymbol?: string | null,
) {
if (!detail) return null;
const view = getProbabilityView(detail, getDetailViewDate(detail, row));
const buckets = Array.isArray(view.probabilitiesAll)
? view.probabilitiesAll
: [];
if (!buckets.length) return null;
const matched = buckets.find((bucket) => bucketMatchesRow(bucket, row, tempSymbol));
return normalizeProbability(matched?.probability);
}
export type OpportunityGroup = {
key: string;
cityName: string;
date?: string | null;
tempSymbol?: string | null;
debLabel: string;
peakLabel: string;
peakProbability?: number | null;
phaseMeta: PhaseMeta;
localTime?: string | null;
remainingMinutes?: number | null;
rows: ScanOpportunityRow[];
};
export function buildOpportunityGroups(
rows: ScanOpportunityRow[],
locale: string,
cityDetailsByName?: Record<string, CityDetail>,
): OpportunityGroup[] {
const groups = new Map<string, OpportunityGroup>();
for (const row of rows) {
const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
const detail = getDetailForRow(row, cityDetailsByName);
const cityName = getLocalizedCityName(
row.city,
row.city_display_name || row.display_name || row.city,
locale,
);
const date = detail ? getDetailViewDate(detail, row) : row.selected_date || row.local_date || "";
const key = `${row.city || cityName}|${date}`;
const modelView = detail ? getModelView(detail, date) : null;
const debPrediction = modelView?.deb ?? row.deb_prediction ?? null;
const modelClusterLabel = formatModelClusterRange(
modelView?.models || row.model_cluster_sources,
tempSymbol,
);
const existing = groups.get(key);
if (!existing) {
groups.set(key, {
key,
cityName,
date,
tempSymbol,
debLabel:
debPrediction != null
? formatTemperatureValue(Number(debPrediction), tempSymbol, { digits: 1 })
: "--",
peakLabel: modelClusterLabel,
peakProbability: null,
phaseMeta: getWindowPhaseMeta(row, locale),
localTime: row.local_time,
remainingMinutes: row.remaining_window_minutes,
rows: [row],
});
continue;
}
existing.rows.push(row);
if (existing.peakLabel === "--" && modelClusterLabel !== "--") {
existing.peakLabel = modelClusterLabel;
}
}
return Array.from(groups.values()).map((group) => ({
...group,
rows: [...group.rows].sort(
(a, b) =>
Number(b.edge_percent ?? -Infinity) - Number(a.edge_percent ?? -Infinity) ||
Number(b.final_score ?? -Infinity) - Number(a.final_score ?? -Infinity),
),
}));
}
export function getBucketDisplayLabel(
row: ScanOpportunityRow,
locale: string,
tempSymbol?: string | null,
) {
const isEn = locale === "en-US";
const { lower, upper } = getTargetRange(row);
if (lower != null && upper == null) {
const value = formatTemperatureValue(lower, tempSymbol);
return isEn ? `${value} or higher` : `${value} 以上`;
}
if (upper != null && lower == null) {
const value = formatTemperatureValue(upper, tempSymbol);
return isEn ? `${value} or lower` : `${value} 以下`;
}
if (lower != null && upper != null && Math.abs(lower - upper) > 0.01) {
return `${formatTemperatureValue(lower, tempSymbol)} ~ ${formatTemperatureValue(upper, tempSymbol)}`;
}
return formatThreshold(row, tempSymbol);
}
@@ -0,0 +1,46 @@
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { formatTemperatureValue } from "@/lib/temperature-utils";
export function formatModelSources(row: ScanOpportunityRow, tempSymbol?: string | null) {
const sources = row.model_cluster_sources || {};
return Object.entries(sources)
.filter(([, value]) => value != null && Number.isFinite(Number(value)))
.sort(([left], [right]) => left.localeCompare(right))
.map(([name, value]) => ({
name,
value: formatTemperatureValue(Number(value), tempSymbol, { digits: 1 }),
}));
}
export function formatModelClusterRange(
sources?: Record<string, number | null> | null,
tempSymbol?: string | null,
) {
const values = Object.values(sources || {})
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
if (!values.length) return "--";
const low = Math.min(...values);
const high = Math.max(...values);
if (Math.abs(low - high) < 0.05) {
return formatTemperatureValue(low, tempSymbol, { digits: 1 });
}
return `${formatTemperatureValue(low, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(high, tempSymbol, { digits: 1 })}`;
}
export function getModelSourceSummary(
row: ScanOpportunityRow,
locale: string,
tempSymbol?: string | null,
) {
const sources = formatModelSources(row, tempSymbol);
if (!sources.length) {
return locale === "en-US"
? "model cluster pending"
: "模型集群暂未回传";
}
const shown = sources.map((item) => `${item.name} ${item.value}`).join(" / ");
return locale === "en-US"
? `all models: ${shown}`
: `全部模型:${shown}`;
}
@@ -0,0 +1,328 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import {
formatTemperatureValue,
normalizeTemperatureSymbol,
} from "@/lib/temperature-utils";
import {
formatAirportReportRead,
formatAirportWeatherRead,
} from "./opportunity-airport-read";
import { formatMinuteSpan } from "./opportunity-format";
import { getTargetRange } from "./opportunity-target";
export type ObservationPoint = { time?: string; temp?: number | null };
export function normalizeObservationPoints(points?: ObservationPoint[] | null) {
if (!Array.isArray(points)) return [];
return points
.map((point) => ({
time: String(point?.time || "").trim(),
temp:
point?.temp != null && Number.isFinite(Number(point.temp))
? Number(point.temp)
: null,
}))
.filter((point): point is { time: string; temp: number } =>
Boolean(point.time && point.temp != null),
)
.sort((a, b) => getObservationSortMinutes(a.time) - getObservationSortMinutes(b.time));
}
export function getObservationSortMinutes(time: string) {
const parsed = Date.parse(time);
if (Number.isFinite(parsed)) {
const date = new Date(parsed);
return date.getUTCHours() * 60 + date.getUTCMinutes();
}
const match = time.match(/(\d{1,2}):(\d{2})/);
if (!match) return Number.MAX_SAFE_INTEGER;
return Number(match[1]) * 60 + Number(match[2]);
}
export function formatPeakWindowTiming(row: ScanOpportunityRow, locale: string) {
const isEn = locale === "en-US";
const phase = String(row.window_phase || "").toLowerCase();
const label = String(row.peak_window_label || "").trim();
const untilStart =
row.minutes_until_peak_start != null && Number.isFinite(Number(row.minutes_until_peak_start))
? Number(row.minutes_until_peak_start)
: null;
const untilEnd =
row.minutes_until_peak_end != null && Number.isFinite(Number(row.minutes_until_peak_end))
? Number(row.minutes_until_peak_end)
: null;
const windowText = label ? `${isEn ? "peak window" : "峰值窗口"} ${label}` : isEn ? "peak window" : "峰值窗口";
if (phase === "active_peak" || (untilStart != null && untilStart <= 0 && untilEnd != null && untilEnd > 0)) {
return isEn ? `Currently inside the ${windowText}.` : `当前已进入${windowText}`;
}
if (phase === "post_peak" || (untilEnd != null && untilEnd <= 0)) {
return isEn ? `The ${windowText} has passed.` : `${windowText}已结束。`;
}
if (untilStart != null && untilStart > 0) {
return isEn
? `${windowText} starts in ${formatMinuteSpan(untilStart, locale)}.`
: `${windowText}尚未开始,约 ${formatMinuteSpan(untilStart, locale)} 后进入。`;
}
if (phase === "early_today" || phase === "setup_today") {
return isEn ? `Before the ${windowText}; latest METAR is not final peak evidence yet.` : `尚处峰值前,最新 METAR 还不能当作最终峰值证据。`;
}
return label ? (isEn ? `Reference ${windowText}.` : `参考${windowText}`) : null;
}
export function firstNonEmptyPoints(...groups: Array<ReturnType<typeof normalizeObservationPoints>>) {
return groups.find((group) => group.length > 0) || [];
}
export function getMetarObservationContext(
row: ScanOpportunityRow,
detail: CityDetail | null,
) {
const context = row.metar_context || {};
const metarToday = firstNonEmptyPoints(
normalizeObservationPoints(context.today_obs),
normalizeObservationPoints(row.metar_today_obs),
);
const detailMetarToday = normalizeObservationPoints(detail?.metar_today_obs);
const metarRecent = firstNonEmptyPoints(
normalizeObservationPoints(context.recent_obs),
normalizeObservationPoints(row.metar_recent_obs),
);
const detailMetarRecent = normalizeObservationPoints(detail?.metar_recent_obs);
const settlementToday = firstNonEmptyPoints(
normalizeObservationPoints(context.settlement_today_obs),
normalizeObservationPoints(row.settlement_today_obs),
);
const detailSettlementToday = normalizeObservationPoints(detail?.settlement_today_obs);
const primaryPoints =
metarToday.length
? metarToday
: detailMetarToday.length
? detailMetarToday
: metarRecent.length
? metarRecent
: detailMetarRecent.length
? detailMetarRecent
: settlementToday.length
? settlementToday
: detailSettlementToday;
const trendPoints =
(metarRecent.length
? metarRecent
: detailMetarRecent.length
? detailMetarRecent
: primaryPoints.slice(-4));
const explicitLast = context.last_temp ?? row.metar_status?.last_temp ?? detail?.metar_status?.last_temp;
const lastPoint = primaryPoints[primaryPoints.length - 1] || null;
const maxPoint = primaryPoints.reduce<{ time: string; temp: number } | null>(
(best, point) => (!best || point.temp >= best.temp ? point : best),
null,
);
const trendFirst = trendPoints[0] || null;
const trendLast = trendPoints[trendPoints.length - 1] || null;
const trendDelta =
context.trend_delta != null && Number.isFinite(Number(context.trend_delta))
? Number(context.trend_delta)
: trendFirst && trendLast && trendPoints.length >= 2
? trendLast.temp - trendFirst.temp
: null;
const lastTemp =
explicitLast != null && Number.isFinite(Number(explicitLast))
? Number(explicitLast)
: lastPoint?.temp ?? null;
const maxTemp =
context.max_temp != null && Number.isFinite(Number(context.max_temp))
? Number(context.max_temp)
: maxPoint?.temp ?? null;
const stale =
context.stale_for_today === true ||
row.metar_status?.stale_for_today === true ||
detail?.metar_status?.stale_for_today === true;
return {
points: primaryPoints,
lastTime: String(context.last_time || lastPoint?.time || ""),
lastTemp,
maxTime: String(context.max_time || maxPoint?.time || ""),
maxTemp,
trendDelta,
stale,
station: context.station || detail?.risk?.icao || detail?.airport_current?.station_code || null,
};
}
export function getMetarGate(
row: ScanOpportunityRow,
detail: CityDetail | null,
locale: string,
tempSymbol?: string | null,
) {
const isEn = locale === "en-US";
const side = String(row.side || "").toLowerCase();
const selectedDate = String(row.selected_date || "");
const localDate = String(row.local_date || detail?.local_date || "");
const futureContract = Boolean(selectedDate && localDate && selectedDate > localDate);
if (futureContract) return null;
const obs = getMetarObservationContext(row, detail);
const evidence: string[] = [];
const unit = normalizeTemperatureSymbol(tempSymbol);
const peakTiming = formatPeakWindowTiming(row, locale);
const airportReport = formatAirportReportRead(row, detail, locale, unit);
const airportWeatherRead = formatAirportWeatherRead(row, detail, locale);
if (peakTiming) evidence.push(peakTiming);
if (airportReport) evidence.push(airportReport);
if (airportWeatherRead) evidence.push(airportWeatherRead);
if (obs.lastTemp != null) {
evidence.push(
`${isEn ? "METAR latest" : "METAR 最新"} ${formatTemperatureValue(obs.lastTemp, unit, { digits: 1 })}${obs.lastTime ? ` @ ${obs.lastTime}` : ""}`,
);
}
if (obs.maxTemp != null) {
evidence.push(
`${isEn ? "METAR max" : "METAR 最高"} ${formatTemperatureValue(obs.maxTemp, unit, { digits: 1 })}${obs.maxTime ? ` @ ${obs.maxTime}` : ""}`,
);
}
if (obs.trendDelta != null) {
evidence.push(
`${isEn ? "Recent METAR delta" : "近端 METAR 变化"} ${formatTemperatureValue(obs.trendDelta, unit, { digits: 1 })}`,
);
}
if (obs.station) evidence.push(`${isEn ? "Station" : "站点"} ${obs.station}`);
if (obs.stale || !obs.points.length || obs.maxTemp == null) {
return {
decision: "downgrade" as const,
reason: isEn
? "AI has no same-day METAR confirmation yet, so this bucket remains forecast-only."
: "AI 还没有拿到同日 METAR 确认,该桶暂时只能作为预测映射。",
evidence,
};
}
const { lower, upper } = getTargetRange(row);
if (lower == null && upper == null) {
return {
decision: "watchlist" as const,
reason: isEn
? "AI has METAR data, but the contract threshold cannot be mapped cleanly to a bucket."
: "AI 已读取 METAR,但该合约阈值无法稳定映射到温度桶。",
evidence,
};
}
const epsilon = String(unit).toUpperCase().includes("F") ? 0.7 : 0.4;
const trendDelta = obs.trendDelta;
const isNotRising = trendDelta != null && trendDelta <= epsilon;
const isFalling = trendDelta != null && trendDelta <= -epsilon;
const phase = String(row.window_phase || "").toLowerCase();
const remaining =
row.remaining_window_minutes != null && Number.isFinite(Number(row.remaining_window_minutes))
? Number(row.remaining_window_minutes)
: null;
const minutesUntilPeakStart =
row.minutes_until_peak_start != null && Number.isFinite(Number(row.minutes_until_peak_start))
? Number(row.minutes_until_peak_start)
: null;
const lateWindow =
phase === "active_peak" ||
phase === "post_peak" ||
(remaining != null && remaining <= 180);
const beforePeak =
phase === "early_today" ||
phase === "setup_today" ||
phase === "tomorrow" ||
phase === "week_ahead" ||
(minutesUntilPeakStart != null && minutesUntilPeakStart > 0);
const aboveUpper = upper != null && obs.maxTemp > upper + epsilon;
const belowLower = lower != null && obs.maxTemp < lower - epsilon;
const insideBucket =
(lower == null || obs.maxTemp >= lower - epsilon) &&
(upper == null || obs.maxTemp <= upper + epsilon);
if (side === "no") {
if (aboveUpper) {
return {
decision: "approve" as const,
reason: isEn
? "METAR max has already moved above this bucket, so AI marks the NO bucket as observation-supported."
: "METAR 实测最高已越过目标桶上沿,AI 判断 NO 桶有实测支撑。",
evidence,
};
}
if (belowLower && (lateWindow || isFalling || isNotRising)) {
if (beforePeak && !lateWindow) {
return {
decision: "watchlist" as const,
reason: isEn
? "The peak window has not arrived, so a still-low METAR path cannot confirm this NO bucket yet; AI keeps it on watch."
: "峰值窗口尚未到来,METAR 暂未触达不能直接确认 NO 桶,AI 先列观察。",
evidence,
};
}
return {
decision: "approve" as const,
reason: isEn
? "METAR max remains below this bucket and recent observations are not strengthening, so AI favors the NO bucket."
: "METAR 最高仍低于目标桶且近期走势不强,AI 倾向 NO 桶。",
evidence,
};
}
if (insideBucket && lateWindow && isNotRising) {
return {
decision: "downgrade" as const,
reason: isEn
? "METAR max is still close to this bucket, so AI cannot treat the NO bucket as confirmed."
: "METAR 最高仍贴近目标桶,AI 不能把 NO 桶视为已确认。",
evidence,
};
}
}
if (side === "yes") {
if (aboveUpper) {
return {
decision: "veto" as const,
reason: isEn
? "METAR max has already exceeded the bucket, so AI marks this YES bucket as outside the observed path."
: "METAR 实测最高已越过目标桶上沿,AI 判断该 YES 桶已偏离实测路径。",
evidence,
};
}
if (belowLower && (lateWindow || isFalling || isNotRising)) {
if (beforePeak && !lateWindow) {
return {
decision: "watchlist" as const,
reason: isEn
? "The peak window has not arrived, so METAR not reaching the bucket only means this bucket still needs peak-window confirmation."
: "峰值窗口尚未到来,METAR 未触达目标桶只能说明仍需等待峰值验证,AI 暂列观察。",
evidence,
};
}
return {
decision: "downgrade" as const,
reason: isEn
? "METAR max has not reached the bucket and recent observations are weak, so AI downgrades the YES bucket."
: "METAR 最高仍未触达目标桶且走势不强,AI 将 YES 桶降级观察。",
evidence,
};
}
if (insideBucket) {
return {
decision: "approve" as const,
reason: isEn
? "METAR max is inside the target bucket, so AI sees observation support while monitoring an overshoot."
: "METAR 实测最高已落入目标桶,AI 认为 YES 桶有实测依据,但仍需防止继续升穿上沿。",
evidence,
};
}
}
return {
decision: "watchlist" as const,
reason: isEn
? "METAR does not give a clean final confirmation yet, so AI keeps this as watchlist."
: "METAR 还没有给出干净的最终确认,AI 暂列观察。",
evidence,
};
}
@@ -0,0 +1,48 @@
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { normalizeTemperatureLabel } from "@/lib/temperature-utils";
export function normalizeBucketLabel(value?: string | null, tempSymbol?: string | null) {
return normalizeTemperatureLabel(value, tempSymbol)
.toLowerCase()
.replace(/\s+/g, "")
.replace(/℃/g, "°c");
}
export function extractNumbers(value?: string | null) {
return Array.from(String(value || "").matchAll(/-?\d+(?:\.\d+)?/g)).map((match) =>
Number(match[0]),
);
}
export function getTargetRange(row: ScanOpportunityRow) {
const lower =
row.target_lower != null && Number.isFinite(Number(row.target_lower))
? Number(row.target_lower)
: null;
const upper =
row.target_upper != null && Number.isFinite(Number(row.target_upper))
? Number(row.target_upper)
: null;
if (lower != null || upper != null) return { lower, upper };
const rawLabel = String(row.target_label || row.action || "");
const numbers = extractNumbers(rawLabel);
if (numbers.length >= 2) {
return { lower: Math.min(numbers[0], numbers[1]), upper: Math.max(numbers[0], numbers[1]) };
}
const value =
row.target_threshold ??
row.target_value ??
(numbers.length ? numbers[0] : null);
if (value == null || !Number.isFinite(Number(value))) {
return { lower: null, upper: null };
}
const numeric = Number(value);
if (/(\+|above|higher|or\s+higher|>=|≥|以上)/i.test(rawLabel)) {
return { lower: numeric, upper: null };
}
if (/(below|or\s+below|<=|≤|以下)/i.test(rawLabel)) {
return { lower: null, upper: numeric };
}
return { lower: numeric, upper: numeric };
}
@@ -0,0 +1,293 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import {
formatTemperatureValue,
normalizeTemperatureSymbol,
} from "@/lib/temperature-utils";
import { formatAirportReportRead } from "./opportunity-airport-read";
import { getLocalizedRowText } from "./opportunity-copy";
import { getBucketDisplayLabel } from "./opportunity-groups";
import { getMetarGate } from "./opportunity-observation";
import { getTargetRange } from "./opportunity-target";
import {
getForecastContractFit,
getForecastRangeLabel,
getPaceDecisionTail,
} from "./opportunity-v4-forecast";
import type { V4CityForecast, V4TradeDecision } from "./opportunity-v4-types";
export function getV4DecisionLabel(
decision: V4TradeDecision["decision"],
locale: string,
) {
if (locale === "en-US") {
if (decision === "approve") return "AI Confirmed";
if (decision === "veto") return "AI Outside";
if (decision === "downgrade") return "AI Downgrade";
return "AI Watch";
}
if (decision === "approve") return "AI 确认";
if (decision === "veto") return "AI 区间外";
if (decision === "downgrade") return "AI 降级";
return "AI 观察";
}
export function getV4TradeDecision(
row: ScanOpportunityRow,
detail: CityDetail | null,
locale: string,
edgePercent?: number | null,
tempSymbol?: string | null,
): V4TradeDecision {
const isEn = locale === "en-US";
const backendMetarDecision = String(row.v4_metar_decision || "").toLowerCase();
const backendMetarReason =
getLocalizedRowText(row, locale, row.v4_metar_reason_zh, row.v4_metar_reason_en) ||
null;
const metarGate = getMetarGate(row, detail, locale, tempSymbol);
const aiDecision = String(row.ai_decision || "").toLowerCase();
const aiReason =
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
getLocalizedRowText(
row,
locale,
row.ai_watchlist_reason_zh,
row.ai_watchlist_reason_en,
);
let decision: V4TradeDecision["decision"] =
backendMetarDecision === "veto" ||
backendMetarDecision === "downgrade" ||
backendMetarDecision === "approve" ||
backendMetarDecision === "watchlist"
? (backendMetarDecision as V4TradeDecision["decision"])
: metarGate?.decision ||
(aiDecision === "veto" || aiDecision === "downgrade" || aiDecision === "approve" || aiDecision === "watchlist"
? (aiDecision as V4TradeDecision["decision"])
: Number(edgePercent || 0) >= 20
? "watchlist"
: "watchlist");
if (metarGate?.decision === "veto") decision = "veto";
if (metarGate?.decision === "watchlist" && backendMetarDecision === "downgrade") {
decision = "watchlist";
}
if (metarGate?.decision === "downgrade" && decision !== "veto") decision = "downgrade";
if (metarGate?.decision === "approve" && decision !== "veto" && decision !== "downgrade") {
decision = "approve";
}
const reason =
(metarGate?.decision === "watchlist" ? metarGate.reason : null) ||
backendMetarReason ||
metarGate?.reason ||
aiReason ||
(isEn
? "AI keeps this on watch until METAR and the full weather-model cluster align."
: "AI 会等 METAR 与全量天气模型集群对齐后再确认。");
const airportReport = formatAirportReportRead(
row,
detail,
locale,
normalizeTemperatureSymbol(tempSymbol),
);
const metarSummary =
metarGate?.evidence?.filter((item) => item !== airportReport).join(" · ") || null;
return {
decision,
label: getV4DecisionLabel(decision, locale),
tone: decision,
reason,
metarSummary,
airportReport,
metarEvidence: metarGate?.evidence || [],
};
}
export function getForecastFitMeta(
fit: ReturnType<typeof getForecastContractFit>,
locale: string,
) {
const isEn = locale === "en-US";
const tone = String(fit.tone || "watchlist");
if (tone === "approve" || tone === "core") {
return {
label: isEn ? "Clear signal" : "方向明确",
tone: "approve",
};
}
if (tone === "veto" || tone === "outside") {
return {
label: isEn ? "Outside AI range" : "偏离 AI 区间",
tone: "veto",
};
}
if (tone === "downgrade") {
return {
label: isEn ? "Downgraded" : "降级观察",
tone: "downgrade",
};
}
return {
label: isEn ? "Need peak confirmation" : "等待峰值确认",
tone: "watchlist",
};
}
export function getThresholdDecision(
row: ScanOpportunityRow,
forecast: V4CityForecast,
locale: string,
tempSymbol?: string | null,
) {
const isEn = locale === "en-US";
const unit = normalizeTemperatureSymbol(tempSymbol);
const { lower, upper } = getTargetRange(row);
const predicted = forecast.predicted;
const low = forecast.low;
const high = forecast.high;
const tolerance = unit === "°F" ? 1 : 0.5;
const cautionBand = unit === "°F" ? 2 : 1;
const format = (value: number) => formatTemperatureValue(value, unit, { digits: 0 });
const paceTolerance = unit === "°F" ? 1 : 0.6;
const paceTail = getPaceDecisionTail(forecast, locale, unit);
const paceAdjustedHigh =
forecast.paceAdjustedHigh != null && Number.isFinite(Number(forecast.paceAdjustedHigh))
? Number(forecast.paceAdjustedHigh)
: null;
const runningHot =
forecast.paceDelta != null && Number(forecast.paceDelta) >= paceTolerance;
const runningCold =
forecast.paceDelta != null && Number(forecast.paceDelta) <= -paceTolerance;
const confidence = (() => {
const values = Object.values(row.model_cluster_sources || {})
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
if (!values.length || predicted == null) return isEn ? "Medium" : "中";
const near = values.filter((value) => Math.abs(value - predicted) <= cautionBand).length;
const ratio = near / values.length;
if (ratio >= 0.75) return isEn ? "High" : "高";
if (ratio >= 0.45) return isEn ? "Medium" : "中";
return isEn ? "Low" : "低";
})();
if (predicted == null || (lower == null && upper == null)) {
return {
confidence,
headline: isEn ? "Conclusion pending" : "结论待确认",
relation: isEn ? "Await stable forecast" : "等待稳定预测",
summary: isEn
? "AI does not have a stable high-temperature center yet."
: "AI 还没有稳定的最高温中枢,先不输出边界结论。",
tone: "watchlist" as const,
};
}
if (lower != null && upper == null) {
const threshold = format(lower);
if (
predicted < lower - cautionBand &&
(high == null || high < lower + tolerance) &&
(paceAdjustedHigh == null || paceAdjustedHigh < lower - tolerance)
) {
return {
confidence,
headline: isEn ? `Unlikely to reach ${threshold}` : `不太可能达到 ${threshold}`,
relation: isEn ? "Clearly below threshold" : "明显低于阈值",
summary: isEn
? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}, below the ${threshold} boundary with no clear breakout signal.${paceTail}`
: `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},低于 ${threshold} 阈值,暂时缺乏明显突破信号。${paceTail}`,
tone: "veto" as const,
};
}
if (
predicted >= lower + tolerance &&
(low == null || low >= lower - tolerance) &&
!runningCold &&
(paceAdjustedHigh == null || paceAdjustedHigh >= lower - tolerance)
) {
return {
confidence,
headline: isEn ? `Likely to reach ${threshold}` : `大概率达到 ${threshold}`,
relation: isEn ? "Above threshold" : "高于阈值",
summary: isEn
? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}, already above the ${threshold} boundary.${paceTail}`
: `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},已经高于 ${threshold} 阈值。${paceTail}`,
tone: "approve" as const,
};
}
return {
confidence,
headline: isEn ? `${threshold} boundary is risky` : `${threshold} 边界偏危险`,
relation: isEn ? "Near threshold" : "接近阈值(存在突破风险)",
summary: isEn
? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}; the ${threshold} boundary still needs peak-window confirmation.${paceTail}`
: `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},接近 ${threshold} 阈值,仍要等峰值窗口确认。${paceTail}`,
tone: "watchlist" as const,
};
}
if (upper != null && lower == null) {
const threshold = format(upper);
if (
predicted <= upper - tolerance &&
(high == null || high <= upper + tolerance) &&
!runningHot &&
(paceAdjustedHigh == null || paceAdjustedHigh <= upper + tolerance)
) {
return {
confidence,
headline: isEn ? `Likely to stay below ${threshold}` : `大概率不超过 ${threshold}`,
relation: isEn ? "Clearly below threshold" : "明显低于阈值",
summary: isEn
? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}, below the ${threshold} boundary.${paceTail}`
: `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},低于 ${threshold} 阈值。${paceTail}`,
tone: "approve" as const,
};
}
if (
predicted > upper + cautionBand &&
(low == null || low > upper - tolerance) &&
(paceAdjustedHigh == null || paceAdjustedHigh > upper + tolerance)
) {
return {
confidence,
headline: isEn ? `Likely above ${threshold}` : `大概率超过 ${threshold}`,
relation: isEn ? "Above threshold" : "高于阈值",
summary: isEn
? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}, above the ${threshold} boundary.${paceTail}`
: `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},高于 ${threshold} 阈值。${paceTail}`,
tone: "veto" as const,
};
}
return {
confidence,
headline: isEn ? `${threshold} boundary is risky` : `${threshold} 边界偏危险`,
relation: isEn ? "Near threshold" : "接近阈值(存在突破风险)",
summary: isEn
? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}; the boundary still needs peak-window confirmation.${paceTail}`
: `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},仍需等待峰值窗口确认边界。${paceTail}`,
tone: "watchlist" as const,
};
}
const bucket = getBucketDisplayLabel(row, locale, unit);
const bucketReference = paceAdjustedHigh ?? predicted;
const inside =
(lower == null || bucketReference >= lower - tolerance) &&
(upper == null || bucketReference <= upper + tolerance);
return {
confidence,
headline: inside
? isEn
? `Likely inside ${bucket}`
: `大概率落在 ${bucket}`
: isEn
? `Unlikely inside ${bucket}`
: `不太可能落在 ${bucket}`,
relation: inside ? (isEn ? "Inside target bucket" : "处于目标桶") : (isEn ? "Outside target bucket" : "偏离目标桶"),
summary: isEn
? `Forecast center is ${formatTemperatureValue(predicted, unit, { digits: 1 })}; use this bucket only as the market mapping of the city high.${paceTail}`
: `温度中枢约 ${formatTemperatureValue(predicted, unit, { digits: 1 })},该温度桶只用于映射城市最高温判断。${paceTail}`,
tone: inside ? ("approve" as const) : ("veto" as const),
};
}
@@ -0,0 +1,250 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import { getTodayPaceView } from "@/lib/pace-utils";
import {
formatTemperatureValue,
normalizeTemperatureSymbol,
} from "@/lib/temperature-utils";
import {
formatAirportReportRead,
formatAirportWeatherRead,
} from "./opportunity-airport-read";
import { getLocalizedRowText } from "./opportunity-copy";
import { formatPeakWindowTiming } from "./opportunity-observation";
import { getModelSourceSummary } from "./opportunity-model-summary";
import { getTargetRange } from "./opportunity-target";
import type { OpportunityGroup } from "./opportunity-groups";
import type { V4CityForecast } from "./opportunity-v4-types";
export function getPaceDeviationRead(
detail: CityDetail | null,
locale: string,
tempSymbol?: string | null,
) {
if (!detail) return null;
const paceView = getTodayPaceView(detail, locale === "en-US" ? "en-US" : "zh-CN");
if (!paceView) return null;
const unit = normalizeTemperatureSymbol(tempSymbol || detail.temp_symbol);
const observed = formatTemperatureValue(paceView.observedNow, unit, { digits: 1 });
const expected = formatTemperatureValue(paceView.expectedNow, unit, { digits: 1 });
const delta = `${paceView.delta > 0 ? "+" : ""}${paceView.delta.toFixed(1)}${unit}`;
const isEn = locale === "en-US";
const toneText =
paceView.biasTone === "warm"
? isEn
? "running hotter"
: "偏热"
: paceView.biasTone === "cold"
? isEn
? "running cooler"
: "偏冷"
: isEn
? "tracking"
: "基本跟踪";
return {
adjustedHigh: paceView.paceAdjustedHigh,
delta: paceView.delta,
label: paceView.badge,
read: isEn
? `Observed path vs DEB curve: ${observed} now vs ${expected} expected, ${delta} (${toneText}).`
: `实测路径对比 DEB 曲线:当前 ${observed},同刻预期 ${expected},偏差 ${delta}${toneText})。`,
tone: paceView.biasTone,
};
}
export function getPaceSignalLabel(forecast: V4CityForecast, locale: string, tempSymbol?: string | null) {
const isEn = locale === "en-US";
if (forecast.paceDelta == null || !Number.isFinite(Number(forecast.paceDelta))) {
return isEn ? "Path pending" : "路径待确认";
}
const unit = normalizeTemperatureSymbol(tempSymbol);
const delta = `${forecast.paceDelta > 0 ? "+" : ""}${Number(forecast.paceDelta).toFixed(1)}${unit}`;
if (forecast.paceTone === "warm") return isEn ? `Hot path ${delta}` : `实测偏热 ${delta}`;
if (forecast.paceTone === "cold") return isEn ? `Cool path ${delta}` : `实测偏冷 ${delta}`;
return isEn ? `On path ${delta}` : `路径跟踪 ${delta}`;
}
export function getPaceDecisionTail(forecast: V4CityForecast, locale: string, tempSymbol?: string | null) {
if (forecast.paceDelta == null || !Number.isFinite(Number(forecast.paceDelta))) return "";
const isEn = locale === "en-US";
const unit = normalizeTemperatureSymbol(tempSymbol);
const delta = `${forecast.paceDelta > 0 ? "+" : ""}${Number(forecast.paceDelta).toFixed(1)}${unit}`;
if (forecast.paceTone === "warm") {
return isEn
? ` Observations are running ${delta} above the DEB path, so upside boundaries need extra caution.`
: ` 实测比 DEB 路径偏高 ${delta},上方阈值要额外谨慎。`;
}
if (forecast.paceTone === "cold") {
return isEn
? ` Observations are running ${delta} below the DEB path, which weakens upside breakout odds.`
: ` 实测比 DEB 路径偏低 ${delta},上破概率需要下修。`;
}
return isEn
? " Observations are still tracking the DEB path."
: " 实测仍基本跟踪 DEB 路径。";
}
export function median(values: number[]) {
if (!values.length) return null;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
export function getV4CityForecast(
row: ScanOpportunityRow,
group: OpportunityGroup,
detail: CityDetail | null,
locale: string,
tempSymbol?: string | null,
): V4CityForecast {
const isEn = locale === "en-US";
const aiPredicted =
row.ai_predicted_max != null && Number.isFinite(Number(row.ai_predicted_max))
? Number(row.ai_predicted_max)
: null;
const aiLow =
row.ai_predicted_low != null && Number.isFinite(Number(row.ai_predicted_low))
? Number(row.ai_predicted_low)
: null;
const aiHigh =
row.ai_predicted_high != null && Number.isFinite(Number(row.ai_predicted_high))
? Number(row.ai_predicted_high)
: null;
const modelValues = Object.values(row.model_cluster_sources || {})
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
const fallbackPredicted =
aiPredicted ??
(row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
? Number(row.deb_prediction)
: median(modelValues));
const fallbackLow = aiLow ?? (modelValues.length ? Math.min(...modelValues) : fallbackPredicted);
const fallbackHigh = aiHigh ?? (modelValues.length ? Math.max(...modelValues) : fallbackPredicted);
const peakWindow =
getLocalizedRowText(row, locale, row.ai_peak_window_zh, row.ai_peak_window_en) ||
formatPeakWindowTiming(row, locale);
const airportRead =
getLocalizedRowText(
row,
locale,
row.ai_airport_metar_read_zh,
row.ai_airport_metar_read_en,
) || formatAirportReportRead(row, detail, locale, tempSymbol);
const weatherRead = formatAirportWeatherRead(row, detail, locale);
const paceRead = getPaceDeviationRead(detail, locale, tempSymbol);
const modelNote =
row.ai_city_model_cluster_note ||
row.ai_model_cluster_note ||
getModelSourceSummary(row, locale, tempSymbol);
const reason =
getLocalizedRowText(row, locale, row.ai_forecast_reason_zh, row.ai_forecast_reason_en) ||
getLocalizedRowText(row, locale, row.ai_city_thesis_zh, row.ai_city_thesis_en) ||
(fallbackPredicted != null
? isEn
? `${group.cityName} final high is centered near ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })}; market temperature buckets are only mapped against that forecast range.`
: `${group.cityName} 最终最高温先以 ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })} 附近为中枢,市场温度桶只用于对照 AI 预测区间。`
: null);
return {
predicted: fallbackPredicted,
low: fallbackLow,
high: fallbackHigh,
confidence: row.ai_forecast_confidence || row.ai_city_confidence,
peakWindow,
airportRead,
weatherRead,
paceRead: paceRead?.read || null,
paceTone: paceRead?.tone || null,
paceDelta: paceRead?.delta ?? null,
paceAdjustedHigh: paceRead?.adjustedHigh ?? null,
reason,
modelNote,
source: aiPredicted != null ? "ai" : "fallback",
};
}
export function getForecastRangeLabel(forecast: V4CityForecast, tempSymbol?: string | null) {
if (forecast.low == null && forecast.high == null) return "--";
if (forecast.low != null && forecast.high != null) {
if (Math.abs(forecast.low - forecast.high) < 0.05) {
return formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 });
}
return `${formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(forecast.high, tempSymbol, { digits: 1 })}`;
}
if (forecast.low != null) return `>= ${formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 })}`;
return `<= ${formatTemperatureValue(Number(forecast.high), tempSymbol, { digits: 1 })}`;
}
export function getForecastContractFit(
row: ScanOpportunityRow,
forecast: V4CityForecast,
locale: string,
tempSymbol?: string | null,
) {
const isEn = locale === "en-US";
const explicitMatch = String(row.ai_forecast_match || "").toLowerCase();
const explicitReason = getLocalizedRowText(
row,
locale,
row.ai_forecast_match_reason_zh,
row.ai_forecast_match_reason_en,
);
if (explicitMatch && explicitReason) {
return {
label:
explicitMatch === "core"
? isEn ? "Core bucket" : "核心桶"
: explicitMatch === "outside"
? isEn ? "Outside forecast" : "预测区间外"
: explicitMatch === "edge"
? isEn ? "Boundary bucket" : "边界桶"
: isEn ? "Watch" : "观察",
tone: explicitMatch === "core" ? "approve" : explicitMatch === "outside" ? "veto" : "watchlist",
reason: explicitReason,
};
}
const { lower, upper } = getTargetRange(row);
const predicted = forecast.predicted;
if (predicted == null || (lower == null && upper == null)) {
return {
label: isEn ? "Await forecast" : "等待预测",
tone: "watchlist",
reason: isEn ? "AI has no stable max-temperature center yet." : "AI 还没有稳定的最高温中枢。",
};
}
const unit = normalizeTemperatureSymbol(tempSymbol);
const tolerance = String(unit).toUpperCase().includes("F") ? 1.0 : 0.5;
const inside =
(lower == null || predicted >= lower - tolerance) &&
(upper == null || predicted <= upper + tolerance);
const rangeOverlaps =
forecast.low != null &&
forecast.high != null &&
(lower == null || forecast.high >= lower - tolerance) &&
(upper == null || forecast.low <= upper + tolerance);
if (inside) {
return {
label: isEn ? "Core bucket" : "核心桶",
tone: "approve",
reason: isEn
? `AI max-temperature center ${formatTemperatureValue(predicted, unit, { digits: 1 })} sits inside this bucket.`
: `AI 最高温中枢 ${formatTemperatureValue(predicted, unit, { digits: 1 })} 落在这个温度桶内。`,
};
}
if (rangeOverlaps) {
return {
label: isEn ? "Boundary bucket" : "边界桶",
tone: "watchlist",
reason: isEn
? `This bucket touches the AI interval ${getForecastRangeLabel(forecast, unit)}, but is not the center.`
: `该桶触及 AI 区间 ${getForecastRangeLabel(forecast, unit)},但不是预测中枢。`,
};
}
return {
label: isEn ? "Outside forecast" : "预测区间外",
tone: "veto",
reason: isEn
? `This bucket is outside the AI max-temperature interval ${getForecastRangeLabel(forecast, unit)}.`
: `该桶位于 AI 最高温区间 ${getForecastRangeLabel(forecast, unit)} 之外。`,
};
}
@@ -0,0 +1,104 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import {
formatTemperatureValue,
normalizeTemperatureSymbol,
} from "@/lib/temperature-utils";
import { formatModelClusterRange } from "./opportunity-model-summary";
import { getMetarObservationContext } from "./opportunity-observation";
import type { V4CityForecast } from "./opportunity-v4-types";
export function getForecastRiskItems(
row: ScanOpportunityRow,
detail: CityDetail | null,
forecast: V4CityForecast,
locale: string,
tempSymbol?: string | null,
) {
const isEn = locale === "en-US";
const obs = getMetarObservationContext(row, detail);
const risks: string[] = [];
const phase = String(row.window_phase || "").toLowerCase();
if (
phase === "early_today" ||
phase === "setup_today" ||
(row.minutes_until_peak_start != null && Number(row.minutes_until_peak_start) > 0)
) {
risks.push(
isEn
? "Peak window has not arrived; current METAR is only path evidence."
: "峰值窗口尚未到达,当前 METAR 只能作为路径证据。",
);
}
if (row.trend_alignment === false) {
risks.push(
isEn
? "Intraday observation path does not fully support the forecast center."
: "日内实况路径还没有完全支持预测中枢。",
);
}
if (forecast.paceRead && forecast.paceTone !== "neutral") {
risks.push(
isEn
? `Observed pace is deviating from the DEB curve: ${forecast.paceRead}`
: `实测节奏正在偏离 DEB 曲线:${forecast.paceRead}`,
);
}
if (obs.stale || obs.lastTemp == null) {
risks.push(
isEn
? "Same-day METAR confirmation is still weak."
: "同日 METAR 确认仍然偏弱。",
);
}
if (forecast.low != null && forecast.high != null) {
const spread = Math.abs(forecast.high - forecast.low);
const wide = String(normalizeTemperatureSymbol(tempSymbol)).toUpperCase().includes("F")
? spread > 2
: spread > 1;
if (wide) {
risks.push(
isEn
? "Model range is wide; treat boundary buckets conservatively."
: "模型区间偏宽,边界温度桶需要保守处理。",
);
}
}
if (!risks.length) {
risks.push(
isEn
? "Residual risk is late METAR revision or a shifted afternoon peak."
: "残余风险主要是后续 METAR 修订或峰值窗口漂移。",
);
}
return Array.from(new Set(risks)).slice(0, 3);
}
export function getDecisionReasonItems(
row: ScanOpportunityRow,
forecast: V4CityForecast,
modelSupportText: string,
locale: string,
tempSymbol?: string | null,
) {
const isEn = locale === "en-US";
const modelRange = formatModelClusterRange(row.model_cluster_sources, tempSymbol);
const reasons: string[] = [];
if (modelRange !== "--") {
reasons.push(
isEn
? `Model cluster sits around ${modelRange}; ${modelSupportText}.`
: `模型区间集中在 ${modelRange}${modelSupportText}`,
);
}
if (forecast.predicted != null) {
reasons.push(
isEn
? `AI high-temperature center is ${formatTemperatureValue(forecast.predicted, tempSymbol, { digits: 1 })}.`
: `AI 最高温中枢约 ${formatTemperatureValue(forecast.predicted, tempSymbol, { digits: 1 })}。`,
);
}
if (forecast.paceRead) reasons.push(forecast.paceRead);
if (forecast.peakWindow) reasons.push(forecast.peakWindow);
if (forecast.weatherRead) reasons.push(forecast.weatherRead);
return Array.from(new Set(reasons)).slice(0, 3);
}
@@ -0,0 +1,26 @@
export type V4TradeDecision = {
decision: "approve" | "downgrade" | "veto" | "watchlist";
label: string;
tone: "approve" | "downgrade" | "veto" | "watchlist";
reason: string;
metarSummary?: string | null;
airportReport?: string | null;
metarEvidence: string[];
};
export type V4CityForecast = {
predicted: number | null;
low: number | null;
high: number | null;
confidence?: string | null;
peakWindow?: string | null;
airportRead?: string | null;
weatherRead?: string | null;
paceRead?: string | null;
paceTone?: "warm" | "cold" | "neutral" | string | null;
paceDelta?: number | null;
paceAdjustedHigh?: number | null;
reason?: string | null;
modelNote?: string | null;
source: "ai" | "fallback";
};
@@ -0,0 +1,59 @@
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
export type PhaseMeta = {
label: string;
tone: "green" | "amber" | "blue" | "red";
};
export function getWindowPhaseMeta(
row: Pick<ScanOpportunityRow, "window_phase" | "trend_alignment">,
locale: string,
): PhaseMeta {
const mode = String(row.window_phase || "").toLowerCase();
if (mode === "city_snapshot") {
return {
label: locale === "en-US" ? "City Snapshot" : "城市概况",
tone: "blue",
};
}
if (mode === "active_peak") {
return {
label: locale === "en-US" ? "Peak Window" : "峰值窗口",
tone: "red",
};
}
if (mode === "setup_today") {
return {
label: locale === "en-US" ? "Touch Play" : "触达博弈",
tone: "red",
};
}
if (mode === "early_today") {
return {
label: locale === "en-US" ? "Early Today" : "日内早段",
tone: "blue",
};
}
if (mode === "tomorrow" || mode === "week_ahead") {
return {
label: locale === "en-US" ? "Early" : "早期机会",
tone: "blue",
};
}
if (mode === "post_peak") {
return {
label: locale === "en-US" ? "Post Peak" : "峰后确认",
tone: "amber",
};
}
if (row.trend_alignment) {
return {
label: locale === "en-US" ? "Trend" : "趋势确认",
tone: "amber",
};
}
return {
label: locale === "en-US" ? "Tradable" : "可交易",
tone: "green",
};
}
@@ -1,5 +1,5 @@
import { memo, useEffect, useMemo, useState } from "react";
import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable";
import { getWindowPhaseMeta } from "@/components/dashboard/opportunity-window-phase";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
import { formatTemperatureValue } from "@/lib/temperature-utils";
@@ -73,7 +73,7 @@ const CalendarActionCard = memo(function CalendarActionCard({
);
});
export function CalendarView({
export const CalendarView = memo(function CalendarView({
rows,
locale,
selectedRowId,
@@ -181,5 +181,5 @@ export function CalendarView({
))}
</div>
);
}
});
@@ -93,7 +93,7 @@ const OpportunityDecisionCard = memo(function OpportunityDecisionCard({
);
});
export function OpportunityOverview({
export const OpportunityOverview = memo(function OpportunityOverview({
rows,
terminalData,
loading,
@@ -212,5 +212,5 @@ export function OpportunityOverview({
</div>
</div>
);
}
});