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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user