feat: implement PolyWeather dashboard layout with integrated map, weather visualization, and detail panels

This commit is contained in:
2569718930@qq.com
2026-04-23 22:35:34 +08:00
parent e2eb5eb429
commit 79a4333aaf
10 changed files with 1029 additions and 773 deletions
+19 -19
View File
@@ -21,16 +21,16 @@
--color-text-muted: #64748b;
--color-text-disabled: #475569;
/* ── Accent Colors ── */
--color-accent-primary: #00e5ff;
--color-accent-secondary: #6366f1;
/* ── Accent Colors (Fintech 3-Color) ── */
--color-accent-primary: #00e0a4;
--color-accent-secondary: #7b61ff;
--color-accent-tertiary: #a78bfa;
/* ── Signal / Semantic Colors ── */
--color-signal-success: #22c55e;
--color-signal-warning: #f59e0b;
--color-signal-danger: #ef4444;
--color-signal-info: #3b82f6;
--color-signal-success: #00e0a4;
--color-signal-warning: #ffb020;
--color-signal-danger: #ff4d6a;
--color-signal-info: #7b61ff;
/* ── Risk Colors (aliased from signal) ── */
--color-risk-high: var(--color-signal-danger);
@@ -38,16 +38,16 @@
--color-risk-low: var(--color-signal-success);
/* ── Border ── */
--color-border-default: rgba(99, 102, 241, 0.12);
--color-border-hover: rgba(99, 102, 241, 0.28);
--color-border-default: rgba(123, 97, 255, 0.12);
--color-border-hover: rgba(123, 97, 255, 0.28);
--color-border-subtle: rgba(255, 255, 255, 0.06);
/* ── Shadow / Elevation ── */
--shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-elevation-2: 0 8px 24px rgba(0, 0, 0, 0.45);
--shadow-elevation-3: 0 20px 60px rgba(0, 0, 0, 0.6);
--shadow-glow-accent: 0 0 20px rgba(0, 229, 255, 0.2);
--shadow-glow-secondary: 0 0 20px rgba(99, 102, 241, 0.25);
--shadow-glow-accent: 0 0 20px rgba(0, 224, 164, 0.25);
--shadow-glow-secondary: 0 0 20px rgba(123, 97, 255, 0.25);
/* ── Typography ── */
--font-data:
@@ -96,7 +96,7 @@
--foreground: 210 40% 98%;
--card: 223 46% 8%;
--card-foreground: 210 40% 98%;
--primary: 187 100% 50%;
--primary: 159 100% 44%;
--primary-foreground: 222 47% 8%;
--secondary: 224 30% 14%;
--secondary-foreground: 210 40% 98%;
@@ -114,17 +114,17 @@
background:
radial-gradient(
circle at 10% -10%,
rgba(0, 229, 255, 0.12),
rgba(0, 224, 164, 0.1),
transparent 40%
),
radial-gradient(
circle at 90% 0%,
rgba(99, 102, 241, 0.1),
rgba(123, 97, 255, 0.08),
transparent 36%
),
radial-gradient(
circle at 80% 100%,
rgba(8, 47, 73, 0.4),
rgba(0, 224, 164, 0.06),
transparent 48%
),
var(--color-bg-base);
@@ -188,7 +188,7 @@
.map-pill.active {
transform: translateY(-2px) scale(1.05);
box-shadow:
0 8px 24px rgba(0, 229, 255, 0.35),
0 8px 24px rgba(0, 224, 164, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
@@ -354,11 +354,11 @@
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.2);
background: rgba(123, 97, 255, 0.2);
border-radius: var(--radius-full);
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.35);
background: rgba(123, 97, 255, 0.35);
}
}
@@ -368,6 +368,6 @@
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
}
50% {
box-shadow: 0 10px 26px rgba(0, 229, 255, 0.3);
box-shadow: 0 10px 26px rgba(0, 224, 164, 0.3);
}
}
+112 -101
View File
@@ -16,14 +16,14 @@
--text-secondary: var(--color-text-secondary);
--text-muted: var(--color-text-muted);
/* Accents — upgraded primary cyan to match new token */
--accent-cyan: #22d3ee;
--accent-blue: #3b82f6;
--accent-green: #34d399;
--accent-orange: #f59e0b;
--accent-red: #fb7185;
--accent-yellow: #fbbf24;
--accent-purple: #7c3aed;
/* Accents — Fintech 3-Color Model */
--accent-cyan: #00e0a4;
--accent-blue: #7b61ff;
--accent-green: #00e0a4;
--accent-orange: #ffb020;
--accent-red: #ff4d6a;
--accent-yellow: #ffb020;
--accent-purple: #7b61ff;
/* Risk colors */
--risk-high: var(--color-risk-high);
@@ -64,10 +64,14 @@
height: 100vh;
width: 100vw;
background-image:
radial-gradient(circle at 18% 18%, rgba(30, 64, 175, 0.1), transparent 24%),
radial-gradient(
circle at 18% 18%,
rgba(0, 224, 164, 0.08),
transparent 24%
),
radial-gradient(
circle at 82% 12%,
rgba(8, 145, 178, 0.08),
rgba(123, 97, 255, 0.06),
transparent 20%
),
linear-gradient(180deg, #040914 0%, #02050d 100%);
@@ -86,8 +90,16 @@
background: #edf5ff;
color: #0b1726;
background-image:
radial-gradient(circle at 18% 18%, rgba(14, 165, 233, 0.16), transparent 25%),
radial-gradient(circle at 82% 12%, rgba(59, 130, 246, 0.12), transparent 22%),
radial-gradient(
circle at 18% 18%,
rgba(0, 224, 164, 0.12),
transparent 25%
),
radial-gradient(
circle at 82% 12%,
rgba(123, 97, 255, 0.1),
transparent 22%
),
linear-gradient(180deg, #f8fbff 0%, #e9f3ff 100%);
}
@@ -124,7 +136,7 @@
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(34, 211, 238, 0.04);
inset 0 0 0 1px rgba(0, 224, 164, 0.04);
}
.weatherAura {
@@ -158,8 +170,8 @@
),
radial-gradient(
circle at 50% 60%,
rgba(34, 211, 238, 0.08) 0%,
rgba(92, 142, 255, 0) 48%
rgba(0, 224, 164, 0.08) 0%,
rgba(123, 97, 255, 0) 48%
);
}
@@ -246,7 +258,7 @@
height: 30px;
flex-shrink: 0;
border-radius: 10px;
border: 1px solid rgba(34, 211, 238, 0.22);
border: 1px solid rgba(0, 224, 164, 0.22);
background: rgba(8, 15, 28, 0.78);
display: inline-flex;
align-items: center;
@@ -254,7 +266,7 @@
overflow: hidden;
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.08),
0 0 20px rgba(34, 211, 238, 0.12);
0 0 20px rgba(0, 224, 164, 0.12);
}
.root :global(.brand-mark img) {
@@ -368,8 +380,8 @@
.root :global(.lang-btn.active) {
color: var(--text-primary);
background: rgba(0, 229, 255, 0.12);
box-shadow: inset 0 0 0 1px rgba(0, 229, 255, 0.2);
background: rgba(0, 224, 164, 0.12);
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.2);
}
.root :global(.live-badge) {
@@ -378,8 +390,8 @@
gap: 5px;
padding: 4px 10px;
border-radius: 9999px;
background: rgba(34, 197, 94, 0.08);
border: 1px solid rgba(34, 197, 94, 0.22);
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);
@@ -399,11 +411,11 @@
0%,
100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4);
box-shadow: 0 0 0 0 rgba(0, 224, 164, 0.4);
}
50% {
opacity: 0.7;
box-shadow: 0 0 0 6px rgba(34, 197, 94, 0);
box-shadow: 0 0 0 6px rgba(0, 224, 164, 0);
}
}
@@ -421,8 +433,8 @@
justify-content: center;
}
.root :global(.refresh-btn:hover) {
background: rgba(0, 229, 255, 0.1);
border-color: rgba(0, 229, 255, 0.3);
background: rgba(0, 224, 164, 0.1);
border-color: rgba(0, 224, 164, 0.3);
color: var(--accent-cyan);
}
.root :global(.refresh-btn.spinning) {
@@ -433,7 +445,7 @@
height: 32px;
padding: 3px;
border-radius: 10px;
border: 1px solid rgba(34, 211, 238, 0.2);
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;
@@ -456,12 +468,12 @@
.root :global(.locale-switch span.active) {
color: #f8fafc;
background: rgba(34, 211, 238, 0.16);
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.24);
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(34, 211, 238, 0.34);
border-color: rgba(0, 224, 164, 0.34);
background: rgba(11, 22, 40, 0.94);
}
@@ -485,13 +497,13 @@
.root :global(.header-utility-btn:hover) {
color: #f8fafc;
border-color: rgba(34, 211, 238, 0.34);
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(59, 130, 246, 0.34);
border-color: rgba(123, 97, 255, 0.34);
}
.root :global(.header-utility-btn.more) {
@@ -564,14 +576,14 @@
}
.root :global(.city-count) {
background: rgba(99, 102, 241, 0.2);
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(99, 102, 241, 0.15);
border: 1px solid rgba(123, 97, 255, 0.15);
}
.root :global(.city-list-items) {
@@ -675,7 +687,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(99, 102, 241, 0.26);
background: rgba(123, 97, 255, 0.26);
color: var(--text-primary);
font-size: 10px;
font-weight: 700;
@@ -717,17 +729,17 @@
}
.root :global(.city-item:hover) {
background: rgba(10, 26, 44, 0.84);
border-color: rgba(34, 211, 238, 0.18);
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.03);
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(34, 211, 238, 0.12), rgba(30, 64, 175, 0.1)),
linear-gradient(135deg, rgba(0, 224, 164, 0.12), rgba(123, 97, 255, 0.1)),
rgba(9, 18, 32, 0.92);
border-color: rgba(34, 211, 238, 0.24);
border-color: rgba(0, 224, 164, 0.24);
box-shadow:
0 0 20px rgba(34, 211, 238, 0.08),
inset 0 0 0 1px rgba(34, 211, 238, 0.08);
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) {
@@ -1270,7 +1282,11 @@
border: 1px solid rgba(34, 211, 238, 0.14);
border-radius: 18px;
background:
radial-gradient(circle at 88% 24%, rgba(245, 158, 11, 0.12), transparent 24%),
radial-gradient(
circle at 88% 24%,
rgba(245, 158, 11, 0.12),
transparent 24%
),
linear-gradient(180deg, rgba(8, 16, 30, 0.62), rgba(4, 10, 22, 0.36));
}
@@ -1631,7 +1647,11 @@
.root :global(.home-card-section.intraday) {
border-color: rgba(34, 211, 238, 0.2);
background:
radial-gradient(circle at 0% 35%, rgba(34, 211, 238, 0.12), transparent 44%),
radial-gradient(
circle at 0% 35%,
rgba(34, 211, 238, 0.12),
transparent 44%
),
linear-gradient(180deg, rgba(7, 14, 26, 0.94), rgba(9, 17, 30, 0.82));
}
@@ -1702,49 +1722,6 @@
filter: drop-shadow(0 0 10px rgba(56, 189, 248, 0.28));
}
.root :global(.home-intraday-hotspot) {
position: absolute;
width: 18px;
height: 18px;
transform: translate(-50%, -50%);
border: 0;
border-radius: 999px;
background: transparent;
cursor: pointer;
}
.root :global(.home-intraday-hotspot:focus-visible) {
outline: 1px solid rgba(34, 211, 238, 0.6);
outline-offset: 1px;
}
.root :global(.home-intraday-tooltip) {
position: absolute;
transform: translate(-50%, -100%);
pointer-events: none;
display: inline-flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
border: 1px solid rgba(34, 211, 238, 0.22);
border-radius: 10px;
background: rgba(7, 15, 28, 0.96);
box-shadow: 0 10px 26px rgba(2, 6, 23, 0.34);
white-space: nowrap;
}
.root :global(.home-intraday-tooltip strong) {
color: #f8fafc;
font-size: 12px;
font-weight: 900;
}
.root :global(.home-intraday-tooltip span) {
color: rgba(148, 163, 184, 0.88);
font-size: 10px;
font-weight: 700;
}
.root :global(.home-intraday-reports) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1842,7 +1819,11 @@
.root :global(.home-card-section.probability) {
border-color: rgba(251, 113, 133, 0.18);
background:
radial-gradient(circle at 100% 0%, rgba(251, 113, 133, 0.08), transparent 42%),
radial-gradient(
circle at 100% 0%,
rgba(251, 113, 133, 0.08),
transparent 42%
),
linear-gradient(180deg, rgba(7, 14, 26, 0.94), rgba(9, 17, 30, 0.82));
}
@@ -2342,12 +2323,11 @@
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(34, 211, 238, 0.18);
background:
linear-gradient(
135deg,
rgba(8, 15, 28, 0.84),
rgba(13, 30, 52, 0.82)
);
background: linear-gradient(
135deg,
rgba(8, 15, 28, 0.84),
rgba(13, 30, 52, 0.82)
);
}
.root :global(.opportunity-hero-edgeblock span) {
@@ -2783,8 +2763,16 @@
/* ── Light Theme Dashboard Overrides ── */
:global(html.light) .root {
background:
radial-gradient(circle at 22% 12%, rgba(14, 165, 233, 0.16), transparent 28%),
radial-gradient(circle at 78% 0%, rgba(59, 130, 246, 0.12), transparent 24%),
radial-gradient(
circle at 22% 12%,
rgba(14, 165, 233, 0.16),
transparent 28%
),
radial-gradient(
circle at 78% 0%,
rgba(59, 130, 246, 0.12),
transparent 24%
),
linear-gradient(180deg, #f8fbff 0%, #e7f1fb 100%);
}
@@ -2893,7 +2881,11 @@
:global(html.light) .root :global(.home-intelligence-panel),
:global(html.light) .root :global(.home-opportunity-strip) {
background:
radial-gradient(circle at 12% 0%, rgba(14, 165, 233, 0.08), transparent 34%),
radial-gradient(
circle at 12% 0%,
rgba(14, 165, 233, 0.08),
transparent 34%
),
rgba(248, 252, 255, 0.9);
border-color: rgba(36, 68, 104, 0.16);
box-shadow: 0 28px 70px rgba(31, 64, 104, 0.14);
@@ -2914,7 +2906,11 @@
:global(html.light) .root :global(.home-forecast-item),
:global(html.light) .root :global(.home-intraday-reports span) {
background:
radial-gradient(circle at 100% 0%, rgba(14, 165, 233, 0.08), transparent 36%),
radial-gradient(
circle at 100% 0%,
rgba(14, 165, 233, 0.08),
transparent 36%
),
rgba(255, 255, 255, 0.92);
border-color: rgba(36, 68, 104, 0.14);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
@@ -2922,8 +2918,11 @@
}
:global(html.light) .root :global(.home-weather-hero) {
background:
linear-gradient(135deg, rgba(238, 246, 255, 0.98), rgba(216, 233, 248, 0.92));
background: linear-gradient(
135deg,
rgba(238, 246, 255, 0.98),
rgba(216, 233, 248, 0.92)
);
border-color: rgba(36, 68, 104, 0.14);
}
@@ -7774,7 +7773,11 @@
width: 38px;
height: 38px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(34, 211, 238, 0.9), rgba(59, 130, 246, 0.86));
background: linear-gradient(
135deg,
rgba(34, 211, 238, 0.9),
rgba(59, 130, 246, 0.86)
);
color: #04111d;
font-size: 13px;
font-weight: 900;
@@ -7975,7 +7978,11 @@
border: 1px solid rgba(34, 211, 238, 0.22);
border-radius: 12px;
padding: 9px 14px;
background: linear-gradient(135deg, rgba(8, 145, 178, 0.24), rgba(37, 99, 235, 0.24));
background: linear-gradient(
135deg,
rgba(8, 145, 178, 0.24),
rgba(37, 99, 235, 0.24)
);
color: #e0f2fe;
cursor: pointer;
font-size: 12px;
@@ -8004,7 +8011,11 @@
:global(html.light) .root :global(.home-ai-launcher),
:global(html.light) .root :global(.home-ai-panel) {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(240, 248, 255, 0.94));
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.96),
rgba(240, 248, 255, 0.94)
);
border-color: rgba(48, 77, 112, 0.16);
}
+148 -95
View File
@@ -62,8 +62,8 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
tension: 0.28,
},
{
backgroundColor: "#22d3ee",
borderColor: "#22d3ee",
backgroundColor: "#00E0A4",
borderColor: "#00E0A4",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
@@ -84,7 +84,7 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
legend: { display: false },
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.95)",
borderColor: "rgba(34, 211, 238, 0.25)",
borderColor: "rgba(0, 224, 164, 0.25)",
borderWidth: 1,
},
},
@@ -153,9 +153,13 @@ export function DetailPanel() {
const isAuthenticated = store.proAccess.authenticated;
const panelRef = useRef<HTMLElement | null>(null);
const [heavyContentReady, setHeavyContentReady] = useState(false);
const isOverlayOpen = Boolean(store.futureModalDate) || store.historyState.isOpen;
const isVisible = store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen;
const hasBasicPanelContent = Boolean(detail || selectedSummary || selectedCityItem);
const isOverlayOpen =
Boolean(store.futureModalDate) || store.historyState.isOpen;
const isVisible =
store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen;
const hasBasicPanelContent = Boolean(
detail || selectedSummary || selectedCityItem,
);
const panelDisplayName =
detail?.display_name ||
selectedSummary?.display_name ||
@@ -196,8 +200,8 @@ export function DetailPanel() {
const heroAirportLabel = detail?.risk?.airport || basicAirportLabel;
const isSparsePanelDetail = Boolean(
detail &&
(detail.detail_depth !== "full" ||
(detail.forecast?.daily?.length ?? 0) <= 1),
(detail.detail_depth !== "full" ||
(detail.forecast?.daily?.length ?? 0) <= 1),
);
const isPanelSyncing = store.loadingState.cityDetail;
const isShowingCachedDetailDuringSync = Boolean(detail && isPanelSyncing);
@@ -249,7 +253,10 @@ export function DetailPanel() {
if (!isVisible) {
panel.setAttribute("inert", "");
if (typeof document !== "undefined" && panel.contains(document.activeElement)) {
if (
typeof document !== "undefined" &&
panel.contains(document.activeElement)
) {
const active = document.activeElement;
if (active instanceof HTMLElement) {
active.blur();
@@ -288,7 +295,11 @@ export function DetailPanel() {
return () => {
canceled = true;
if (win && idleId != null && typeof win.cancelIdleCallback === "function") {
if (
win &&
idleId != null &&
typeof win.cancelIdleCallback === "function"
) {
win.cancelIdleCallback(idleId);
}
if (timeoutId != null && typeof window !== "undefined") {
@@ -298,7 +309,10 @@ export function DetailPanel() {
}, [detail, isVisible]);
return (
<aside ref={panelRef} className={clsx("detail-panel", isVisible && "visible")}>
<aside
ref={panelRef}
className={clsx("detail-panel", isVisible && "visible")}
>
<div className="panel-header">
<button
type="button"
@@ -311,7 +325,7 @@ export function DetailPanel() {
>
×
</button>
<div className="panel-title-area">
<div className="panel-title-area">
<div className="panel-title-stack">
<div className="panel-overline">
<span>{locale === "en-US" ? "City briefing" : "城市简报"}</span>
@@ -321,7 +335,11 @@ export function DetailPanel() {
<h2>{panelDisplayName}</h2>
</div>
{isPanelSyncing && (
<div className="panel-loading-hint" role="status" aria-live="polite">
<div
className="panel-loading-hint"
role="status"
aria-live="polite"
>
<span className="panel-loading-spinner" aria-hidden="true" />
<span>
{locale === "en-US"
@@ -338,8 +356,12 @@ export function DetailPanel() {
<span className={clsx("risk-badge", panelRiskLevel)}>
{getRiskBadgeLabel(panelRiskLevel, locale)}
</span>
<span className="panel-meta-chip panel-meta-chip-strong">{heroSettlementLabel}</span>
<span className="panel-meta-chip panel-meta-chip-muted">{heroAirportLabel}</span>
<span className="panel-meta-chip panel-meta-chip-strong">
{heroSettlementLabel}
</span>
<span className="panel-meta-chip panel-meta-chip-muted">
{heroAirportLabel}
</span>
</div>
<div className="panel-actions">
{marketUrl ? (
@@ -365,12 +387,16 @@ export function DetailPanel() {
!isPro && "pro-locked",
)}
title={
isPro ? t("detail.todayAnalysis") : `${t("detail.todayAnalysis")} (Pro)`
isPro
? t("detail.todayAnalysis")
: `${t("detail.todayAnalysis")} (Pro)`
}
onClick={() => handleFeatureAccess("today")}
disabled={!store.selectedCity}
>
{isPro ? t("detail.todayAnalysis") : `${t("detail.todayAnalysis")} · Pro`}
{isPro
? t("detail.todayAnalysis")
: `${t("detail.todayAnalysis")} · Pro`}
</button>
<button
type="button"
@@ -379,7 +405,9 @@ export function DetailPanel() {
"panel-action-button-secondary",
!isPro && "pro-locked",
)}
title={isPro ? t("detail.history") : `${t("detail.history")} (Pro)`}
title={
isPro ? t("detail.history") : `${t("detail.history")} (Pro)`
}
onClick={() => handleFeatureAccess("history")}
disabled={!store.selectedCity}
>
@@ -389,7 +417,12 @@ export function DetailPanel() {
</div>
</div>
<div className={clsx("panel-body", isShowingCachedDetailDuringSync && "is-syncing")}>
<div
className={clsx(
"panel-body",
isShowingCachedDetailDuringSync && "is-syncing",
)}
>
{isShowingCachedDetailDuringSync ? (
<div className="panel-sync-blocker" role="status" aria-live="polite">
<span className="panel-loading-spinner" aria-hidden="true" />
@@ -400,101 +433,121 @@ export function DetailPanel() {
</span>
</div>
) : null}
<div className={clsx(isShowingCachedDetailDuringSync && "panel-content-stale")}>
{!hasBasicPanelContent ? (
<section className="detail-summary-shell detail-empty-state">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">
{locale === "en-US" ? "No city selected" : "尚未选择城市"}
</div>
<h3>{locale === "en-US" ? "Pick a city to start." : "先选择一个城市。"}</h3>
</div>
</div>
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
{store.loadingState.cityDetail ? t("detail.loading") : t("detail.emptyHint")}
</div>
</section>
) : !detail ? (
<div className="detail-mini-meta" role="status" aria-live="polite">
{store.loadingState.cityDetail
? locale === "en-US"
? "Loading city cards..."
: "正在加载城市卡片..."
: locale === "en-US"
? "City cards will appear here."
: "城市卡片会显示在这里。"}
</div>
) : (
<>
<section className="detail-structured-section">
<div
className={clsx(
isShowingCachedDetailDuringSync && "panel-content-stale",
)}
>
{!hasBasicPanelContent ? (
<section className="detail-summary-shell detail-empty-state">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">{locale === "en-US" ? "Profile" : "城市画像"}</div>
<h3>{t("detail.profile")}</h3>
<div className="detail-section-kicker">
{locale === "en-US" ? "No city selected" : "尚未选择城市"}
</div>
<h3>
{locale === "en-US"
? "Pick a city to start."
: "先选择一个城市。"}
</h3>
</div>
</div>
<div className="detail-grid">
{profileStats.map((item) => (
<article key={item.label} className="detail-card">
<span className="detail-label">{item.label}</span>
<span className="detail-value">{item.value}</span>
</article>
))}
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
{store.loadingState.cityDetail
? t("detail.loading")
: t("detail.emptyHint")}
</div>
</section>
{officialLinks.length > 0 ? (
) : !detail ? (
<div className="detail-mini-meta" role="status" aria-live="polite">
{store.loadingState.cityDetail
? locale === "en-US"
? "Loading city cards..."
: "正在加载城市卡片..."
: locale === "en-US"
? "City cards will appear here."
: "城市卡片会显示在这里。"}
</div>
) : (
<>
<section className="detail-structured-section">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">
{locale === "en-US" ? "Primary references" : "官方参考"}
{locale === "en-US" ? "Profile" : "城市画像"}
</div>
<h3>{locale === "en-US" ? "Settlement and observation references" : "结算与观测参考来源"}</h3>
<h3>{t("detail.profile")}</h3>
</div>
</div>
<p className="detail-source-note">
{locale === "en-US"
? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page."
: "AGENCY = 国家气象机构,METAR = 机场实测报文,AIRPORT = 机场官网页面。"}
</p>
<div className="detail-source-list">
{officialLinks.map((link) => (
<a
key={`${link.label}-${link.href}`}
className="detail-source-link"
href={link.href}
target="_blank"
rel="noreferrer"
>
<span className="detail-source-kind">{link.kind.toUpperCase()}</span>
<span className="detail-source-label">{link.label}</span>
</a>
<div className="detail-grid">
{profileStats.map((item) => (
<article key={item.label} className="detail-card">
<span className="detail-label">{item.label}</span>
<span className="detail-value">{item.value}</span>
</article>
))}
</div>
</section>
) : null}
<section className="detail-structured-section rounded-2xl">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">
{locale === "en-US" ? "Mini trend" : "温度微趋势"}
{officialLinks.length > 0 ? (
<section className="detail-structured-section">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">
{locale === "en-US" ? "Primary references" : "官方参考"}
</div>
<h3>
{locale === "en-US"
? "Settlement and observation references"
: "结算与观测参考来源"}
</h3>
</div>
</div>
<h3>{t("detail.todayMiniTrend")}</h3>
</div>
</div>
{heavyContentReady ? (
<DetailMiniTemperatureChart detail={detail} />
) : (
<div className="detail-mini-meta">{t("detail.loading")}</div>
)}
</section>
<p className="detail-source-note">
{locale === "en-US"
? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page."
: "AGENCY = 国家气象机构,METAR = 机场实测报文,AIRPORT = 机场官网页面。"}
</p>
<div className="detail-source-list">
{officialLinks.map((link) => (
<a
key={`${link.label}-${link.href}`}
className="detail-source-link"
href={link.href}
target="_blank"
rel="noreferrer"
>
<span className="detail-source-kind">
{link.kind.toUpperCase()}
</span>
<span className="detail-source-label">
{link.label}
</span>
</a>
))}
</div>
</section>
) : null}
{heavyContentReady ? <ForecastTable /> : null}
</>
)}
<section className="detail-structured-section rounded-2xl">
<div className="detail-section-head">
<div>
<div className="detail-section-kicker">
{locale === "en-US" ? "Mini trend" : "温度微趋势"}
</div>
<h3>{t("detail.todayMiniTrend")}</h3>
</div>
</div>
{heavyContentReady ? (
<DetailMiniTemperatureChart detail={detail} />
) : (
<div className="detail-mini-meta">{t("detail.loading")}</div>
)}
</section>
{heavyContentReady ? <ForecastTable /> : null}
</>
)}
</div>
</div>
</aside>
@@ -36,7 +36,10 @@ import {
normalizeObservationSourceCode,
normalizeObservationSourceLabel,
} from "@/lib/source-labels";
import type { IntradayMeteorologySignal, MarketScan } from "@/lib/dashboard-types";
import type {
IntradayMeteorologySignal,
MarketScan,
} from "@/lib/dashboard-types";
function normalizeMarketValue(value?: number | null) {
if (value == null) return null;
@@ -182,7 +185,9 @@ function parsePercentFromText(value?: string | number | null) {
}
function formatConfidenceLabel(value?: string | null, locale = "zh-CN") {
const normalized = String(value || "").trim().toLowerCase();
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" : "低";
@@ -190,21 +195,29 @@ function formatConfidenceLabel(value?: string | null, locale = "zh-CN") {
}
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" : "压制峰值";
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "support")
return locale === "en-US" ? "Support" : "支持升温";
if (normalized === "suppress")
return locale === "en-US" ? "Suppress" : "压制峰值";
return locale === "en-US" ? "Neutral" : "中性";
}
function formatSignalStrength(value?: string | null, locale = "zh-CN") {
const normalized = String(value || "").trim().toLowerCase();
const normalized = String(value || "")
.trim()
.toLowerCase();
if (normalized === "strong") return locale === "en-US" ? "Strong" : "强";
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
return locale === "en-US" ? "Weak" : "弱";
}
function signalTone(signal?: IntradayMeteorologySignal | null) {
const direction = String(signal?.direction || "").trim().toLowerCase();
const direction = String(signal?.direction || "")
.trim()
.toLowerCase();
if (direction === "support") return "cyan";
if (direction === "suppress") return "amber";
return "blue";
@@ -261,7 +274,7 @@ function getTrendMetricVisual(metric: {
return {
mode: "center" as const,
percent: clamp(50 + (numeric / 4) * 50, 0, 100),
tone: numeric >= 0 ? "warm" as const : "cold" as const,
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
};
}
@@ -269,7 +282,7 @@ function getTrendMetricVisual(metric: {
return {
mode: "center" as const,
percent: clamp(50 + (numeric / 3) * 50, 0, 100),
tone: numeric >= 0 ? "warm" as const : "cold" as const,
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
};
}
@@ -277,7 +290,7 @@ function getTrendMetricVisual(metric: {
return {
mode: "center" as const,
percent: clamp(50 + (numeric / 4) * 50, 0, 100),
tone: numeric >= 0 ? "warm" as const : "cold" as const,
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
};
}
@@ -285,7 +298,7 @@ function getTrendMetricVisual(metric: {
return {
mode: "center" as const,
percent: clamp(50 + (numeric / 40) * 50, 0, 100),
tone: numeric >= 0 ? "cold" as const : "warm" as const,
tone: numeric >= 0 ? ("cold" as const) : ("warm" as const),
};
}
@@ -303,7 +316,8 @@ function DailyTemperatureChart({
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 isToday =
forceToday || (detail ? dateStr === detail.local_date : false);
const todayChartData = useMemo(
() => (detail && isToday ? getTemperatureChartData(detail, locale) : null),
[detail, isToday, locale],
@@ -363,8 +377,8 @@ function DailyTemperatureChart({
}
datasets.push({
backgroundColor: "#22d3ee",
borderColor: "#22d3ee",
backgroundColor: "#00E0A4",
borderColor: "#00E0A4",
borderWidth: 0,
data: todayChartData.datasets.metarSeries,
fill: false,
@@ -385,8 +399,7 @@ function DailyTemperatureChart({
borderWidth: 1,
data: todayChartData.datasets.airportMetarSeries,
fill: false,
label:
locale === "en-US" ? "Airport METAR" : "机场 METAR",
label: locale === "en-US" ? "Airport METAR" : "机场 METAR",
order: 0,
parsing: false,
pointHoverRadius: 6,
@@ -416,7 +429,7 @@ function DailyTemperatureChart({
Math.abs(todayChartData.datasets.offset) > 0.3
) {
datasets.push({
borderColor: "rgba(99, 102, 241, 0.2)",
borderColor: "rgba(123, 97, 255, 0.2)",
borderDash: [2, 4],
borderWidth: 1,
data: todayChartData.datasets.tempsSeries,
@@ -484,7 +497,8 @@ function DailyTemperatureChart({
filter: (legendItem, chartData) => {
const text = String(legendItem.text || "");
if (!text) return false;
if (text === "TAF Timing" || text === "TAF 时段") return false;
if (text === "TAF Timing" || text === "TAF 时段")
return false;
if (!text.includes("DEB")) return true;
const firstDebIndex = (chartData.datasets || []).findIndex(
@@ -497,17 +511,27 @@ function DailyTemperatureChart({
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.96)",
borderColor: "rgba(34, 211, 238, 0.2)",
borderColor: "rgba(0, 224, 164, 0.2)",
borderWidth: 1,
callbacks: {
title: (items) => {
const rawX = items?.[0]?.parsed?.x;
return rawX != null ? formatMinuteAxisLabel(Number(rawX)) : "";
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 } }
| {
marker?: {
summary?: string;
markerType?: string;
displayType?: string;
isCurrent?: boolean;
isPeakWindow?: boolean;
};
}
| undefined;
if (
label === "TAF Timing" ||
@@ -539,7 +563,9 @@ function DailyTemperatureChart({
: "峰值窗口 TAF"
: label;
return `${prefix}: ${
markerType ? summary.replace(markerType, displayType) : summary
markerType
? summary.replace(markerType, displayType)
: summary
}`;
}
const value = ctx.parsed.y;
@@ -598,8 +624,8 @@ function DailyTemperatureChart({
data: {
datasets: [
{
backgroundColor: "rgba(34, 211, 238, 0.08)",
borderColor: "#22d3ee",
backgroundColor: "rgba(0, 224, 164, 0.08)",
borderColor: "#00E0A4",
data: view.slice.map((point) => point.temp),
fill: false,
label:
@@ -632,7 +658,7 @@ function DailyTemperatureChart({
},
tooltip: {
backgroundColor: "rgba(15, 23, 42, 0.96)",
borderColor: "rgba(34, 211, 238, 0.2)",
borderColor: "rgba(0, 224, 164, 0.2)",
borderWidth: 1,
callbacks: {
label: (ctx) =>
@@ -685,8 +711,11 @@ export function FutureForecastModal() {
const dateStr = store.futureModalDate;
const isPro = store.proAccess.subscriptionActive;
const isProLoading = store.proAccess.loading;
const [showDeferredTodaySections, setShowDeferredTodaySections] = useState(false);
const [freshMarketScan, setFreshMarketScan] = useState<MarketScan | null>(null);
const [showDeferredTodaySections, setShowDeferredTodaySections] =
useState(false);
const [freshMarketScan, setFreshMarketScan] = useState<MarketScan | null>(
null,
);
if (!detail || !dateStr) return null;
@@ -728,7 +757,8 @@ export function FutureForecastModal() {
(store.forecastModalMode == null && dateStr === detail.local_date);
const detailDepth = detail.detail_depth || "full";
const isFullDetailReady = detailDepth === "full";
const isStructureSyncing = store.loadingState.futureDeep || !isFullDetailReady;
const isStructureSyncing =
store.loadingState.futureDeep || !isFullDetailReady;
const isAnyLayerSyncing = isStructureSyncing;
const isTodayBlockingRefresh = isToday && isStructureSyncing;
const activeMarketScan = freshMarketScan || detail.market_scan || null;
@@ -763,7 +793,10 @@ export function FutureForecastModal() {
refreshMarketScan();
intervalId = setInterval(() => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
if (
typeof document !== "undefined" &&
document.visibilityState === "hidden"
) {
return;
}
refreshMarketScan();
@@ -804,7 +837,10 @@ export function FutureForecastModal() {
() => getProbabilityView(detail, dateStr),
[dateStr, detail],
);
const modelView = useMemo(() => getModelView(detail, dateStr), [dateStr, detail]);
const modelView = useMemo(
() => getModelView(detail, dateStr),
[dateStr, detail],
);
const probabilityEngineKey = String(probabilityView?.engine || "")
.trim()
.toLowerCase();
@@ -816,12 +852,16 @@ export function FutureForecastModal() {
const hasLgbmProbability = useMemo(
() =>
Object.keys(modelView?.models || {}).some((name) =>
String(name || "").toLowerCase().replace(/[\s_/-]/g, "").includes("lgbm"),
String(name || "")
.toLowerCase()
.replace(/[\s_/-]/g, "")
.includes("lgbm"),
),
[modelView],
);
const hasEmosProbability =
probabilityEngineKey === "emos" || probabilityCalibrationMode.includes("emos");
probabilityEngineKey === "emos" ||
probabilityCalibrationMode.includes("emos");
const probabilityEngineLabel = hasLgbmProbability
? locale === "en-US"
? "LGBM"
@@ -877,7 +917,9 @@ export function FutureForecastModal() {
if (projected == null || !Number.isFinite(projected)) return null;
const distances = [bounds.lower, bounds.upper]
.filter((value): value is number => value != null && Number.isFinite(value))
.filter(
(value): value is number => value != null && Number.isFinite(value),
)
.map((value) => ({
boundary: value,
gap: Math.abs(projected - value),
@@ -911,7 +953,15 @@ export function FutureForecastModal() {
tone,
value: `${nearest.gap.toFixed(1)}${detail.temp_symbol}`,
};
}, [detail.deb?.prediction, detail.temp_symbol, isToday, locale, paceView, showDeferredTodaySections, topProbabilityBucket]);
}, [
detail.deb?.prediction,
detail.temp_symbol,
isToday,
locale,
paceView,
showDeferredTodaySections,
topProbabilityBucket,
]);
const peakWindowStateView = useMemo(() => {
if (!showDeferredTodaySections) return null;
if (!isToday || !paceView) return null;
@@ -948,7 +998,15 @@ export function FutureForecastModal() {
tone,
value: paceView.peakWindowText,
};
}, [detail.local_time, detail.peak?.first_h, detail.peak?.last_h, isToday, locale, paceView, showDeferredTodaySections]);
}, [
detail.local_time,
detail.peak?.first_h,
detail.peak?.last_h,
isToday,
locale,
paceView,
showDeferredTodaySections,
]);
const networkLeadView = useMemo(() => {
if (!showDeferredTodaySections) return null;
if (!isToday) return null;
@@ -958,8 +1016,12 @@ export function FutureForecastModal() {
const leaderLabel =
String(leadSignal?.leader_station_label || "").trim() ||
String(leadSignal?.leader_station_code || "").trim();
const leaderSyncStatus = String(leadSignal?.leader_sync_status || "").trim().toLowerCase();
const leaderSyncDelta = Number(leadSignal?.leader_time_delta_vs_anchor_minutes);
const leaderSyncStatus = String(leadSignal?.leader_sync_status || "")
.trim()
.toLowerCase();
const leaderSyncDelta = Number(
leadSignal?.leader_time_delta_vs_anchor_minutes,
);
const syncNote =
leaderSyncStatus === "near_realtime" || leaderSyncStatus === "lagged"
? Number.isFinite(leaderSyncDelta)
@@ -987,8 +1049,7 @@ export function FutureForecastModal() {
: locale === "en-US"
? "Tracking network"
: "与站网齐平";
const tone =
delta <= -0.4 ? "amber" : delta >= 0.4 ? "cyan" : "blue";
const tone = delta <= -0.4 ? "amber" : delta >= 0.4 ? "cyan" : "blue";
const note =
delta <= -0.4
? locale === "en-US"
@@ -1008,7 +1069,14 @@ export function FutureForecastModal() {
tone,
value: `${delta > 0 ? "+" : ""}${delta.toFixed(1)}${detail.temp_symbol}`,
};
}, [detail.airport_vs_network_delta, detail.network_lead_signal, detail.temp_symbol, isToday, locale, showDeferredTodaySections]);
}, [
detail.airport_vs_network_delta,
detail.network_lead_signal,
detail.temp_symbol,
isToday,
locale,
showDeferredTodaySections,
]);
const isNoaaSettlement =
detail.current?.settlement_source === "noaa" ||
detail.current?.settlement_source_label === "NOAA";
@@ -1056,7 +1124,8 @@ export function FutureForecastModal() {
: "当前拿不到可用的模型分歧。";
}
const modelEntries = Object.entries(modelView?.models || {}).filter(
([, value]) => value !== null && value !== undefined && Number.isFinite(Number(value)),
([, value]) =>
value !== null && value !== undefined && Number.isFinite(Number(value)),
);
if (modelEntries.length === 1) {
const [singleModelName, singleModelValue] = modelEntries[0];
@@ -1072,9 +1141,12 @@ export function FutureForecastModal() {
const tafSignal = detail.taf?.signal || {};
const upperAirCue = useMemo(() => {
if (!showDeferredTodaySections) return null;
if (!isToday || (!upperAirSignal.source && !tafSignal.available)) return null;
if (!isToday || (!upperAirSignal.source && !tafSignal.available))
return null;
const setup = String(upperAirSignal.heating_setup || "neutral").toLowerCase();
const setup = String(
upperAirSignal.heating_setup || "neutral",
).toLowerCase();
const tafSuppression = String(
tafSignal.suppression_level || "low",
).toLowerCase();
@@ -1228,7 +1300,9 @@ export function FutureForecastModal() {
if (!showDeferredTodaySections) return [] as string[];
const commentary = detail.dynamic_commentary || {};
const headline = String(
locale === "en-US" ? commentary.headline_en || "" : commentary.headline_zh || "",
locale === "en-US"
? commentary.headline_en || ""
: commentary.headline_zh || "",
).trim();
const bullets = (
locale === "en-US" ? commentary.bullets_en : commentary.bullets_zh
@@ -1275,9 +1349,19 @@ export function FutureForecastModal() {
);
}
return lines.slice(0, 3);
}, [boundaryRiskView, isToday, locale, localizedAiCommentaryLines, networkLeadView, paceView, showDeferredTodaySections]);
}, [
boundaryRiskView,
isToday,
locale,
localizedAiCommentaryLines,
networkLeadView,
paceView,
showDeferredTodaySections,
]);
const intradayMeteorology = detail.intraday_meteorology || {};
const meteorologySignals = Array.isArray(intradayMeteorology.signal_contributions)
const meteorologySignals = Array.isArray(
intradayMeteorology.signal_contributions,
)
? intradayMeteorology.signal_contributions
: [];
const invalidationRules = localizedList(
@@ -1382,40 +1466,38 @@ export function FutureForecastModal() {
{
key: "base",
state: isAnyLayerSyncing ? "syncing" : "ready",
label:
isAnyLayerSyncing
? locale === "en-US"
? "Refreshing base analysis"
: "正在刷新基础分析"
: locale === "en-US" ? "Base analysis ready" : "基础分析已加载",
note:
isAnyLayerSyncing
? locale === "en-US"
? "Latest anchor readings and forecast curve are being rebuilt."
: "正在重建最新锚点读数和预测曲线。"
: locale === "en-US"
? "Forecast curve, anchor state, and the core intraday view are available."
: "预测曲线、锚点状态和核心日内视图已经可用。",
label: isAnyLayerSyncing
? locale === "en-US"
? "Refreshing base analysis"
: "正在刷新基础分析"
: locale === "en-US"
? "Base analysis ready"
: "基础分析已加载",
note: isAnyLayerSyncing
? locale === "en-US"
? "Latest anchor readings and forecast curve are being rebuilt."
: "正在重建最新锚点读数和预测曲线。"
: locale === "en-US"
? "Forecast curve, anchor state, and the core intraday view are available."
: "预测曲线、锚点状态和核心日内视图已经可用。",
},
{
key: "market",
state: isAnyLayerSyncing ? "syncing" : "ready",
label:
isAnyLayerSyncing
? locale === "en-US"
? "Refreshing probability layer"
: "正在刷新概率层"
: locale === "en-US"
? "Probability layer ready"
: "概率层已加载",
note:
isAnyLayerSyncing
? locale === "en-US"
? "Model spread and calibrated buckets are updating."
: "模型分歧和校准概率桶正在更新。"
: locale === "en-US"
? `Probability buckets are derived from the ${probabilityEngineLabel} layer.`
: `概率桶当前由 ${probabilityEngineLabel} 层推导。`,
label: isAnyLayerSyncing
? locale === "en-US"
? "Refreshing probability layer"
: "正在刷新概率层"
: locale === "en-US"
? "Probability layer ready"
: "概率层已加载",
note: isAnyLayerSyncing
? locale === "en-US"
? "Model spread and calibrated buckets are updating."
: "模型分歧和校准概率桶正在更新。"
: locale === "en-US"
? `Probability buckets are derived from the ${probabilityEngineLabel} layer.`
: `概率桶当前由 ${probabilityEngineLabel} 层推导。`,
},
] as const;
@@ -1450,7 +1532,9 @@ export function FutureForecastModal() {
<div className="modal-header">
<div className="modal-title-stack">
<div className="modal-overline">
<span>{locale === "en-US" ? "Analysis workspace" : "分析工作台"}</span>
<span>
{locale === "en-US" ? "Analysis workspace" : "分析工作台"}
</span>
<span className="modal-overline-sep"></span>
<span>{detail.display_name.toUpperCase()}</span>
</div>
@@ -1534,8 +1618,15 @@ export function FutureForecastModal() {
)}
>
{isTodayBlockingRefresh && (
<div className="future-v2-refresh-lock" role="status" aria-live="assertive">
<span className="future-v2-refresh-spinner" aria-hidden="true" />
<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"
@@ -1555,19 +1646,27 @@ export function FutureForecastModal() {
<div className="future-v2-meteorology-copy">
<div className="future-v2-anchor-row">
<div className="modal-section-kicker">
{locale === "en-US" ? "Professional meteorology read" : "专业气象判断"}
{locale === "en-US"
? "Professional meteorology read"
: "专业气象判断"}
</div>
<span className="future-v2-anchor-source">{anchorSourceLabel}</span>
<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(intradayMeteorology.confidence, locale)}
{formatConfidenceLabel(
intradayMeteorology.confidence,
locale,
)}
</span>
<span>
{locale === "en-US" ? "Path state" : "路径状态"} · {pathStatus}
{locale === "en-US" ? "Path state" : "路径状态"} ·{" "}
{pathStatus}
</span>
<span>
{nextObservationLabel} · {nextObservationTime}
@@ -1587,14 +1686,20 @@ export function FutureForecastModal() {
</div>
<div>
<span>{locale === "en-US" ? "Upside" : "上修"}</span>
<strong>{intradayMeteorology.upside_bucket || "--"}</strong>
<strong>
{intradayMeteorology.upside_bucket || "--"}
</strong>
</div>
<div>
<span>{locale === "en-US" ? "Downside" : "下修"}</span>
<strong>{intradayMeteorology.downside_bucket || "--"}</strong>
<strong>
{intradayMeteorology.downside_bucket || "--"}
</strong>
</div>
<div>
<span>{locale === "en-US" ? "Gap to base" : "距基准还差"}</span>
<span>
{locale === "en-US" ? "Gap to base" : "距基准还差"}
</span>
<strong>{gapToBaseText}</strong>
</div>
</div>
@@ -1637,9 +1742,7 @@ export function FutureForecastModal() {
<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"
: "锚点状态"}
{locale === "en-US" ? "Anchor Status" : "锚点状态"}
</h3>
<div className="future-v2-card-kicker">
{locale === "en-US"
@@ -1669,9 +1772,7 @@ export function FutureForecastModal() {
<div className="future-v2-daylight">
<div className="future-v2-daylight-head">
<span>
{locale === "en-US"
? "Solar Window"
: "昼夜进度"}
{locale === "en-US" ? "Solar Window" : "昼夜进度"}
</span>
<strong>
{locale === "en-US"
@@ -1684,7 +1785,9 @@ export function FutureForecastModal() {
style={
{
"--daylight-progress": `${daylightProgress.percent}%`,
} as CSSProperties & { "--daylight-progress": string }
} as CSSProperties & {
"--daylight-progress": string;
}
}
/>
<div className="future-v2-daylight-times">
@@ -1735,9 +1838,7 @@ export function FutureForecastModal() {
<span>
{locale === "en-US" ? "Sunset" : "日落时间"}
</span>
<strong>
{detail.forecast?.sunset || "--"}
</strong>
<strong>{detail.forecast?.sunset || "--"}</strong>
</div>
</div>
</section>
@@ -1835,10 +1936,17 @@ export function FutureForecastModal() {
</div>
</div>
<div className="future-v2-pace-signal-grid">
{[boundaryRiskView, peakWindowStateView, networkLeadView]
{[
boundaryRiskView,
peakWindowStateView,
networkLeadView,
]
.filter((item) => item != null)
.map((item) => (
<div key={item.label} className="future-v2-pace-signal-card">
<div
key={item.label}
className="future-v2-pace-signal-card"
>
<div className="future-v2-signal-head">
<span>{item.label}</span>
<em
@@ -1879,7 +1987,6 @@ export function FutureForecastModal() {
</div>
</section>
) : null}
</aside>
<main className="future-v2-right">
@@ -1894,11 +2001,23 @@ export function FutureForecastModal() {
: "今日气温路径(锚点观测 + 模型)"}
</h3>
</div>
<DailyTemperatureChart dateStr={dateStr} forceToday={isToday} />
<DailyTemperatureChart
dateStr={dateStr}
forceToday={isToday}
/>
<div className="future-v2-chart-thresholds">
<span>{locale === "en-US" ? "Base" : "基准"} · {baseCaseBucket || "--"}</span>
<span>{locale === "en-US" ? "Upside" : "上修"} · {intradayMeteorology.upside_bucket || "--"}</span>
<span>{locale === "en-US" ? "Invalidates at" : "失效观察"} · {nextObservationTime}</span>
<span>
{locale === "en-US" ? "Base" : "基准"} ·{" "}
{baseCaseBucket || "--"}
</span>
<span>
{locale === "en-US" ? "Upside" : "上修"} ·{" "}
{intradayMeteorology.upside_bucket || "--"}
</span>
<span>
{locale === "en-US" ? "Invalidates at" : "失效观察"} ·{" "}
{nextObservationTime}
</span>
</div>
</section>
@@ -1908,7 +2027,11 @@ export function FutureForecastModal() {
<div className="modal-section-kicker">
{locale === "en-US" ? "Evidence chain" : "气象证据链"}
</div>
<h3>{locale === "en-US" ? "Signal Contributions" : "信号贡献"}</h3>
<h3>
{locale === "en-US"
? "Signal Contributions"
: "信号贡献"}
</h3>
</div>
<div className="future-v2-evidence-list">
{meteorologySignals.length > 0 ? (
@@ -1922,15 +2045,30 @@ export function FutureForecastModal() {
>
<div className="future-v2-evidence-head">
<strong>
{localizedText(locale, signal.label, signal.label_en) || "--"}
{localizedText(
locale,
signal.label,
signal.label_en,
) || "--"}
</strong>
<span>
{formatSignalDirection(signal.direction, locale)} ·{" "}
{formatSignalStrength(signal.strength, locale)}
{formatSignalDirection(
signal.direction,
locale,
)}{" "}
·{" "}
{formatSignalStrength(
signal.strength,
locale,
)}
</span>
</div>
<p>
{localizedText(locale, signal.summary, signal.summary_en) || "--"}
{localizedText(
locale,
signal.summary,
signal.summary_en,
) || "--"}
</p>
</div>
))
@@ -1949,7 +2087,11 @@ export function FutureForecastModal() {
<div className="modal-section-kicker">
{locale === "en-US" ? "Failure modes" : "失效条件"}
</div>
<h3>{locale === "en-US" ? "What Downgrades the Read" : "什么会让判断降级"}</h3>
<h3>
{locale === "en-US"
? "What Downgrades the Read"
: "什么会让判断降级"}
</h3>
</div>
<ul className="future-v2-rule-list">
{(invalidationRules.length > 0
@@ -1970,7 +2112,11 @@ export function FutureForecastModal() {
<div className="modal-section-kicker">
{locale === "en-US" ? "Confirmation" : "确认条件"}
</div>
<h3>{locale === "en-US" ? "What Confirms the Path" : "什么会确认主路径"}</h3>
<h3>
{locale === "en-US"
? "What Confirms the Path"
: "什么会确认主路径"}
</h3>
</div>
<ul className="future-v2-rule-list">
{(confirmationRules.length > 0
@@ -1998,11 +2144,12 @@ export function FutureForecastModal() {
<div className="modal-section-kicker">
{locale === "en-US" ? "Probability read" : "概率判断"}
</div>
<h3>
{probabilityTitle}
</h3>
<h3>{probabilityTitle}</h3>
</div>
<div className="future-text-block" style={{ marginBottom: "12px" }}>
<div
className="future-text-block"
style={{ marginBottom: "12px" }}
>
{probabilitySummary}
</div>
<div style={{ position: "relative", minHeight: "120px" }}>
@@ -2020,10 +2167,15 @@ export function FutureForecastModal() {
{locale === "en-US" ? "Model layer" : "模型层"}
</div>
<h3>
{locale === "en-US" ? "Model Range & Spread" : "模型区间与分歧"}
{locale === "en-US"
? "Model Range & Spread"
: "模型区间与分歧"}
</h3>
</div>
<div className="future-text-block" style={{ marginBottom: "12px" }}>
<div
className="future-text-block"
style={{ marginBottom: "12px" }}
>
{modelSummary}
</div>
<ModelForecast
@@ -2033,7 +2185,6 @@ export function FutureForecastModal() {
/>
</section>
</div>
</main>
</div>
) : (
@@ -2072,7 +2223,10 @@ export function FutureForecastModal() {
<section className="future-modal-section">
<h3>{t("future.targetTempTrend")}</h3>
<DailyTemperatureChart dateStr={dateStr} forceToday={isToday} />
<DailyTemperatureChart
dateStr={dateStr}
forceToday={isToday}
/>
</section>
<div className="future-modal-grid">
@@ -2101,4 +2255,3 @@ export function FutureForecastModal() {
</div>
);
}
@@ -18,7 +18,7 @@ function clamp(value: number, min: number, max: number) {
}
function getToneColor(tone: string) {
if (tone === "cyan") return "#22d3ee";
if (tone === "cyan") return "#00E0A4";
if (tone === "blue") return "#60a5fa";
if (tone === "amber") return "#f59e0b";
return "#94a3b8";
@@ -77,7 +77,7 @@ export function IntradaySignalScene({
const ring = new THREE.Mesh(
new THREE.TorusGeometry(2.8, 0.03, 18, 100),
new THREE.MeshBasicMaterial({
color: new THREE.Color(score >= 0 ? "#22d3ee" : "#f59e0b"),
color: new THREE.Color(score >= 0 ? "#00E0A4" : "#FFB020"),
transparent: true,
opacity: 0.5,
}),
@@ -134,7 +134,13 @@ export function IntradaySignalScene({
glow.position.set(mesh.position.x, 0.06, 0);
stage.add(glow);
bars.push({ mesh, cap, glow, baseY: cap.position.y, targetHeight: height });
bars.push({
mesh,
cap,
glow,
baseY: cap.position.y,
targetHeight: height,
});
});
const resize = () => {
@@ -195,7 +201,11 @@ export function IntradaySignalScene({
return (
<div className="intraday-scene-shell">
<div ref={containerRef} className="intraday-scene-frame" aria-hidden="true" />
<div
ref={containerRef}
className="intraday-scene-frame"
aria-hidden="true"
/>
<div className="intraday-scene-legend">
{metrics.slice(0, 4).map((metric) => (
<div key={metric.key} className="intraday-scene-chip">
+229 -185
View File
@@ -80,7 +80,9 @@ function getMarketYesPrice(scan?: MarketScan | null) {
}
function isFahrenheitSymbol(symbol?: string | null) {
return String(symbol || "").toUpperCase().includes("F");
return String(symbol || "")
.toUpperCase()
.includes("F");
}
function displayTempToMarketCelsius(
@@ -120,17 +122,25 @@ function getMarketBucketUnit(bucket?: MarketTopBucket | null) {
}
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");
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");
const text =
`${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
.toLowerCase()
.replace(/\s+/g, "");
return (
text.includes("<=") || text.includes("orlower") || text.includes("or-lower")
);
}
function findMarketBucketForDisplayTemp(
@@ -182,11 +192,14 @@ function marketBucketContainsDisplayTemp(
displayTemp: number | null,
detail: Pick<CityDetail, "temp_symbol">,
) {
if (!bucket || displayTemp == null || !Number.isFinite(displayTemp)) return false;
if (!bucket || displayTemp == null || !Number.isFinite(displayTemp))
return false;
const bucketUnit = getMarketBucketUnit(bucket);
const compareTemp =
bucketUnit === "F" ? displayTemp : displayTempToMarketCelsius(displayTemp, detail);
bucketUnit === "F"
? displayTemp
: displayTempToMarketCelsius(displayTemp, detail);
if (compareTemp == null) return false;
const lower = bucket.lower != null ? Number(bucket.lower) : null;
@@ -265,18 +278,26 @@ function formatMarketBucketDisplayLabel(
}
const unit =
getMarketBucketUnit(bucket) === "F" || isFahrenheitSymbol(detail.temp_symbol)
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)) {
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 isMarketBucketAbove(bucket)
? `${numeric}${unit}+`
: `${numeric}${unit}`;
}
return "--";
}
@@ -321,7 +342,8 @@ function getModelGroupMeta(
) {
return {
key: "north-america",
label: locale === "en-US" ? "North America high-resolution" : "北美高分辨率",
label:
locale === "en-US" ? "North America high-resolution" : "北美高分辨率",
order: 3,
tone: "amber",
};
@@ -477,7 +499,7 @@ function getMarketTopBuckets(scan?: MarketScan | null) {
.filter(
(item): item is MarketTopBucket & { probability: number } =>
item.probability != null,
);
);
}
function getMarketAllBuckets(scan?: MarketScan | null) {
@@ -530,11 +552,11 @@ function formatProbabilityEngineLabel(
) {
const view = getProbabilityView(detail, targetDate);
if (hasLgbmModel(detail, targetDate)) {
return locale === "en-US"
? "LGBM-calibrated probability"
: "LGBM 校准概率";
return locale === "en-US" ? "LGBM-calibrated probability" : "LGBM 校准概率";
}
const engine = String(view.engine || "").trim().toLowerCase();
const engine = String(view.engine || "")
.trim()
.toLowerCase();
const calibrationMode = String(view.calibrationMode || "")
.trim()
.toLowerCase();
@@ -555,9 +577,7 @@ export function HeroSummary() {
const settlementSourceCode = normalizeObservationSourceCode(
current.settlement_source || "metar",
);
const settlementIcao = String(
current.station_code || data.risk?.icao || "",
)
const settlementIcao = String(current.station_code || data.risk?.icao || "")
.trim()
.toUpperCase();
const settlementSource =
@@ -605,9 +625,7 @@ export function HeroSummary() {
<span className="label">
{locale === "en-US" ? "Current Obs" : "当前实测"}
</span>
<span className="value">
{currentObsText}
</span>
<span className="value">{currentObsText}</span>
</div>
<div className="hero-item">
<span className="label">
@@ -699,8 +717,8 @@ export function TemperatureChart() {
}
datasets.push({
backgroundColor: "#22d3ee",
borderColor: "#22d3ee",
backgroundColor: "#00E0A4",
borderColor: "#00E0A4",
borderWidth: 0,
data: chartData.datasets.metarPoints,
fill: false,
@@ -732,7 +750,7 @@ export function TemperatureChart() {
Math.abs(chartData.datasets.offset) > 0.3
) {
datasets.push({
borderColor: "rgba(99, 102, 241, 0.2)",
borderColor: "rgba(123, 97, 255, 0.2)",
borderDash: [2, 4],
borderWidth: 1,
data: chartData.datasets.temps,
@@ -860,7 +878,9 @@ export function ProbabilityDistribution({
const topProbabilityLabel = topProbability
? formatBucketDisplayLabel(topProbability, detail)
: null;
const topProbabilityTemp = topProbability ? getBucketTemp(topProbability) : null;
const topProbabilityTemp = topProbability
? getBucketTemp(topProbability)
: null;
const probabilitiesForMarketContracts =
view.probabilitiesAll?.length > 0
? view.probabilitiesAll
@@ -955,18 +975,23 @@ export function ProbabilityDistribution({
const linkedNoAsk = linkedMarketBucket?.no_buy ?? noPriceView?.ask ?? null;
const linkedContractLabel =
topContractRow?.label ||
(linkedMarketBucket ? formatMarketBucketDisplayLabel(linkedMarketBucket, detail) : null) ||
(linkedMarketBucket
? formatMarketBucketDisplayLabel(linkedMarketBucket, detail)
: null) ||
topProbabilityLabel ||
null;
const aggregatedMarketProbability = getAggregatedModelProbabilityForMarketBucket(
probabilitiesForMarketContracts,
linkedMarketBucket,
detail,
);
const aggregatedMarketProbability =
getAggregatedModelProbabilityForMarketBucket(
probabilitiesForMarketContracts,
linkedMarketBucket,
detail,
);
const linkedMarketProbability =
topContractRow?.probability ??
aggregatedMarketProbability ??
(topProbability?.probability != null ? Number(topProbability.probability) : null);
(topProbability?.probability != null
? Number(topProbability.probability)
: null);
const linkedMarketProbabilityText = toPercent(linkedMarketProbability);
const linkedMarketEdge =
linkedMarketProbability != null && linkedMarketAsk != null
@@ -985,28 +1010,28 @@ export function ProbabilityDistribution({
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"
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 yesDisplayPrice = linkedMarketBucket
? linkedMarketAsk
: yesPriceView?.ask;
const noDisplayPrice = linkedMarketBucket ? linkedNoAsk : noPriceView?.ask;
const yesDisplayEdge = linkedMarketBucket
? linkedMarketEdge
@@ -1018,7 +1043,9 @@ export function ProbabilityDistribution({
Boolean(marketScan) ||
Boolean(topProbability));
const lockEdge = normalizeSignedProbability(priceAnalysis?.lock?.edge);
const lockAvailable = Boolean(priceAnalysis?.lock?.available && lockEdge != null);
const lockAvailable = Boolean(
priceAnalysis?.lock?.available && lockEdge != null,
);
const quoteSource =
linkedMarketBucket?.quote_source ||
marketScan?.yes_token?.quote_source ||
@@ -1047,53 +1074,54 @@ export function ProbabilityDistribution({
linkedNoEdge != null &&
linkedNoEdge > 0;
const linkedContractOverpay =
linkedContractOverpriced && linkedMarketProbability != null && linkedMarketAsk != null
linkedContractOverpriced &&
linkedMarketProbability != null &&
linkedMarketAsk != null
? Number(linkedMarketAsk) - linkedMarketProbability
: null;
const actionText =
!marketScan
const actionText = !marketScan
? locale === "en-US"
? "Waiting"
: "等待"
: !marketScan.available
? locale === "en-US"
? "Waiting"
: "等待"
: !marketScan.available
? "No market"
: "无盘口"
: actionableEdge == null
? locale === "en-US"
? "No market"
: "无盘口"
: actionableEdge == null
? locale === "en-US"
? "No quote"
: "无报价"
: actionableEdge >= 0.02
? "No quote"
: "无报价"
: actionableEdge >= 0.02
? linkedContractOverpriced
? locale === "en-US"
? "Overpriced"
: "市场偏贵"
: locale === "en-US"
? `Watch ${preferredSideLabel}`
: `可关注 ${preferredSideLabel}`
: actionableEdge > 0
? linkedContractOverpriced
? locale === "en-US"
? "Overpriced"
: "市场偏贵"
? "Slightly 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"
: "暂无优势";
? `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)}`;
? locale === "en-US"
? `${formatSignedPercent(actionableEdge)} vs ask`
: `相对买价 ${formatSignedPercent(actionableEdge)}`
: locale === "en-US"
? `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`
: `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`;
return (
<section className="prob-section">
@@ -1140,7 +1168,7 @@ export function ProbabilityDistribution({
: `市场仅作参考:最高交易温度桶 ${topMarketBucketText}`
: locale === "en-US"
? `Market reference only: this bucket ${marketYesText}`
: `市场仅作参考:该温度桶 ${marketYesText}`}
: `市场仅作参考:该温度桶 ${marketYesText}`}
</div>
)}
<div className="prob-distribution-panel">
@@ -1164,10 +1192,14 @@ export function ProbabilityDistribution({
<EmptyState text={t("section.noProb")} />
) : (
probabilityRows.map((row, index) => {
const probability = Math.round(Number(row.probability || 0) * 100);
const probability = Math.round(
Number(row.probability || 0) * 100,
);
const rowMarketBucket = row.marketBucket;
const rowMarketPrice =
rowMarketBucket?.yes_buy ?? rowMarketBucket?.market_price ?? null;
rowMarketBucket?.yes_buy ??
rowMarketBucket?.market_price ??
null;
const yesPriceText = toPriceCents(rowMarketPrice);
const marketTagFinal = rowMarketBucket
? locale === "en-US"
@@ -1176,10 +1208,7 @@ export function ProbabilityDistribution({
: null;
return (
<div
key={`${row.key || index}`}
className="prob-row"
>
<div key={`${row.key || index}`} className="prob-row">
<div className="prob-label">{row.label}</div>
<div className="prob-bar-track">
<div
@@ -1224,19 +1253,25 @@ export function ProbabilityDistribution({
? `${actionText}: ${linkedContractLabel || "contract bucket"}`
: `${actionText}${linkedContractLabel || "合约桶"}`
: preferredPriceView?.edge != null
? locale === "en-US"
? `${actionText} · edge ${formatSignedPercent(preferredPriceView.edge)}`
: `${actionText} · 优势 ${formatSignedPercent(preferredPriceView.edge)}`
: locale === "en-US"
? "Waiting for executable quote"
: "等待可执行报价"}
? locale === "en-US"
? `${actionText} · edge ${formatSignedPercent(preferredPriceView.edge)}`
: `${actionText} · 优势 ${formatSignedPercent(preferredPriceView.edge)}`
: locale === "en-US"
? "Waiting for executable quote"
: "等待可执行报价"}
</strong>
</div>
<div className="prob-price-grid">
<div>
<span>{locale === "en-US" ? "Contract bucket" : "合约桶口径"}</span>
<strong>{linkedContractLabel || topProbabilityLabel || "--"}</strong>
<em>{linkedMarketProbabilityText || topProbabilityText || "--"}</em>
<span>
{locale === "en-US" ? "Contract bucket" : "合约桶口径"}
</span>
<strong>
{linkedContractLabel || topProbabilityLabel || "--"}
</strong>
<em>
{linkedMarketProbabilityText || topProbabilityText || "--"}
</em>
</div>
<div>
<span>{locale === "en-US" ? "Candidate" : "可关注"}</span>
@@ -1333,30 +1368,32 @@ export function ModelForecast({
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 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)
@@ -1491,7 +1528,10 @@ export function ForecastTable() {
const isForecastCompleting =
store.loadingState.cityDetail &&
(data.detail_depth !== "full" || isSparseDaily);
const resolveForecastTemp = (date: string, fallback: number | null | undefined) => {
const resolveForecastTemp = (
date: string,
fallback: number | null | undefined,
) => {
const debPrediction = data.multi_model_daily?.[date]?.deb?.prediction;
return debPrediction ?? fallback ?? null;
};
@@ -1513,62 +1553,66 @@ export function ForecastTable() {
{daily.length === 0 ? (
<EmptyState text={t("forecast.empty")} />
) : (
daily.map((day, index) => {
const isToday = day.date === data.local_date || index === 0;
const isSelected =
(isToday &&
store.forecastModalMode === "today" &&
Boolean(store.futureModalDate)) ||
(store.forecastModalMode !== "today" &&
store.futureModalDate === day.date) ||
store.selectedForecastDate === day.date;
return (
<button
key={day.date}
type="button"
className={clsx(
"forecast-day",
isToday && "today",
isSelected && "selected",
)}
onClick={() => {
startTransition(() => {
if (isToday) {
store.openTodayModal();
return;
}
store.openFutureModal(day.date);
});
}}
>
<div className="f-date">
{isToday
? t("forecast.today")
: day.date.substring(5).replace("-", "/")}
</div>
<div className="f-temp">
{resolveForecastTemp(day.date, day.max_temp)}
{data.temp_symbol}
</div>
</button>
);
}).concat(
isForecastCompleting
? Array.from({ length: Math.max(0, 5 - daily.length) }).map((_, index) => (
<button
key={`forecast-sync-${index}`}
type="button"
className="forecast-day forecast-day-sync"
disabled
>
<div className="f-date">
{locale === "en-US" ? "Syncing" : "同步中"}
</div>
<div className="f-temp">--</div>
</button>
))
: [],
)
daily
.map((day, index) => {
const isToday = day.date === data.local_date || index === 0;
const isSelected =
(isToday &&
store.forecastModalMode === "today" &&
Boolean(store.futureModalDate)) ||
(store.forecastModalMode !== "today" &&
store.futureModalDate === day.date) ||
store.selectedForecastDate === day.date;
return (
<button
key={day.date}
type="button"
className={clsx(
"forecast-day",
isToday && "today",
isSelected && "selected",
)}
onClick={() => {
startTransition(() => {
if (isToday) {
store.openTodayModal();
return;
}
store.openFutureModal(day.date);
});
}}
>
<div className="f-date">
{isToday
? t("forecast.today")
: day.date.substring(5).replace("-", "/")}
</div>
<div className="f-temp">
{resolveForecastTemp(day.date, day.max_temp)}
{data.temp_symbol}
</div>
</button>
);
})
.concat(
isForecastCompleting
? Array.from({ length: Math.max(0, 5 - daily.length) }).map(
(_, index) => (
<button
key={`forecast-sync-${index}`}
type="button"
className="forecast-day forecast-day-sync"
disabled
>
<div className="f-date">
{locale === "en-US" ? "Syncing" : "同步中"}
</div>
<div className="f-temp">--</div>
</button>
),
)
: [],
)
)}
</div>
</section>
@@ -142,15 +142,11 @@ function buildSnapshot(
detail?.risk?.level;
const hitRate = Number(city.deb_recent_hit_rate ?? 0);
const sampleCount = Number(city.deb_recent_sample_count ?? 0);
const edgePercent = Number(getMarketEdgeValue(detail));
const normalizedEdge =
Number.isFinite(edgePercent) && Math.abs(edgePercent) <= 1
? edgePercent * 100
: edgePercent;
const normalizedEdge = normalizeEdgePercent(getMarketEdgeValue(detail));
const tradableOpportunity = isTradableMarketOpportunity(detail);
const score =
(detail?.market_scan && !tradableOpportunity ? -10_000 : 0) +
(tradableOpportunity && Number.isFinite(normalizedEdge)
(tradableOpportunity && normalizedEdge != null
? 1000 + normalizedEdge * 10
: 0) +
(RISK_SCORE[String(tier || "")] || 0) * 100 +
@@ -305,6 +301,75 @@ function getBestSide(detail?: CityDetail | null) {
return null;
}
function getSideEdgePercent(detail: CityDetail | null | undefined, side: "yes" | "no") {
const sideAnalysis = detail?.market_scan?.price_analysis?.[side];
return normalizeEdgePercent(sideAnalysis?.edge_percent ?? sideAnalysis?.edge);
}
function getExecutableEdgePercent(detail?: CityDetail | null) {
const bestSide = getBestSide(detail);
if (bestSide) {
const bestEdge = getSideEdgePercent(detail, bestSide);
if (bestEdge != null) return bestEdge;
}
const sideEdges = [
getSideEdgePercent(detail, "yes"),
getSideEdgePercent(detail, "no"),
].filter((value): value is number => value != null && value > 0);
if (sideEdges.length) return Math.max(...sideEdges);
return normalizeEdgePercent(detail?.market_scan?.edge_percent);
}
function getBestSideModelProbability(detail?: CityDetail | null) {
const marketScan = detail?.market_scan;
const pYes = normalizeProbabilityValue(
marketScan?.price_analysis?.model_probability ??
marketScan?.model_probability ??
marketScan?.temperature_bucket?.probability,
);
if (pYes == null) return null;
return getBestSide(detail) === "no" ? 1 - pYes : pYes;
}
function getBestSideAsk(detail?: CityDetail | null) {
const marketScan = detail?.market_scan;
const side = getBestSide(detail);
if (side === "yes") {
return (
marketScan?.yes_buy ??
marketScan?.yes_token?.buy_price ??
marketScan?.price_analysis?.yes?.ask
);
}
if (side === "no") {
return (
marketScan?.no_buy ??
marketScan?.no_token?.buy_price ??
marketScan?.price_analysis?.no?.ask
);
}
return marketScan?.market_price ?? marketScan?.midpoint ?? null;
}
function getTradeSideLabel(side: "yes" | "no" | null, locale: string) {
if (side === "yes") return locale === "en-US" ? "BUY YES" : "买 YES";
if (side === "no") return locale === "en-US" ? "BUY NO" : "买 NO";
return locale === "en-US" ? "WATCH" : "观察";
}
function getTradeFormulaLabel(side: "yes" | "no" | null, locale: string) {
if (side === "no") {
return locale === "en-US"
? "Edge = EMOS P(NO) - NO ask"
: "Edge = EMOS P(NO) - NO 买价";
}
return locale === "en-US"
? "Edge = EMOS P(YES) - YES ask"
: "Edge = EMOS P(YES) - YES 买价";
}
function isLiveMarketScan(detail?: CityDetail | null) {
const marketScan = detail?.market_scan;
if (!marketScan) return false;
@@ -640,13 +705,6 @@ type HomeTrendChart = {
label: string;
temperatureText: string;
}>;
hoverPoints: Array<{
cx: number;
cy: number;
key: string;
label: string;
temperatureText: string;
}>;
yAxisLabels: Array<{ key: string; label: string; y: number }>;
xAxisLabels: Array<{ key: string; label: string; x: number }>;
};
@@ -904,23 +962,6 @@ function buildHomeTrendChart(
});
const hoverSource =
observationSeries.length > 0 ? observationSeries : forecastSeries;
const hoverPoints = hoverSource.map((point, index) => {
const projected = projectHomeTrendPoint(
point.x,
point.y,
chartData.xMin,
chartData.xMax,
chartData.min,
chartData.max,
);
return {
cx: projected.cx,
cy: projected.cy,
key: `hover-${point.labelTime}-${index}`,
label: point.labelTime,
temperatureText: formatTemperature(point.y, detail.temp_symbol || "°C"),
};
});
const hourlyReports = hoverSource
.filter((point) => {
const label = String(point.labelTime || "");
@@ -985,7 +1026,6 @@ function buildHomeTrendChart(
legendText: chartData.legendText,
observationDots,
hourlyReports,
hoverPoints,
yAxisLabels,
xAxisLabels,
};
@@ -1061,14 +1101,7 @@ function isBoundaryMarketPrice(value: number | null | undefined) {
}
function getMarketEdgeValue(detail?: CityDetail | null) {
const marketScan = detail?.market_scan;
return (
marketScan?.edge_percent ??
marketScan?.price_analysis?.yes?.edge_percent ??
marketScan?.price_analysis?.yes?.edge ??
marketScan?.price_analysis?.no?.edge_percent ??
marketScan?.price_analysis?.no?.edge
);
return getExecutableEdgePercent(detail);
}
function isTradableMarketOpportunity(detail?: CityDetail | null) {
@@ -1110,19 +1143,13 @@ function isTradableMarketOpportunity(detail?: CityDetail | null) {
return false;
}
if (isBoundaryMarketPrice(marketPrice)) return false;
return Number.isFinite(Number(getMarketEdgeValue(detail)));
const executableEdge = getExecutableEdgePercent(detail);
return executableEdge != null && executableEdge > 0;
}
function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
const store = useDashboardStore();
const { locale } = useI18n();
const [hoveredTrendPoint, setHoveredTrendPoint] = useState<{
cx: number;
cy: number;
key: string;
label: string;
temperatureText: string;
} | null>(null);
const spotlight = useMemo(
() =>
store.selectedCity
@@ -1449,39 +1476,6 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
</span>
))}
</div>
{trendChart.hoverPoints.map((point) => (
<button
key={point.key}
type="button"
className="home-intraday-hotspot"
style={{ left: `${point.cx}px`, top: `${point.cy}px` }}
aria-label={`${point.label} ${point.temperatureText}`}
onMouseEnter={() => setHoveredTrendPoint(point)}
onMouseLeave={() =>
setHoveredTrendPoint((current) =>
current?.key === point.key ? null : current,
)
}
onFocus={() => setHoveredTrendPoint(point)}
onBlur={() =>
setHoveredTrendPoint((current) =>
current?.key === point.key ? null : current,
)
}
/>
))}
{hoveredTrendPoint ? (
<div
className="home-intraday-tooltip"
style={{
left: `${Math.min(248, Math.max(48, hoveredTrendPoint.cx))}px`,
top: `${Math.max(8, hoveredTrendPoint.cy - 34)}px`,
}}
>
<strong>{hoveredTrendPoint.temperatureText}</strong>
<span>{hoveredTrendPoint.label}</span>
</div>
) : null}
</div>
{trendChart.hourlyReports.length ? (
<div className="home-intraday-reports">
@@ -1677,16 +1671,15 @@ function OpportunityStrip({
bestTradableSnapshot.detail,
)
: "--";
const highCount = snapshots.filter(
(snapshot) => snapshot.city.deb_recent_tier === "high",
).length;
const mediumCount = snapshots.filter(
(snapshot) => snapshot.city.deb_recent_tier === "medium",
).length;
const lowCount = snapshots.filter(
(snapshot) => snapshot.city.deb_recent_tier === "low",
).length;
const noEdgeCount = Math.max(completedCount - tradableSnapshots.length, 0);
const quoteReadyCount = liveSnapshots.filter((snapshot) => {
const marketScan = snapshot.detail?.market_scan;
return Boolean(
marketScan?.price_analysis?.available ||
marketScan?.yes_buy != null ||
marketScan?.no_buy != null,
);
}).length;
const noEdgeCount = Math.max(quoteReadyCount - tradableSnapshots.length, 0);
return {
items: tradableSnapshots.slice(0, 5),
@@ -1705,29 +1698,49 @@ function OpportunityStrip({
bestTradableCityName,
summaryCards: [
{
key: "scan-progress",
title: locale === "en-US" ? "Scan Progress" : "扫描进度",
key: "data-chain",
title: locale === "en-US" ? "Data Chain" : "数据链路",
items: [
{
label: locale === "en-US" ? "Completed" : "已完成",
value: `${completedCount}/${totalCount}`,
label: locale === "en-US" ? "Markets" : "市场列表",
value: "Gamma",
accent: "cyan" as const,
},
{
label: locale === "en-US" ? "Scanning" : "扫描中",
value: String(pendingCount),
accent: "amber" as const,
label: locale === "en-US" ? "Quotes" : "盘口价格",
value: "CLOB",
accent: "green" as const,
},
{
label: locale === "en-US" ? "Errors" : "异常",
value: String(errorCount),
accent: errorCount > 0 ? ("red" as const) : ("green" as const),
label: locale === "en-US" ? "Model" : "概率模型",
value: "EMOS",
accent: "amber" as const,
},
],
},
{
key: "market-live",
title: locale === "en-US" ? "Market Status" : "盘口状态",
key: "refresh-cadence",
title: locale === "en-US" ? "Refresh Cadence" : "刷新节奏",
items: [
{
label: locale === "en-US" ? "Market list" : "市场列表",
value: "60s",
accent: "green" as const,
},
{
label: locale === "en-US" ? "YES/NO ask" : "YES/NO 买价",
value: "30s",
accent: "cyan" as const,
},
{
label: locale === "en-US" ? "Mode" : "模式",
value: "REST",
},
],
},
{
key: "quote-filter",
title: locale === "en-US" ? "Quote Filter" : "报价筛选",
items: [
{
label: locale === "en-US" ? "Live" : "在线",
@@ -1735,117 +1748,95 @@ function OpportunityStrip({
accent: "green" as const,
},
{
label: locale === "en-US" ? "Tradable" : "可交易",
value: String(tradableSnapshots.length),
label: locale === "en-US" ? "Quoted" : "已报价",
value: String(quoteReadyCount),
accent: "cyan" as const,
},
{
label: locale === "en-US" ? "No edge" : "无机会",
label: locale === "en-US" ? "No edge" : "无正 edge",
value: String(noEdgeCount),
accent: "amber" as const,
},
],
},
{
key: "market-quality",
title: locale === "en-US" ? "Opportunity Quality" : "机会质量",
key: "edge-decision",
title: locale === "en-US" ? "Decision" : "交易决策",
items: [
{
label: locale === "en-US" ? "Avg. edge" : "平均优势",
value: formatEdge(avgTradableEdge),
label: locale === "en-US" ? "Actionable" : "可买",
value: String(tradableSnapshots.length),
accent: "green" as const,
},
{
label: locale === "en-US" ? "Best edge" : "最高优势",
value: bestTradableSnapshot
? formatEdge(getMarketEdgeValue(bestTradableSnapshot.detail))
: "--",
accent: "cyan" as const,
label: locale === "en-US" ? "Avg edge" : "平均 edge",
value: formatEdge(avgTradableEdge),
accent: avgTradableEdge != null ? ("cyan" as const) : undefined,
},
{
label: locale === "en-US" ? "Focus" : "最佳城市",
label: locale === "en-US" ? "Focus" : "当前标的",
value: bestTradableCityName,
accent: "amber" as const,
},
],
},
{
key: "risk-summary",
title: locale === "en-US" ? "Risk Layer" : "风险层",
items: [
{
label: locale === "en-US" ? "High" : "高",
value: String(highCount),
accent: "red" as const,
},
{
label: locale === "en-US" ? "Medium" : "中",
value: String(mediumCount),
accent: "amber" as const,
},
{
label: locale === "en-US" ? "Low" : "低",
value: String(lowCount),
accent: "green" as const,
},
],
},
],
overviewKicker:
locale === "en-US" ? "EMOS Market Scanner" : "EMOS 交易扫描台",
locale === "en-US" ? "EMOS / CLOB Decision Layer" : "EMOS / CLOB 交易决策层",
headingTitle:
tradableSnapshots.length > 0
? locale === "en-US"
? `${tradableSnapshots.length} tradable markets · focus ${bestTradableCityName}`
: `发现 ${tradableSnapshots.length} 个可交易市场 · 当前最佳 ${bestTradableCityName}`
? `${getTradeSideLabel(getBestSide(bestTradableSnapshot?.detail), locale)} · ${bestTradableCityName} · ${formatEdge(bestTradableEdge)}`
: `${getTradeSideLabel(getBestSide(bestTradableSnapshot?.detail), locale)} · ${bestTradableCityName} · ${formatEdge(bestTradableEdge)}`
: pendingCount > 0
? locale === "en-US"
? `Scanning ${completedCount}/${totalCount} cities`
: `正在扫描 ${completedCount}/${totalCount} 个城市市场层`
? "Waiting for executable CLOB quotes"
: "等待可执行 CLOB 报价"
: locale === "en-US"
? `Completed ${completedCount} scans · no tradable market`
: `已完成 ${completedCount} 个城市扫描 · 当前无可交易市场`,
? "No positive EMOS edge right now"
: "当前没有正 EMOS edge",
overviewBody:
tradableSnapshots.length > 0
? locale === "en-US"
? "Use EMOS distribution against the live CLOB quote and surface the highest executable edge first."
: "用 EMOS 概率分布对齐实时 CLOB 盘口,优先展示当前可执行 edge 最高的市场。"
? "Decision rule: compare the EMOS probability for the exact market bucket with the executable YES/NO ask from CLOB REST."
: "决策规则:把具体温度桶的 EMOS 概率和 CLOB REST 可买 YES/NO 价格相减,只展示正 edge 的买入方向。"
: pendingCount > 0
? locale === "en-US"
? "The opportunity layer is filling active markets and will promote the first executable setup when quotes arrive."
: "机会层正在补齐活跃盘口,等最新报价返回后会直接抬出第一优先机会。"
? "Gamma active markets are cached separately; the faster loop waits for CLOB YES/NO asks and recalculates edge locally."
: "Gamma 活跃市场单独缓存;更快的价格层等待 CLOB YES/NO 买价后在本地重算 edge。"
: locale === "en-US"
? "Scanner is live, but the current market prices do not beat the EMOS distribution yet."
: "扫描器在线,但当前盘口价格还没有跑赢 EMOS 概率分布。",
? "Markets can still be live, but the current asks are not cheaper than the EMOS distribution."
: "盘口可以在线,但当前 ask 没有低于 EMOS 概率分布,先不提示买入。",
tapeStats: [
{
key: "coverage",
label: locale === "en-US" ? "Coverage" : "覆盖城市",
value: `${completedCount}/${totalCount}`,
label: locale === "en-US" ? "Gamma" : "Gamma 市场",
value: "60s",
accent: "cyan" as const,
},
{
key: "live",
label: locale === "en-US" ? "Live" : "在线盘口",
value: String(liveSnapshots.length),
label: locale === "en-US" ? "CLOB asks" : "CLOB 报价",
value: "30s",
accent: "green" as const,
},
{
key: "tradable",
label: locale === "en-US" ? "Tradable" : "可交易",
label: locale === "en-US" ? "Actionable" : "可买机会",
value: String(tradableSnapshots.length),
accent: "cyan" as const,
},
{
key: "avg-edge",
label: locale === "en-US" ? "Avg edge" : "平均优势",
value: formatEdge(avgTradableEdge),
accent: avgTradableEdge != null ? ("green" as const) : ("slate" as const),
label: locale === "en-US" ? "Best edge" : "最佳 edge",
value: formatEdge(bestTradableEdge),
accent: bestTradableEdge != null ? ("green" as const) : ("slate" as const),
},
],
yesCountLabel:
locale === "en-US" ? `YES bias ${yesCount}` : `YES 倾向 ${yesCount}`,
locale === "en-US" ? `BUY YES ${yesCount}` : `YES ${yesCount}`,
noCountLabel:
locale === "en-US" ? `NO bias ${noCount}` : `NO 倾向 ${noCount}`,
locale === "en-US" ? `BUY NO ${noCount}` : `NO ${noCount}`,
};
}, [locale, marketScanStatusByCity, scanTargetNames, snapshots]);
@@ -1853,24 +1844,15 @@ function OpportunityStrip({
const heroSnapshot = stripState.bestTradableSnapshot;
const heroSide = getBestSide(heroSnapshot?.detail);
const heroSignalLabel =
heroSnapshot?.detail?.market_scan?.signal_label ||
(heroSide === "yes"
? locale === "en-US"
? "BUY YES"
: "买入 YES"
: heroSide === "no"
? locale === "en-US"
? "BUY NO"
: "买入 NO"
: locale === "en-US"
? "MONITOR"
: "继续监控");
const heroSignalLabel = getTradeSideLabel(heroSide, locale);
const heroSignalTone = heroSignalLabel.toLowerCase().includes("yes")
? "yes"
: heroSignalLabel.toLowerCase().includes("no")
? "no"
: "neutral";
const heroBestAsk = getBestSideAsk(heroSnapshot?.detail);
const heroBestProbability = getBestSideModelProbability(heroSnapshot?.detail);
const heroBestEdge = getExecutableEdgePercent(heroSnapshot?.detail);
const heroSymbol = heroSnapshot
? getTempSymbol(heroSnapshot.city, heroSnapshot.summary, heroSnapshot.detail)
: "°C";
@@ -1902,17 +1884,7 @@ function OpportunityStrip({
if (engine.includes("emos")) return "EMOS";
return engine.toUpperCase();
})();
const heroModelProbability =
heroSnapshot?.detail?.market_scan?.model_probability ??
heroMarketBucket?.probability ??
null;
const heroMidpoint =
heroSnapshot?.detail?.market_scan?.midpoint ??
heroSnapshot?.detail?.market_scan?.market_price ??
null;
const heroSpread = heroSnapshot?.detail?.market_scan?.spread ?? null;
const heroConfidence =
heroSnapshot?.detail?.market_scan?.confidence || null;
const heroQuoteSource = heroSnapshot?.detail?.market_scan?.quote_source
? String(heroSnapshot.detail.market_scan.quote_source)
.replaceAll("_", " ")
@@ -1992,7 +1964,7 @@ function OpportunityStrip({
<div className="opportunity-hero-header">
<div className="opportunity-hero-copy">
<span className="opportunity-hero-kicker">
{locale === "en-US" ? "Primary market" : "当前主机会"}
{locale === "en-US" ? "Current action" : "当前交易建议"}
</span>
<div className="opportunity-hero-title-row">
<strong>{heroCityName}</strong>
@@ -2025,47 +1997,47 @@ function OpportunityStrip({
<div className="opportunity-hero-body">
<div className="opportunity-hero-edgeblock">
<span>
{locale === "en-US" ? "Executable edge" : "可执行优势"}
{locale === "en-US" ? "Trade now" : "现在值得买什么"}
</span>
<strong>{formatEdge(stripState.bestTradableEdge)}</strong>
<strong>
{heroSide
? `${getTradeSideLabel(heroSide, locale)} ${formatCents(heroBestAsk)}`
: locale === "en-US"
? "WATCH"
: "观察"}
</strong>
<em>
{heroSide === "yes"
? locale === "en-US"
? "Prefer lifting the YES ask"
: "优先吃 YES 买价"
: heroSide === "no"
? locale === "en-US"
? "Prefer lifting the NO ask"
: "优先吃 NO 买价"
: locale === "en-US"
? "Keep monitoring executable quotes"
: "继续观察可执行报价"}
{getTradeFormulaLabel(heroSide, locale)} ·{" "}
{locale === "en-US" ? "edge" : "优势"}{" "}
{formatEdge(heroBestEdge)}
</em>
</div>
<div className="opportunity-hero-metrics">
<div className="opportunity-hero-metric">
<span>{locale === "en-US" ? "EMOS prob." : "EMOS 概率"}</span>
<strong>{formatProbability(heroModelProbability)}</strong>
<span>
{heroSide === "no"
? locale === "en-US"
? "EMOS P(NO)"
: "EMOS P(NO)"
: locale === "en-US"
? "EMOS P(YES)"
: "EMOS P(YES)"}
</span>
<strong>{formatProbability(heroBestProbability)}</strong>
</div>
<div className="opportunity-hero-metric">
<span>{locale === "en-US" ? "Midpoint" : "盘口中位"}</span>
<strong>{formatCents(heroMidpoint)}</strong>
<span>{locale === "en-US" ? "CLOB ask" : "CLOB 买价"}</span>
<strong>{formatCents(heroBestAsk)}</strong>
</div>
<div className="opportunity-hero-metric">
<span>{locale === "en-US" ? "Edge" : "Edge"}</span>
<strong>{formatEdge(heroBestEdge)}</strong>
</div>
<div className="opportunity-hero-metric">
<span>{locale === "en-US" ? "Spread" : "盘口点差"}</span>
<strong>{formatCents(heroSpread)}</strong>
</div>
<div className="opportunity-hero-metric">
<span>{locale === "en-US" ? "Confidence" : "信号强度"}</span>
<strong>
{heroConfidence
? String(heroConfidence).toUpperCase()
: locale === "en-US"
? "LIVE"
: "在线"}
</strong>
</div>
</div>
<div className="opportunity-hero-book">
+1 -1
View File
@@ -13,7 +13,7 @@ interface SparklineProps {
export function Sparkline({
data,
color = "#22d3ee",
color = "#00E0A4",
height = 40,
width = 120,
strokeWidth = 2,
+51 -38
View File
@@ -23,33 +23,32 @@ export type WeatherAuraEffect =
const RISK_AURA: Record<
string,
Omit<WeatherAuraProfile, "intensity" | "drift" | "effect" | "effectIntensity">
> =
{
high: {
primary: "#ff7c2a",
secondary: "#ffcf66",
tertiary: "#56c7ff",
particleOpacity: 0.42,
},
medium: {
primary: "#f6c453",
secondary: "#5eead4",
tertiary: "#7dd3fc",
particleOpacity: 0.34,
},
low: {
primary: "#38bdf8",
secondary: "#22d3ee",
tertiary: "#34d399",
particleOpacity: 0.28,
},
default: {
primary: "#6ee7ff",
secondary: "#7c8dff",
tertiary: "#60a5fa",
particleOpacity: 0.3,
},
};
> = {
high: {
primary: "#ff7c2a",
secondary: "#ffcf66",
tertiary: "#56c7ff",
particleOpacity: 0.42,
},
medium: {
primary: "#f6c453",
secondary: "#5eead4",
tertiary: "#7dd3fc",
particleOpacity: 0.34,
},
low: {
primary: "#38bdf8",
secondary: "#00E0A4",
tertiary: "#34d399",
particleOpacity: 0.28,
},
default: {
primary: "#6ee7ff",
secondary: "#7c8dff",
tertiary: "#60a5fa",
particleOpacity: 0.3,
},
};
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
@@ -61,7 +60,9 @@ export function getWeatherAuraProfile(
): WeatherAuraProfile {
const dominantRisk =
String(detail?.risk?.level || "").toLowerCase() ||
String(cities.find((city) => city.risk_level)?.risk_level || "").toLowerCase() ||
String(
cities.find((city) => city.risk_level)?.risk_level || "",
).toLowerCase() ||
"default";
const base = RISK_AURA[dominantRisk] || RISK_AURA.default;
@@ -69,7 +70,11 @@ export function getWeatherAuraProfile(
const validTemp = Number.isFinite(currentTemp) ? currentTemp : 18;
const intensity = clamp(0.7 + validTemp / 40, 0.72, 1.45);
const drift = clamp(0.45 + validTemp / 30, 0.55, 1.35);
const particleOpacity = clamp(base.particleOpacity + (intensity - 1) * 0.08, 0.22, 0.48);
const particleOpacity = clamp(
base.particleOpacity + (intensity - 1) * 0.08,
0.22,
0.48,
);
const wxText = `${String(detail?.current?.wx_desc || "")} ${String(
detail?.current?.cloud_desc || "",
)}`.toUpperCase();
@@ -77,9 +82,7 @@ export function getWeatherAuraProfile(
const humidity = Number(detail?.current?.humidity);
let effect: WeatherAuraEffect = "clear";
if (
/(TS|VCTS|THUNDER|雷暴|LIGHTNING)/.test(wxText)
) {
if (/(TS|VCTS|THUNDER|雷暴|LIGHTNING)/.test(wxText)) {
effect = "storm";
} else if (/(SN|SG|GS|ICE|SLEET|雪|霰)/.test(wxText)) {
effect = "snow";
@@ -87,9 +90,7 @@ export function getWeatherAuraProfile(
effect = "rain";
} else if (/(FG|BR|HZ|FU|MIST|FOG|雾|霾)/.test(wxText)) {
effect = "fog";
} else if (
/(BKN|OVC|SCT|FEW|CLOUD|云|阴)/.test(wxText)
) {
} else if (/(BKN|OVC|SCT|FEW|CLOUD|云|阴)/.test(wxText)) {
effect = "cloud";
} else if (Number.isFinite(windSpeed) && windSpeed >= 18) {
effect = "wind";
@@ -113,11 +114,23 @@ export function getWeatherAuraProfile(
1.7,
)
: effect === "snow"
? clamp(0.8 + (Number.isFinite(windSpeed) ? windSpeed / 50 : 0), 0.78, 1.4)
? clamp(
0.8 + (Number.isFinite(windSpeed) ? windSpeed / 50 : 0),
0.78,
1.4,
)
: effect === "fog"
? clamp(0.78 + (Number.isFinite(humidity) ? humidity / 240 : 0), 0.75, 1.3)
? clamp(
0.78 + (Number.isFinite(humidity) ? humidity / 240 : 0),
0.75,
1.3,
)
: effect === "wind"
? clamp(0.72 + (Number.isFinite(windSpeed) ? windSpeed / 40 : 0), 0.72, 1.5)
? clamp(
0.72 + (Number.isFinite(windSpeed) ? windSpeed / 40 : 0),
0.72,
1.5,
)
: effect === "cloud"
? 0.82
: 0.72;
+1 -1
View File
@@ -70,7 +70,7 @@ const config: Config = {
"pw-xl": "var(--radius-xl)",
},
boxShadow: {
glow: "0 0 0 1px rgba(0, 229, 255, 0.15), 0 8px 32px rgba(8, 47, 73, 0.5)",
glow: "0 0 0 1px rgba(0, 224, 164, 0.15), 0 8px 32px rgba(8, 47, 73, 0.5)",
"glow-accent": "var(--shadow-glow-accent)",
"glow-secondary": "var(--shadow-glow-secondary)",
"elevation-1": "var(--shadow-elevation-1)",