feat: implement PolyWeather dashboard layout with premium dark theme and map integration

This commit is contained in:
2569718930@qq.com
2026-04-23 03:29:20 +08:00
parent 558a0031be
commit d0a98403e7
3 changed files with 865 additions and 168 deletions
@@ -65,7 +65,11 @@
width: 100vw;
background-image:
radial-gradient(circle at 18% 18%, rgba(30, 64, 175, 0.1), transparent 24%),
radial-gradient(circle at 82% 12%, rgba(8, 145, 178, 0.08), transparent 20%),
radial-gradient(
circle at 82% 12%,
rgba(8, 145, 178, 0.08),
transparent 20%
),
linear-gradient(180deg, #040914 0%, #02050d 100%);
}
@@ -156,7 +160,11 @@
}
.root :global(.home-map-modes button.active) {
background: linear-gradient(180deg, rgba(28, 100, 242, 0.76), rgba(31, 72, 184, 0.76));
background: linear-gradient(
180deg,
rgba(28, 100, 242, 0.76),
rgba(31, 72, 184, 0.76)
);
color: #f8fafc;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.24);
}
@@ -205,7 +213,14 @@
width: 178px;
height: 8px;
border-radius: 999px;
background: linear-gradient(90deg, #2563eb, #06b6d4, #22c55e, #eab308, #ef4444);
background: linear-gradient(
90deg,
#2563eb,
#06b6d4,
#22c55e,
#eab308,
#ef4444
);
}
.root :global(.home-map-legend-values) {
@@ -339,7 +354,11 @@
border-radius: 9px;
border: 1px solid rgba(34, 211, 238, 0.28);
background:
radial-gradient(circle at 30% 30%, rgba(34, 211, 238, 0.24), transparent 52%),
radial-gradient(
circle at 30% 30%,
rgba(34, 211, 238, 0.24),
transparent 52%
),
linear-gradient(180deg, rgba(11, 22, 40, 0.96), rgba(6, 13, 24, 0.96));
display: inline-flex;
align-items: center;
@@ -373,7 +392,11 @@
font-size: 18px;
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(135deg, var(--accent-cyan) 0%, var(--accent-blue) 100%);
background: linear-gradient(
135deg,
var(--accent-cyan) 0%,
var(--accent-blue) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -790,7 +813,9 @@
linear-gradient(135deg, rgba(34, 211, 238, 0.12), rgba(30, 64, 175, 0.1)),
rgba(9, 18, 32, 0.92);
border-color: rgba(34, 211, 238, 0.24);
box-shadow: 0 0 20px rgba(34, 211, 238, 0.08), inset 0 0 0 1px rgba(34, 211, 238, 0.08);
box-shadow:
0 0 20px rgba(34, 211, 238, 0.08),
inset 0 0 0 1px rgba(34, 211, 238, 0.08);
}
.root :global(.city-item-main) {
@@ -899,8 +924,16 @@
border: 1px solid rgba(34, 211, 238, 0.18);
border-radius: 24px;
background:
radial-gradient(circle at 18% 0%, rgba(34, 211, 238, 0.16), transparent 32%),
radial-gradient(circle at 100% 20%, rgba(59, 130, 246, 0.18), transparent 34%),
radial-gradient(
circle at 18% 0%,
rgba(34, 211, 238, 0.16),
transparent 32%
),
radial-gradient(
circle at 100% 20%,
rgba(59, 130, 246, 0.18),
transparent 34%
),
linear-gradient(155deg, rgba(5, 12, 25, 0.86), rgba(12, 19, 35, 0.78));
backdrop-filter: blur(24px) saturate(130%);
box-shadow:
@@ -915,7 +948,11 @@
width: 260px;
height: 260px;
border-radius: 999px;
background: radial-gradient(circle, rgba(34, 211, 238, 0.18), transparent 62%);
background: radial-gradient(
circle,
rgba(34, 211, 238, 0.18),
transparent 62%
);
pointer-events: none;
}
@@ -1150,7 +1187,10 @@
font-weight: 800;
padding: 8px 12px;
text-decoration: none;
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
transition:
transform 160ms ease,
border-color 160ms ease,
background 160ms ease;
}
.root :global(.home-pro-card a:hover),
@@ -1167,7 +1207,11 @@
overflow-y: auto;
border-color: rgba(34, 211, 238, 0.34);
background:
radial-gradient(circle at 100% 0%, rgba(37, 99, 235, 0.22), transparent 34%),
radial-gradient(
circle at 100% 0%,
rgba(37, 99, 235, 0.22),
transparent 34%
),
linear-gradient(162deg, rgba(3, 12, 24, 0.94), rgba(6, 26, 48, 0.92));
}
@@ -1403,7 +1447,11 @@
width: 44px;
height: 2px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(125, 211, 252, 0), rgba(125, 211, 252, 0.92));
background: linear-gradient(
90deg,
rgba(125, 211, 252, 0),
rgba(125, 211, 252, 0.92)
);
}
.root :global(.home-weather-icon .wind::after) {
@@ -1526,7 +1574,11 @@
.root :global(.home-card-section) {
border-radius: 18px;
border: 1px solid rgba(115, 137, 161, 0.16);
background: linear-gradient(180deg, rgba(7, 14, 26, 0.92), rgba(9, 17, 30, 0.8));
background: linear-gradient(
180deg,
rgba(7, 14, 26, 0.92),
rgba(9, 17, 30, 0.8)
);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
@@ -1940,11 +1992,16 @@
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 22px;
background:
radial-gradient(circle at 8% 50%, rgba(34, 211, 238, 0.12), transparent 32%),
radial-gradient(
circle at 8% 50%,
rgba(34, 211, 238, 0.12),
transparent 32%
),
rgba(7, 13, 25, 0.76);
backdrop-filter: blur(20px) saturate(130%);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.34);
animation: opportunity-strip-enter 580ms cubic-bezier(0.16, 1, 0.3, 1) 120ms both;
animation: opportunity-strip-enter 580ms cubic-bezier(0.16, 1, 0.3, 1) 120ms
both;
}
.root :global(.home-summary-grid) {
@@ -2011,10 +2068,18 @@
font-weight: 650;
}
.root :global(.home-summary-stat b.accent-red) { color: #fb7185; }
.root :global(.home-summary-stat b.accent-amber) { color: #fbbf24; }
.root :global(.home-summary-stat b.accent-green) { color: #4ade80; }
.root :global(.home-summary-stat b.accent-cyan) { color: #22d3ee; }
.root :global(.home-summary-stat b.accent-red) {
color: #fb7185;
}
.root :global(.home-summary-stat b.accent-amber) {
color: #fbbf24;
}
.root :global(.home-summary-stat b.accent-green) {
color: #4ade80;
}
.root :global(.home-summary-stat b.accent-cyan) {
color: #22d3ee;
}
.root :global(.opportunity-strip-heading) {
display: flex;
@@ -2064,20 +2129,31 @@
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 16px;
background:
radial-gradient(circle at 100% 0%, rgba(30, 64, 175, 0.12), transparent 32%),
radial-gradient(
circle at 100% 0%,
rgba(30, 64, 175, 0.12),
transparent 32%
),
rgba(10, 16, 28, 0.84);
color: inherit;
cursor: pointer;
font: inherit;
text-align: left;
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
transition:
transform 160ms ease,
border-color 160ms ease,
background 160ms ease;
}
.root :global(.opportunity-card:hover) {
transform: translateY(-2px);
border-color: rgba(34, 211, 238, 0.32);
background:
radial-gradient(circle at 100% 0%, rgba(30, 64, 175, 0.18), transparent 32%),
radial-gradient(
circle at 100% 0%,
rgba(30, 64, 175, 0.18),
transparent 32%
),
rgba(15, 23, 42, 0.92);
}
@@ -2169,6 +2245,22 @@
font-weight: 800;
}
/* Market-closed opportunity card — dimmed state */
.root :global(.opportunity-card.market-closed) {
opacity: 0.6;
border-style: dashed;
}
.root :global(.opportunity-card.market-closed:hover) {
opacity: 0.82;
}
.root :global(.opportunity-market-status) {
color: rgba(251, 191, 36, 0.8);
font-size: 10px;
font-weight: 700;
}
@keyframes home-panel-enter {
from {
opacity: 0;
@@ -2981,7 +3073,11 @@
.root :global(.model-bar-fill) {
height: 100%;
border-radius: 6px;
background: linear-gradient(90deg, rgba(14, 165, 233, 0.72), rgba(34, 211, 238, 0.92));
background: linear-gradient(
90deg,
rgba(14, 165, 233, 0.72),
rgba(34, 211, 238, 0.92)
);
transition: width 0.6s ease-out;
}
@@ -3096,8 +3192,12 @@
.root :global(.forecast-day-sync) {
cursor: wait;
opacity: 0.76;
background:
linear-gradient(90deg, rgba(15, 23, 42, 0.86), rgba(30, 41, 59, 0.72), rgba(15, 23, 42, 0.86));
background: linear-gradient(
90deg,
rgba(15, 23, 42, 0.86),
rgba(30, 41, 59, 0.72),
rgba(15, 23, 42, 0.86)
);
background-size: 220% 100%;
animation: panelSkeletonSweep 1.35s ease-in-out infinite;
}
@@ -5142,8 +5242,11 @@
padding: 16px;
border: 1px solid rgba(34, 211, 238, 0.22);
border-radius: 8px;
background:
linear-gradient(180deg, rgba(8, 47, 73, 0.42), rgba(15, 23, 42, 0.78));
background: linear-gradient(
180deg,
rgba(8, 47, 73, 0.42),
rgba(15, 23, 42, 0.78)
);
}
.root :global(.future-v2-meteorology-copy) {
@@ -6181,7 +6284,8 @@
background: #22d3ee;
}
.root :global(.future-v2-rule-panel:nth-child(2) .future-v2-rule-list li::before) {
.root
:global(.future-v2-rule-panel:nth-child(2) .future-v2-rule-list li::before) {
background: #f59e0b;
}
@@ -42,28 +42,24 @@ const loadFutureForecastModal = () =>
const MapCanvas = dynamic(
() =>
import("@/components/dashboard/MapCanvas").then((module) => module.MapCanvas),
import("@/components/dashboard/MapCanvas").then(
(module) => module.MapCanvas,
),
{
ssr: false,
loading: () => <div className="map" aria-hidden="true" />,
},
);
const HistoryModal = dynamic(
loadHistoryModal,
{
ssr: false,
loading: () => null,
},
);
const HistoryModal = dynamic(loadHistoryModal, {
ssr: false,
loading: () => null,
});
const FutureForecastModal = dynamic(
loadFutureForecastModal,
{
ssr: false,
loading: () => null,
},
);
const FutureForecastModal = dynamic(loadFutureForecastModal, {
ssr: false,
loading: () => null,
});
type CitySnapshot = {
city: CityListItem;
@@ -149,7 +145,11 @@ function buildSnapshot(
}
function getProbabilityLabel(
bucket: { label?: string | null; value?: number | null; bucket?: string | null },
bucket: {
label?: string | null;
value?: number | null;
bucket?: string | null;
},
symbol: string,
) {
if (bucket.label) return bucket.label;
@@ -162,19 +162,22 @@ function getProbabilityLabel(
function formatProbability(value: number | null | undefined) {
if (!Number.isFinite(Number(value))) return "--";
const normalized = Math.abs(Number(value)) <= 1 ? Number(value) * 100 : Number(value);
const normalized =
Math.abs(Number(value)) <= 1 ? Number(value) * 100 : Number(value);
return `${Math.round(normalized)}%`;
}
function formatCents(value: number | null | undefined) {
if (!Number.isFinite(Number(value))) return "--";
const normalized = Math.abs(Number(value)) <= 1 ? Number(value) * 100 : Number(value);
const normalized =
Math.abs(Number(value)) <= 1 ? Number(value) * 100 : Number(value);
return `${Math.round(normalized)}¢`;
}
function formatEdge(value: number | null | undefined) {
if (!Number.isFinite(Number(value))) return "--";
const normalized = Math.abs(Number(value)) <= 1 ? Number(value) * 100 : Number(value);
const normalized =
Math.abs(Number(value)) <= 1 ? Number(value) * 100 : Number(value);
const sign = normalized > 0 ? "+" : "";
return `${sign}${normalized.toFixed(1)}%`;
}
@@ -203,7 +206,8 @@ function buildSparklinePoints(values: number[] | undefined) {
const span = max - min || 1;
return values
.map((value, index) => {
const x = values.length === 1 ? width : (index / (values.length - 1)) * width;
const x =
values.length === 1 ? width : (index / (values.length - 1)) * width;
const y = height - ((value - min) / span) * height;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
@@ -253,10 +257,18 @@ function buildDashboardSummaryCards(
snapshots: CitySnapshot[],
locale: string,
): HomeSummaryCard[] {
const topTradable = snapshots.filter((snapshot) => snapshot.tradableOpportunity);
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 topTradable = snapshots.filter(
(snapshot) => snapshot.tradableOpportunity,
);
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 strongAgreement = snapshots.filter(
(snapshot) => Number(snapshot.city.deb_recent_hit_rate ?? 0) >= 0.66,
).length;
@@ -377,11 +389,20 @@ function HomeMapToolbar() {
<>
<div className="home-map-header">
<div className="home-map-title">
<strong>{locale === "en-US" ? "Global weather regime" : "全球天气态势"}</strong>
<span>{locale === "en-US" ? "Live monitored weather derivatives board" : "天气衍生品实时监控面板"}</span>
<strong>
{locale === "en-US" ? "Global weather regime" : "全球天气态势"}
</strong>
<span>
{locale === "en-US"
? "Live monitored weather derivatives board"
: "天气衍生品实时监控面板"}
</span>
</div>
</div>
<div className="home-map-toolbar" aria-label={locale === "en-US" ? "Map layer controls" : "地图图层控件"}>
<div
className="home-map-toolbar"
aria-label={locale === "en-US" ? "Map layer controls" : "地图图层控件"}
>
<div className="home-map-modes">
<button type="button" className="active">
<Thermometer size={14} strokeWidth={2} />
@@ -447,12 +468,21 @@ function projectHomeTrendPoint(
};
}
function getHomeWeatherIconKind(detail?: CityDetail | null, locale = "zh-CN"): HomeWeatherIconKind {
function getHomeWeatherIconKind(
detail?: CityDetail | null,
locale = "zh-CN",
): HomeWeatherIconKind {
if (!detail) return "cloudy";
const summary = getWeatherSummary(detail, locale === "en-US" ? "en-US" : "zh-CN");
const weatherText = `${summary.weatherIcon} ${summary.weatherText} ${detail.current?.wx_desc || ""} ${detail.current?.cloud_desc || ""}`.toLowerCase();
const summary = getWeatherSummary(
detail,
locale === "en-US" ? "en-US" : "zh-CN",
);
const weatherText =
`${summary.weatherIcon} ${summary.weatherText} ${detail.current?.wx_desc || ""} ${detail.current?.cloud_desc || ""}`.toLowerCase();
const cloudCover = Number(detail.hourly_next_48h?.cloud_cover?.[0]);
const windSpeed = Number(detail.current?.wind_speed_kt ?? detail.airport_current?.wind_speed_kt);
const windSpeed = Number(
detail.current?.wind_speed_kt ?? detail.airport_current?.wind_speed_kt,
);
if (/⛈|雷|storm|thunder/.test(weatherText)) return "storm";
if (/🌧|🌦|雨|drizzle|shower|rain/.test(weatherText)) return "rain";
@@ -471,11 +501,17 @@ function buildHomeTrendChart(
locale = "zh-CN",
): HomeTrendChart | null {
if (!detail) return null;
const chartData = getTemperatureChartData(detail, locale === "en-US" ? "en-US" : "zh-CN");
const chartData = getTemperatureChartData(
detail,
locale === "en-US" ? "en-US" : "zh-CN",
);
if (!chartData) return null;
const forecastSeries = chartData.datasets.hasMgmHourly
? chartData.datasets.mgmHourlySeries
: [...chartData.datasets.debPastSeries, ...chartData.datasets.debFutureSeries];
: [
...chartData.datasets.debPastSeries,
...chartData.datasets.debFutureSeries,
];
const observationSeries =
chartData.datasets.metarSeries.length > 0
? chartData.datasets.metarSeries
@@ -510,7 +546,8 @@ function buildHomeTrendChart(
key: `${point.labelTime}-${index}`,
};
});
const hoverSource = observationSeries.length > 0 ? observationSeries : forecastSeries;
const hoverSource =
observationSeries.length > 0 ? observationSeries : forecastSeries;
const hoverPoints = hoverSource.map((point, index) => {
const projected = projectHomeTrendPoint(
point.x,
@@ -544,14 +581,22 @@ function buildHomeForecastDays(
if (!detail) return [];
const todayLabel = locale === "en-US" ? "Today" : "今天";
const tomorrowLabel = locale === "en-US" ? "Tomorrow" : "明天";
const formatter = new Intl.DateTimeFormat(locale === "en-US" ? "en-US" : "zh-CN", {
day: "2-digit",
month: "2-digit",
});
const rows = Array.isArray(detail.forecast?.daily) ? detail.forecast.daily : [];
const formatter = new Intl.DateTimeFormat(
locale === "en-US" ? "en-US" : "zh-CN",
{
day: "2-digit",
month: "2-digit",
},
);
const rows = Array.isArray(detail.forecast?.daily)
? detail.forecast.daily
: [];
const byDate = new Map<string, number>();
if (detail.local_date && Number.isFinite(Number(detail.forecast?.today_high))) {
if (
detail.local_date &&
Number.isFinite(Number(detail.forecast?.today_high))
) {
byDate.set(detail.local_date, Number(detail.forecast?.today_high));
}
rows.forEach((row) => {
@@ -582,7 +627,9 @@ function buildHomeForecastDays(
function readNumericField(source: unknown, key: string) {
if (!source || typeof source !== "object") return undefined;
const value = (source as Record<string, unknown>)[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
function normalizePercentValue(value: number | null | undefined) {
@@ -623,7 +670,8 @@ function isTradableMarketOpportunity(detail?: CityDetail | null) {
: Number.NaN;
if (Number.isFinite(endDateMs) && endDateMs < Date.now()) return false;
const marketBucket = marketScan.temperature_bucket || marketScan.top_buckets?.[0] || null;
const marketBucket =
marketScan.temperature_bucket || marketScan.top_buckets?.[0] || null;
const yesPrice =
marketScan.yes_buy ??
readNumericField(marketBucket, "yes_buy") ??
@@ -661,7 +709,9 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
const spotlight = useMemo(
() =>
store.selectedCity
? snapshots.find((snapshot) => snapshot.city.name === store.selectedCity) || null
? snapshots.find(
(snapshot) => snapshot.city.name === store.selectedCity,
) || null
: null,
[snapshots, store.selectedCity],
);
@@ -672,9 +722,12 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
const symbol = getTempSymbol(city, summary, detail);
const currentTemp = summary?.current?.temp ?? detail?.current?.temp;
const debPrediction = summary?.deb?.prediction ?? detail?.deb?.prediction;
const maxSoFar = detail?.current?.max_so_far ?? detail?.airport_current?.max_so_far;
const maxSoFar =
detail?.current?.max_so_far ?? detail?.airport_current?.max_so_far;
const maxTime =
detail?.current?.max_temp_time || detail?.airport_current?.max_temp_time || "--";
detail?.current?.max_temp_time ||
detail?.airport_current?.max_temp_time ||
"--";
const localTime = summary?.local_time || detail?.local_time || "--";
const riskLevel =
city.deb_recent_tier ||
@@ -748,9 +801,11 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
readNumericField(marketBucket, "yes_buy") ??
yesPrice ??
marketScan?.model_probability,
marketModelProbability: marketScan?.model_probability ?? marketBucket?.probability,
marketModelProbability:
marketScan?.model_probability ?? marketBucket?.probability,
sparklinePoints: buildSparklinePoints(marketScan?.sparkline),
isLoading: store.loadingState.cityDetail && store.selectedCity === city.name,
isLoading:
store.loadingState.cityDetail && store.selectedCity === city.name,
isPro: store.proAccess.subscriptionActive,
cityCode: city.icao || detail?.risk?.icao || city.airport,
localizedCityName: getLocalizedCityDisplay(city, locale, summary, detail),
@@ -770,37 +825,34 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
marketEdgeLabel: locale === "en-US" ? "Edge" : "优势",
marketImpliedLabel: locale === "en-US" ? "Implied" : "市场隐含",
marketModelLabel: locale === "en-US" ? "Model prob" : "模型概率",
proLabel:
store.proAccess.subscriptionActive
? locale === "en-US"
? "Pro signal"
: "PRO 信号"
: locale === "en-US"
? "Pro locked"
: "PRO 锁定",
proLabel: store.proAccess.subscriptionActive
? locale === "en-US"
? "Pro signal"
: "PRO 信号"
: locale === "en-US"
? "Pro locked"
: "PRO 锁定",
forecastDays: buildHomeForecastDays(detail, locale),
keySignals: [
{
active: Number(marketEdge) > 0,
label:
locale === "en-US"
? "DEB > Market implied"
: "DEB 高于市场隐含",
locale === "en-US" ? "DEB > Market implied" : "DEB 高于市场隐含",
tone: "green",
},
{
active: deviation?.trend === "expanding" || deviation?.direction === "hot",
active:
deviation?.trend === "expanding" || deviation?.direction === "hot",
label: locale === "en-US" ? "Rising temps trend" : "升温趋势",
tone: "green",
},
{
active: Boolean(detail?.peak?.hours?.length),
label:
detail?.peak?.hours?.length
? `${locale === "en-US" ? "High impact window" : "高影响窗口"} ${detail.peak.hours[0]}-${detail.peak.hours[detail.peak.hours.length - 1]}`
: locale === "en-US"
? "High impact window pending"
: "高影响窗口待确认",
label: detail?.peak?.hours?.length
? `${locale === "en-US" ? "High impact window" : "高影响窗口"} ${detail.peak.hours[0]}-${detail.peak.hours[detail.peak.hours.length - 1]}`
: locale === "en-US"
? "High impact window pending"
: "高影响窗口待确认",
tone: "amber",
},
],
@@ -860,7 +912,10 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
const subtitle = `${cityCode} · ${localizedAirportName}`;
return (
<aside className="home-intelligence-panel full" aria-label={localizedCityName}>
<aside
className="home-intelligence-panel full"
aria-label={localizedCityName}
>
<div className="home-panel-glow" aria-hidden="true" />
<button
type="button"
@@ -901,7 +956,9 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
</div>
<div className="home-card-meta-row">
<span>{locale === "en-US" ? "Local time" : "当地时间"} {localTime}</span>
<span>
{locale === "en-US" ? "Local time" : "当地时间"} {localTime}
</span>
<span className="home-card-live-dot" />
<span>
{isLoading
@@ -1047,7 +1104,10 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
const probability = Number(bucket.probability ?? 0);
const width = Math.max(6, Math.min(100, probability * 100));
return (
<div key={`${getProbabilityLabel(bucket, symbol)}-${index}`} className="home-probability-row">
<div
key={`${getProbabilityLabel(bucket, symbol)}-${index}`}
className="home-probability-row"
>
<span>{getProbabilityLabel(bucket, symbol)}</span>
<div>
<i style={{ width: `${width}%` }} />
@@ -1100,10 +1160,12 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
{marketEdgeLabel} <strong>{formatEdge(marketEdge)}</strong>
</span>
<span>
{marketImpliedLabel} <strong>{formatProbability(marketProbability)}</strong>
{marketImpliedLabel}{" "}
<strong>{formatProbability(marketProbability)}</strong>
</span>
<span>
{marketModelLabel} <strong>{formatProbability(marketModelProbability)}</strong>
{marketModelLabel}{" "}
<strong>{formatProbability(marketModelProbability)}</strong>
</span>
<svg viewBox="0 0 96 32" aria-hidden="true">
{sparklinePoints ? <polyline points={sparklinePoints} /> : null}
@@ -1154,23 +1216,19 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) {
);
}
function OpportunityStrip({
snapshots,
}: {
snapshots: CitySnapshot[];
}) {
function OpportunityStrip({ snapshots }: { snapshots: CitySnapshot[] }) {
const { locale } = useI18n();
const store = useDashboardStore();
const summaryCards = useMemo(
() => buildDashboardSummaryCards(snapshots, locale),
[locale, snapshots],
);
const items = useMemo(
() => snapshots.filter((snapshot) => snapshot.tradableOpportunity).slice(0, 5),
[snapshots],
);
if (!items.length) return null;
// Always show top 5 cities by score, regardless of market tradability
const items = useMemo(() => snapshots.slice(0, 5), [snapshots]);
// Always render: summary cards should be visible even without tradable opportunities
if (!snapshots.length) return null;
return (
<section
@@ -1186,8 +1244,15 @@ function OpportunityStrip({
</div>
<div className="home-summary-card-body">
{card.items.map((item) => (
<div key={`${card.key}-${item.label}`} className="home-summary-stat">
<b className={item.accent ? `accent-${item.accent}` : undefined}>
<div
key={`${card.key}-${item.label}`}
className="home-summary-stat"
>
<b
className={
item.accent ? `accent-${item.accent}` : undefined
}
>
{item.value}
</b>
<span>{item.label}</span>
@@ -1198,57 +1263,97 @@ function OpportunityStrip({
))}
</div>
<div className="opportunity-strip-heading">
<div>
<span>{locale === "en-US" ? "Today's Top Opportunities" : "今日最佳机会"}</span>
<strong>
{locale === "en-US" ? "Sorted by edge and tradability" : "按优势与可交易性排序"}
</strong>
</div>
<Link href="/docs/intraday-signal" className="opportunity-view-all">
{locale === "en-US" ? "View all" : "查看全部"}
</Link>
</div>
<div className="opportunity-card-grid top-opportunities">
{items.map(({ city, detail, summary }, index) => {
const symbol = getTempSymbol(city, summary, detail);
const currentTemp = summary?.current?.temp ?? detail?.current?.temp;
const debPrediction = summary?.deb?.prediction ?? detail?.deb?.prediction;
const localizedCityName = getLocalizedCityDisplay(city, locale, summary, detail);
const tier =
city.deb_recent_tier ||
city.risk_level ||
summary?.risk?.level ||
detail?.risk?.level;
return (
<button
key={city.name}
type="button"
className="opportunity-card"
onClick={() => void store.focusCity(city.name)}
>
<div className="opportunity-card-header">
<span className="opportunity-rank">{index + 1}</span>
<span className="opportunity-city">{localizedCityName}</span>
<span className={clsx("opportunity-pill", String(tier || "other"))}>
{getRiskCopy(tier as RiskLevel | undefined, locale)}
</span>
</div>
<span className="opportunity-meta">
&gt; {formatTemperature(debPrediction, symbol)}{" "}
{locale === "en-US" ? "target" : "目标"}
{items.length > 0 && (
<>
<div className="opportunity-strip-heading">
<div>
<span>
{locale === "en-US"
? "Today's Top Opportunities"
: "今日最佳机会"}
</span>
<div className="opportunity-card-footer">
<div className="opportunity-price-pair">
<span className="opportunity-yes">YES {formatCents(detail?.market_scan?.yes_buy)}</span>
<span className="opportunity-no">NO {formatCents(detail?.market_scan?.no_buy)}</span>
</div>
<span className="opportunity-edge">{formatEdge(getMarketEdgeValue(detail))}</span>
</div>
</button>
);
})}
</div>
<strong>
{locale === "en-US"
? "Sorted by score and risk"
: "按综合评分排序"}
</strong>
</div>
<Link href="/docs/intraday-signal" className="opportunity-view-all">
{locale === "en-US" ? "View all" : "查看全部"}
</Link>
</div>
<div className="opportunity-card-grid top-opportunities">
{items.map(
({ city, detail, summary, tradableOpportunity }, index) => {
const symbol = getTempSymbol(city, summary, detail);
const debPrediction =
summary?.deb?.prediction ?? detail?.deb?.prediction;
const localizedCityName = getLocalizedCityDisplay(
city,
locale,
summary,
detail,
);
const tier =
city.deb_recent_tier ||
city.risk_level ||
summary?.risk?.level ||
detail?.risk?.level;
const marketClosed = !tradableOpportunity;
return (
<button
key={city.name}
type="button"
className={clsx(
"opportunity-card",
marketClosed && "market-closed",
)}
onClick={() => void store.focusCity(city.name)}
>
<div className="opportunity-card-header">
<span className="opportunity-rank">{index + 1}</span>
<span className="opportunity-city">
{localizedCityName}
</span>
<span
className={clsx(
"opportunity-pill",
String(tier || "other"),
)}
>
{getRiskCopy(tier as RiskLevel | undefined, locale)}
</span>
</div>
<span className="opportunity-meta">
&gt; {formatTemperature(debPrediction, symbol)}{" "}
{locale === "en-US" ? "target" : "目标"}
{marketClosed && (
<span className="opportunity-market-status">
{" · "}
{locale === "en-US" ? "Market closed" : "市场已关闭"}
</span>
)}
</span>
<div className="opportunity-card-footer">
<div className="opportunity-price-pair">
<span className="opportunity-yes">
YES {formatCents(detail?.market_scan?.yes_buy)}
</span>
<span className="opportunity-no">
NO {formatCents(detail?.market_scan?.no_buy)}
</span>
</div>
<span className="opportunity-edge">
{formatEdge(getMarketEdgeValue(detail))}
</span>
</div>
</button>
);
},
)}
</div>
</>
)}
</section>
);
}
@@ -1264,7 +1369,8 @@ function DashboardScreen() {
const activeCityName =
store.selectedDetail?.display_name ||
activeSummary?.display_name ||
store.cities.find((city) => city.name === store.selectedCity)?.display_name ||
store.cities.find((city) => city.name === store.selectedCity)
?.display_name ||
store.selectedCity ||
"";
@@ -1291,9 +1397,7 @@ function DashboardScreen() {
}, [store]);
// Avoid full-page flashing on initial load; only show this overlay for manual refresh.
const showLoading =
store.loadingState.cities ||
store.loadingState.refresh;
const showLoading = store.loadingState.cities || store.loadingState.refresh;
const showCitySyncToast =
store.loadingState.cityDetail &&
activeCityName &&
@@ -1332,7 +1436,9 @@ function DashboardScreen() {
useEffect(() => {
if (!showHomepageChrome) return;
const targets = homepageSnapshots.slice(0, 4).map((snapshot) => snapshot.city.name);
const targets = homepageSnapshots
.slice(0, 4)
.map((snapshot) => snapshot.city.name);
targets.forEach((cityName) => {
if (preloadedOpportunityRef.current.has(cityName)) return;
preloadedOpportunityRef.current.add(cityName);
@@ -1343,9 +1449,9 @@ function DashboardScreen() {
}, [homepageSnapshots, showHomepageChrome, store]);
return (
<div
className={clsx(
styles.root,
<div
className={clsx(
styles.root,
detailChromeStyles.root,
modalChromeStyles.root,
)}
+487
View File
@@ -0,0 +1,487 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PolyWeather - 天气衍生品智能分析平台</title>
<meta name="description" content="PolyWeather 天气衍生品智能分析平台,提供全球天气数据、市场分析与交易信号">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Noto+Sans+SC:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
:root{
--bg-base:#0a0e1a;--bg-card:#111827;--bg-card-hover:#1a2235;--bg-surface:#0f1525;
--border:#1e2a3a;--border-hover:#2a3a50;
--text-primary:#e8ecf4;--text-secondary:#8899aa;--text-muted:#556677;
--accent:#00bcd4;--accent-glow:rgba(0,188,212,.15);--accent-dim:#0097a7;
--red:#ef4444;--red-bg:rgba(239,68,68,.12);--red-border:rgba(239,68,68,.3);
--orange:#f59e0b;--orange-bg:rgba(245,158,11,.12);--orange-border:rgba(245,158,11,.3);
--green:#22c55e;--green-bg:rgba(34,197,94,.12);--green-border:rgba(34,197,94,.3);
--blue:#3b82f6;
--radius:10px;--radius-lg:14px;--radius-sm:6px;
--font-sans:'Inter','Noto Sans SC',system-ui,sans-serif;
}
html{font-size:13px}
body{font-family:var(--font-sans);background:var(--bg-base);color:var(--text-primary);overflow:hidden;height:100vh;display:flex;flex-direction:column}
::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#2a3a50;border-radius:4px}
/* === HEADER === */
.header{display:flex;align-items:center;height:46px;padding:0 16px;background:linear-gradient(180deg,#111827 0%,#0f1420 100%);border-bottom:1px solid var(--border);flex-shrink:0;gap:8px;z-index:100}
.logo{display:flex;align-items:center;gap:8px;margin-right:12px}
.logo-icon{width:28px;height:28px;background:linear-gradient(135deg,#00bcd4,#0288d1);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:800;color:#fff}
.logo-text{font-size:16px;font-weight:700;color:#fff;letter-spacing:-.5px}
.logo-sub{font-size:11px;color:var(--text-muted);margin-left:4px;white-space:nowrap}
.nav{display:flex;gap:2px;margin:0 auto}
.nav-item{padding:6px 14px;border-radius:var(--radius-sm);font-size:12px;color:var(--text-secondary);cursor:pointer;display:flex;align-items:center;gap:5px;transition:.2s}
.nav-item:hover{color:var(--text-primary);background:rgba(255,255,255,.05)}
.nav-item.active{color:var(--accent);background:var(--accent-glow)}
.header-right{display:flex;align-items:center;gap:6px;margin-left:auto}
.live-badge{display:flex;align-items:center;gap:5px;padding:4px 10px;border-radius:20px;border:1px solid var(--green-border);background:var(--green-bg);font-size:11px;color:var(--green);font-weight:600}
.live-dot{width:6px;height:6px;border-radius:50%;background:var(--green);animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.hdr-btn{width:32px;height:32px;border-radius:var(--radius-sm);border:1px solid var(--border);background:transparent;color:var(--text-secondary);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:13px;transition:.2s}
.hdr-btn:hover{border-color:var(--border-hover);color:var(--text-primary);background:rgba(255,255,255,.04)}
/* === MAIN LAYOUT === */
.main{display:grid;grid-template-columns:220px 1fr 280px;flex:1;overflow:hidden;min-height:0}
/* === SIDEBAR === */
.sidebar{background:var(--bg-surface);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
.sidebar-header{padding:10px 12px 6px}
.search-box{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:var(--radius-sm);border:1px solid var(--border);background:rgba(255,255,255,.03);color:var(--text-secondary);font-size:12px}
.search-box input{background:none;border:none;color:var(--text-primary);font-size:12px;outline:none;width:100%;font-family:inherit}
.sidebar-title{display:flex;align-items:center;justify-content:space-between;padding:8px 12px 4px;font-size:11px;font-weight:600;color:var(--text-secondary)}
.sidebar-title .count{background:rgba(255,255,255,.08);padding:1px 7px;border-radius:10px;font-size:10px}
.city-list{flex:1;overflow-y:auto;padding:0 6px 8px}
.risk-group{margin-bottom:4px}
.risk-label{display:flex;align-items:center;gap:6px;padding:5px 8px;font-size:11px;font-weight:600;cursor:pointer;color:var(--text-secondary)}
.risk-label .arrow{font-size:8px;transition:.2s}
.risk-label .dot{width:6px;height:6px;border-radius:50%}
.risk-label .r-count{margin-left:auto;font-size:10px;color:var(--text-muted)}
.city-item{display:flex;align-items:center;padding:5px 8px 5px 22px;border-radius:var(--radius-sm);cursor:pointer;transition:.15s;gap:6px;font-size:12px}
.city-item:hover{background:rgba(255,255,255,.04)}
.city-item.active{background:var(--accent-glow);border:1px solid rgba(0,188,212,.25)}
.city-item .name{flex:1;white-space:nowrap}
.city-item .temp{font-weight:600;font-size:12px;min-width:36px;text-align:right}
.city-item .trend{font-size:10px;margin-left:2px}
.city-item .time{font-size:10px;color:var(--text-muted);min-width:34px;text-align:right}
.sidebar-footer{padding:8px 12px;border-top:1px solid var(--border);font-size:10px;color:var(--text-muted);line-height:1.5}
/* === CENTER === */
.center{display:flex;flex-direction:column;overflow:hidden;min-width:0}
.map-panel{flex:1;position:relative;background:var(--bg-base);overflow:hidden;min-height:0}
.map-header{position:absolute;top:12px;left:16px;right:16px;display:flex;align-items:center;justify-content:space-between;z-index:10}
.map-title{font-size:14px;font-weight:600;display:flex;align-items:center;gap:8px}
.map-title .sub{font-size:11px;color:var(--text-muted);font-weight:400}
.map-tabs{display:flex;gap:2px;background:rgba(17,24,39,.8);border:1px solid var(--border);border-radius:var(--radius-sm);padding:2px}
.map-tab{padding:4px 14px;border-radius:4px;font-size:11px;color:var(--text-secondary);cursor:pointer;transition:.2s}
.map-tab.active{background:var(--accent);color:#fff}
.map-tab:hover:not(.active){color:var(--text-primary)}
.map-expand{width:28px;height:28px;border-radius:var(--radius-sm);border:1px solid var(--border);background:rgba(17,24,39,.8);color:var(--text-secondary);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:12px}
/* Map Canvas */
.map-canvas{width:100%;height:100%;position:relative}
.map-bg{position:absolute;inset:0;background:radial-gradient(ellipse at 50% 50%,#0d1a2f 0%,#080e1a 70%);overflow:hidden}
.map-bg::before{content:'';position:absolute;inset:-20%;background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='400'%3E%3Cdefs%3E%3CradialGradient id='a'%3E%3Cstop offset='0' stop-color='%23112240'/%3E%3Cstop offset='1' stop-color='transparent'/%3E%3C/radialGradient%3E%3C/defs%3E%3Crect fill='%230a0e18' width='800' height='400'/%3E%3Ccircle cx='400' cy='200' r='250' fill='url(%23a)' opacity='.3'/%3E%3C/svg%3E") center/cover;opacity:.5}
.map-grid{position:absolute;inset:0;background-image:linear-gradient(rgba(0,188,212,.03) 1px,transparent 1px),linear-gradient(90deg,rgba(0,188,212,.03) 1px,transparent 1px);background-size:60px 60px}
.continent{position:absolute;border-radius:40%;opacity:.12;filter:blur(15px)}
.c-na{width:280px;height:180px;background:#1a5276;top:18%;left:12%}
.c-sa{width:140px;height:200px;background:#1a5276;top:48%;left:22%}
.c-eu{width:200px;height:120px;background:#1a5276;top:14%;left:42%}
.c-af{width:160px;height:200px;background:#1a5276;top:32%;left:42%}
.c-as{width:280px;height:180px;background:#1a5276;top:16%;left:55%}
.heat-spot{position:absolute;border-radius:50%;filter:blur(25px);animation:heatPulse 4s ease-in-out infinite alternate}
@keyframes heatPulse{0%{opacity:.4;transform:scale(1)}100%{opacity:.7;transform:scale(1.1)}}
.city-pin{position:absolute;z-index:5;cursor:pointer;transition:.2s}
.city-pin:hover{z-index:10;transform:scale(1.15)}
.pin-dot{width:8px;height:8px;border-radius:50%;border:2px solid;position:relative}
.pin-dot::after{content:'';position:absolute;inset:-4px;border-radius:50%;border:1px solid;opacity:.3;animation:ripple 2.5s infinite}
@keyframes ripple{0%{transform:scale(1);opacity:.4}100%{transform:scale(2.2);opacity:0}}
.pin-label{position:absolute;top:-22px;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:10px;padding:2px 6px;border-radius:4px;background:rgba(10,14,26,.85);border:1px solid var(--border);backdrop-filter:blur(4px);display:flex;align-items:center;gap:4px}
.pin-label .pin-temp{font-weight:600;color:var(--accent)}
.pin-highlight{position:absolute;z-index:4}
.pin-highlight .ring{width:60px;height:60px;border-radius:50%;border:2px solid var(--red);opacity:.4;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);animation:ringPulse 2s infinite}
.pin-highlight .ring2{width:90px;height:90px;border-radius:50%;border:1px solid var(--red);opacity:.2;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);animation:ringPulse 2s .5s infinite}
@keyframes ringPulse{0%{opacity:.4;transform:translate(-50%,-50%) scale(1)}100%{opacity:0;transform:translate(-50%,-50%) scale(1.5)}}
.pin-highlight .glow{width:40px;height:40px;border-radius:50%;background:radial-gradient(var(--red),transparent 70%);opacity:.25;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}
/* Map controls */
.map-zoom{position:absolute;right:16px;top:50%;transform:translateY(-50%);display:flex;flex-direction:column;gap:2px;z-index:10}
.map-zoom button{width:30px;height:30px;border-radius:var(--radius-sm);border:1px solid var(--border);background:rgba(17,24,39,.85);color:var(--text-secondary);cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center;transition:.2s;backdrop-filter:blur(4px)}
.map-zoom button:hover{border-color:var(--accent);color:var(--accent)}
.map-legend{position:absolute;bottom:12px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:8px;padding:6px 14px;border-radius:var(--radius);background:rgba(17,24,39,.85);border:1px solid var(--border);z-index:10;backdrop-filter:blur(4px)}
.map-legend .label{font-size:10px;color:var(--text-secondary)}
.legend-bar{width:200px;height:8px;border-radius:4px;background:linear-gradient(90deg,#2563eb,#06b6d4,#22c55e,#eab308,#ef4444);margin:0 4px}
.legend-vals{display:flex;justify-content:space-between;width:200px;font-size:9px;color:var(--text-muted)}
/* === Bottom Summary === */
.bottom-section{border-top:1px solid var(--border);flex-shrink:0}
.summary-row{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;padding:10px 16px;grid-column:1/-1}
.summary-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px;transition:.2s}
.summary-card:hover{border-color:var(--border-hover);background:var(--bg-card-hover)}
.sc-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.sc-title{font-size:12px;font-weight:600}
.sc-link{font-size:10px;color:var(--accent);cursor:pointer}
.sc-body{display:flex;align-items:flex-end;gap:16px}
.sc-stat{text-align:center}
.sc-stat .num{font-size:22px;font-weight:700;line-height:1}
.sc-stat .lbl{font-size:10px;color:var(--text-muted);margin-top:3px}
.sc-divider{width:1px;height:30px;background:var(--border)}
.sc-bar-row{display:flex;align-items:center;gap:6px;flex:1}
.sc-bar{height:6px;border-radius:3px;flex:1}
.sc-bar-label{font-size:11px;font-weight:700;min-width:30px}
/* === Opportunity Cards === */
.opp-section{padding:8px 16px 12px;border-top:1px solid var(--border);grid-column:1/-1}
.opp-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.opp-title{font-size:13px;font-weight:600;display:flex;align-items:center;gap:6px}
.opp-title .badge{font-size:10px;color:var(--text-muted);background:rgba(255,255,255,.06);padding:2px 8px;border-radius:10px}
.opp-nav{display:flex;gap:4px}
.opp-nav button{width:26px;height:26px;border-radius:50%;border:1px solid var(--border);background:transparent;color:var(--text-secondary);cursor:pointer;font-size:11px;transition:.2s}
.opp-nav button:hover{border-color:var(--accent);color:var(--accent)}
.opp-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}
.opp-card{background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px;transition:.2s;cursor:pointer}
.opp-card:hover{border-color:var(--accent);background:var(--bg-card-hover);transform:translateY(-1px);box-shadow:0 4px 20px rgba(0,188,212,.08)}
.opp-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
.opp-rank{font-size:12px;font-weight:700;color:var(--accent);margin-right:6px}
.opp-city{font-size:12px;font-weight:600}
.risk-tag{font-size:9px;padding:2px 6px;border-radius:4px;font-weight:600}
.risk-tag.high{color:var(--red);background:var(--red-bg);border:1px solid var(--red-border)}
.risk-tag.mid{color:var(--orange);background:var(--orange-bg);border:1px solid var(--orange-border)}
.risk-tag.low{color:var(--green);background:var(--green-bg);border:1px solid var(--green-border)}
.opp-question{font-size:10px;color:var(--text-muted);margin-bottom:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.opp-prices{display:flex;align-items:center;gap:8px;margin-bottom:6px}
.opp-yes,.opp-no{font-size:11px;font-weight:600}
.opp-yes{color:var(--green)}.opp-no{color:var(--red)}
.opp-edge{font-size:11px;color:var(--accent);font-weight:600}
.opp-chart{height:28px;margin-top:4px;position:relative}
.opp-chart svg{width:100%;height:100%}
/* === RIGHT PANEL === */
.right-panel{background:var(--bg-surface);border-left:1px solid var(--border);overflow-y:auto;display:flex;flex-direction:column}
.rp-header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid var(--border)}
.rp-title{font-size:12px;font-weight:600;display:flex;align-items:center;gap:5px}
.rp-link{font-size:10px;color:var(--accent);cursor:pointer}
.rp-city{padding:12px 14px}
.rp-city-name{font-size:22px;font-weight:700;display:flex;align-items:center;gap:6px}
.rp-city-name .star{color:var(--orange);font-size:14px}
.rp-meta{font-size:10px;color:var(--text-muted);margin-top:4px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.rp-meta .code{color:var(--text-secondary);font-weight:500}
.rp-time{display:flex;gap:12px;font-size:10px;color:var(--text-muted);margin-top:6px}
.rp-time .live-dot-sm{width:5px;height:5px;border-radius:50%;background:var(--green);display:inline-block;margin-right:3px}
.rp-temp-block{padding:8px 14px;display:flex;align-items:center;justify-content:space-between}
.rp-temp-main{display:flex;align-items:flex-start}
.rp-temp-num{font-size:52px;font-weight:800;line-height:1;letter-spacing:-2px}
.rp-temp-unit{font-size:18px;font-weight:300;margin-top:4px}
.rp-temp-sub{font-size:11px;color:var(--text-muted);margin-top:4px}
.rp-temp-right{text-align:right}
.rp-weather-icon{font-size:36px;opacity:.7}
.rp-forecast{font-size:10px;color:var(--text-muted);margin-top:4px}
.rp-forecast .val{color:var(--text-primary);font-weight:600}
.rp-card{margin:4px 14px;padding:10px 12px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius)}
.rp-card-title{font-size:11px;font-weight:600;margin-bottom:6px;display:flex;align-items:center;gap:5px}
.rp-card-title .tag{font-size:9px;padding:1px 5px;border-radius:3px;background:var(--accent-glow);color:var(--accent)}
.rp-deb{display:flex;align-items:center;justify-content:space-between}
.rp-deb-temp{font-size:26px;font-weight:700}
.rp-deb-delta{font-size:13px;color:var(--red);font-weight:600;margin-left:6px}
.rp-mini-chart{width:140px;height:32px}
.rp-market{margin-top:2px}
.rp-market-q{font-size:11px;color:var(--text-secondary);margin-bottom:6px}
.rp-market-prices{display:flex;gap:8px;margin-bottom:8px}
.rp-price-box{flex:1;padding:6px;border-radius:var(--radius-sm);text-align:center;font-weight:700;font-size:13px}
.rp-price-yes{background:var(--green-bg);border:1px solid var(--green-border);color:var(--green)}
.rp-price-no{background:var(--red-bg);border:1px solid var(--red-border);color:var(--red)}
.rp-market-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;text-align:center}
.rp-market-stats .stat-label{font-size:9px;color:var(--text-muted)}
.rp-market-stats .stat-val{font-size:12px;font-weight:600;margin-top:1px}
.rp-signals{margin:4px 14px;padding:10px 12px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius)}
.rp-signals .title{font-size:11px;font-weight:600;margin-bottom:6px}
.signal-item{display:flex;align-items:center;gap:6px;padding:4px 0;font-size:11px}
.signal-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0}
.rp-cta{margin:8px 14px;padding:10px;text-align:center;background:linear-gradient(135deg,rgba(0,188,212,.15),rgba(0,188,212,.05));border:1px solid rgba(0,188,212,.25);border-radius:var(--radius);color:var(--accent);font-size:12px;font-weight:600;cursor:pointer;transition:.2s}
.rp-cta:hover{background:linear-gradient(135deg,rgba(0,188,212,.25),rgba(0,188,212,.1));transform:translateY(-1px)}
</style>
</head>
<body>
<!-- HEADER -->
<header class="header">
<div class="logo">
<div class="logo-icon">P</div>
<span class="logo-text">PolyWeather</span>
<span class="logo-sub">天气衍生品智能分析</span>
</div>
<nav class="nav">
<div class="nav-item active">🏠 首页</div>
<div class="nav-item">🗺️ 地图</div>
<div class="nav-item">📊 市场</div>
<div class="nav-item">📈 分析</div>
<div class="nav-item">📅 历史</div>
<div class="nav-item">⚠️ 预警</div>
</nav>
<div class="header-right">
<div class="live-badge"><span class="live-dot"></span>LIVE</div>
<button class="hdr-btn" title="刷新">🔄</button>
<button class="hdr-btn" title="文档">📄</button>
<button class="hdr-btn" title="通知">🔔</button>
<button class="hdr-btn" title="账户">👤</button>
</div>
</header>
<!-- MAIN -->
<div class="main">
<!-- SIDEBAR -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="search-box">🔍 <input type="text" placeholder="搜索城市..."></div>
</div>
<div class="sidebar-title">监控城市 <span class="count">52</span></div>
<div class="city-list">
<!-- High Risk -->
<div class="risk-group">
<div class="risk-label"><span class="arrow"></span><span class="dot" style="background:var(--red)"></span> 高风险城市 <span class="r-count">6</span></div>
<div class="city-item active"><span class="name">芝加哥</span><span class="temp" style="color:var(--red)">72°F</span><span class="trend">📈</span><span class="time"></span></div>
<div class="city-item"><span class="name">达拉斯</span><span class="temp">63°F</span><span class="time">09:18</span></div>
<div class="city-item"><span class="name">休斯顿</span><span class="temp">69°F</span><span class="time">09:25</span></div>
<div class="city-item"><span class="name">马尼拉</span><span class="temp">84°F</span><span class="time">08:59</span></div>
<div class="city-item"><span class="name">曼谷</span><span class="temp">83°F</span><span class="time">09:47</span></div>
<div class="city-item"><span class="name">迈阿密</span><span class="temp">82°F</span><span class="time">09:02</span></div>
</div>
<!-- Mid Risk -->
<div class="risk-group">
<div class="risk-label"><span class="arrow"></span><span class="dot" style="background:var(--orange)"></span> 中等风险城市 <span class="r-count">18</span></div>
<div class="city-item"><span class="name">纽约</span><span class="temp">66°F</span><span class="time">09:10</span></div>
<div class="city-item"><span class="name">洛杉矶</span><span class="temp">68°F</span><span class="time">09:10</span></div>
<div class="city-item"><span class="name">墨西哥城</span><span class="temp">64°F</span><span class="time">08:50</span></div>
<div class="city-item"><span class="name">伦敦</span><span class="temp">57°F</span><span class="time">09:35</span></div>
<div class="city-item"><span class="name">巴黎</span><span class="temp">59°F</span><span class="time">08:33</span></div>
</div>
<!-- Low Risk -->
<div class="risk-group">
<div class="risk-label"><span class="arrow"></span><span class="dot" style="background:var(--green)"></span> 低风险城市 <span class="r-count">28</span></div>
<div class="city-item"><span class="name">旧金山</span><span class="temp">61°F</span><span class="time">09:11</span></div>
<div class="city-item"><span class="name">西雅图</span><span class="temp">58°F</span><span class="time">09:07</span></div>
<div class="city-item"><span class="name">温哥华</span><span class="temp">56°F</span><span class="time">09:12</span></div>
<div class="city-item"><span class="name">多伦多</span><span class="temp">60°F</span><span class="time">09:08</span></div>
<div class="city-item"><span class="name">柏林</span><span class="temp">55°F</span><span class="time">09:30</span></div>
</div>
</div>
<div class="sidebar-footer">📊 数据更新 09:41:12 (北京)<br>数据来源:METAR · ECMWF · DEB · 市场</div>
</aside>
<!-- CENTER -->
<main class="center">
<div class="map-panel">
<div class="map-header">
<div class="map-title">全球天气态势 <span class="sub">更新时间:09:41 北京</span></div>
<div style="display:flex;gap:8px;align-items:center">
<div class="map-tabs">
<div class="map-tab active">温度</div>
<div class="map-tab">降水</div>
<div class="map-tab">风场</div>
</div>
<button class="map-expand"></button>
</div>
</div>
<div class="map-canvas">
<div class="map-bg"></div>
<div class="map-grid"></div>
<!-- Continents -->
<div class="continent c-na"></div>
<div class="continent c-sa"></div>
<div class="continent c-eu"></div>
<div class="continent c-af"></div>
<div class="continent c-as"></div>
<!-- Heat spots -->
<div class="heat-spot" style="width:120px;height:120px;background:radial-gradient(var(--red),transparent 70%);top:22%;left:22%;opacity:.3"></div>
<div class="heat-spot" style="width:80px;height:80px;background:radial-gradient(var(--orange),transparent 70%);top:35%;left:27%;opacity:.25;animation-delay:1s"></div>
<div class="heat-spot" style="width:60px;height:60px;background:radial-gradient(#22c55e,transparent 70%);top:20%;left:50%;opacity:.2;animation-delay:2s"></div>
<div class="heat-spot" style="width:100px;height:100px;background:radial-gradient(var(--red),transparent 70%);top:30%;left:70%;opacity:.25;animation-delay:.5s"></div>
<!-- City pins -->
<div class="city-pin pin-highlight" style="left:24%;top:28%"><div class="ring"></div><div class="ring2"></div><div class="glow"></div><div class="pin-dot" style="border-color:var(--red);background:var(--red)"></div><div class="pin-label">芝加哥 <span class="pin-temp">72°F</span></div></div>
<div class="city-pin" style="left:20%;top:22%"><div class="pin-dot" style="border-color:var(--green);background:transparent"></div><div class="pin-label">温哥华 <span class="pin-temp">56°F</span></div></div>
<div class="city-pin" style="left:22%;top:36%"><div class="pin-dot" style="border-color:var(--red);background:transparent"></div><div class="pin-label">达拉斯 <span class="pin-temp">63°F</span></div></div>
<div class="city-pin" style="left:19%;top:26%"><div class="pin-dot" style="border-color:var(--green);background:transparent"></div><div class="pin-label">旧金山 <span class="pin-temp">61°F</span></div></div>
<div class="city-pin" style="left:27%;top:30%"><div class="pin-dot" style="border-color:var(--orange);background:transparent"></div><div class="pin-label">纽约 <span class="pin-temp">66°F</span></div></div>
<div class="city-pin" style="left:22%;top:44%"><div class="pin-dot" style="border-color:var(--orange);background:transparent"></div><div class="pin-label">墨西哥城 <span class="pin-temp">64°F</span></div></div>
<div class="city-pin" style="left:46%;top:22%"><div class="pin-dot" style="border-color:var(--orange);background:transparent"></div><div class="pin-label">伦敦 <span class="pin-temp">57°F</span></div></div>
<div class="city-pin" style="left:48%;top:24%"><div class="pin-dot" style="border-color:var(--orange);background:transparent"></div><div class="pin-label">巴黎 <span class="pin-temp">59°F</span></div></div>
<div class="city-pin" style="left:44%;top:22%"><div class="pin-dot" style="border-color:var(--orange);background:transparent"></div><div class="pin-label">西雅图 <span class="pin-temp">58°F</span></div></div>
<div class="city-pin" style="left:30%;top:38%"><div class="pin-dot" style="border-color:var(--red);background:transparent"></div><div class="pin-label">迈阿密 <span class="pin-temp">82°F</span></div></div>
<div class="city-pin" style="left:70%;top:30%"><div class="pin-dot" style="border-color:var(--orange);background:transparent"></div><div class="pin-label">北京 <span class="pin-temp">61°F</span></div></div>
<div class="city-pin" style="left:72%;top:42%"><div class="pin-dot" style="border-color:var(--red);background:transparent"></div><div class="pin-label">马尼拉 <span class="pin-temp">84°F</span></div></div>
<div class="city-pin" style="left:68%;top:38%"><div class="pin-dot" style="border-color:var(--red);background:transparent"></div><div class="pin-label">曼谷 <span class="pin-temp">83°F</span></div></div>
<div class="city-pin" style="left:65%;top:44%"><div class="pin-dot" style="border-color:var(--orange);background:transparent"></div><div class="pin-label">圣保罗 <span class="pin-temp">70°F</span></div></div>
</div>
<div class="map-zoom"><button>+</button><button></button><button></button></div>
<div class="map-legend">
<span class="label">温度 (°F)</span>
<div>
<div class="legend-bar"></div>
<div class="legend-vals"><span>-4</span><span>14</span><span>32</span><span>50</span><span>68</span><span>86</span><span>104</span></div>
</div>
</div>
</div>
<!-- Bottom area -->
<div class="bottom-section">
<div class="summary-row">
<div class="summary-card">
<div class="sc-header"><span class="sc-title">今日机会概览</span><span class="sc-link">查看全部</span></div>
<div class="sc-body">
<div class="sc-stat"><div class="num" style="color:var(--red)">6</div><div class="lbl">高风险</div></div>
<div class="sc-stat"><div class="num" style="color:var(--orange)">14</div><div class="lbl">中等风险</div></div>
<div class="sc-stat"><div class="num" style="color:var(--green)">28</div><div class="lbl">低风险</div></div>
</div>
</div>
<div class="summary-card">
<div class="sc-header"><span class="sc-title">市场总览</span><span class="sc-link">查看全部</span></div>
<div class="sc-body" style="flex-direction:column;gap:4px">
<div style="display:flex;justify-content:space-between;font-size:11px"><span>交易市场数</span><span style="font-weight:700">1,248</span></div>
<div style="display:flex;justify-content:space-between;font-size:11px"><span>活跃合约数</span><span style="font-weight:700">892</span></div>
<div style="display:flex;justify-content:space-between;font-size:11px"><span>24h 成交量</span><span style="font-weight:700">$2.34M</span></div>
<div style="display:flex;justify-content:space-between;font-size:11px"><span>平均 Edge</span><span style="font-weight:700;color:var(--green)">+6.7%</span></div>
</div>
</div>
<div class="summary-card">
<div class="sc-header"><span class="sc-title">模型一致性</span><span class="sc-link">查看全部</span></div>
<div class="sc-body">
<div class="sc-stat"><div class="sc-bar-label" style="color:var(--green)">48%</div><div class="lbl"></div></div>
<div class="sc-stat"><div class="sc-bar-label" style="color:var(--orange)">32%</div><div class="lbl"></div></div>
<div class="sc-stat"><div class="sc-bar-label" style="color:var(--red)">20%</div><div class="lbl"></div></div>
</div>
<div style="display:flex;height:6px;border-radius:3px;overflow:hidden;margin-top:8px">
<div style="width:48%;background:var(--green)"></div>
<div style="width:32%;background:var(--orange)"></div>
<div style="width:20%;background:var(--red)"></div>
</div>
</div>
<div class="summary-card">
<div class="sc-header"><span class="sc-title">高影响窗口 (未来6小时)</span></div>
<div class="sc-body" style="flex-direction:column;gap:4px">
<div style="display:flex;justify-content:space-between;font-size:11px"><span>影响市场数</span><span style="font-weight:700">12</span></div>
<div style="display:flex;justify-content:space-between;font-size:11px"><span>预计成交量</span><span style="font-weight:700">$1.26M</span></div>
</div>
</div>
</div>
<!-- Opportunity Cards -->
<div class="opp-section">
<div class="opp-header">
<div class="opp-title">今日最佳机会 <span class="badge">Top 机会</span></div>
<div class="opp-nav"><button></button><button></button></div>
</div>
<div class="opp-grid">
<div class="opp-card">
<div class="opp-card-head"><span><span class="opp-rank">1</span><span class="opp-city">芝加哥</span></span><span class="risk-tag high">高风险</span></div>
<div class="opp-question">> 76°F 出现时间 5月22日</div>
<div class="opp-prices"><span class="opp-yes">YES 62¢</span><span style="color:var(--text-muted);font-size:10px">·</span><span class="opp-edge">Edge +12.4%</span></div>
<div class="opp-chart"><svg viewBox="0 0 160 28"><polyline points="5,22 20,18 40,20 60,14 80,16 100,10 120,12 140,6 155,8" fill="none" stroke="#22c55e" stroke-width="1.5"/></svg></div>
</div>
<div class="opp-card">
<div class="opp-card-head"><span><span class="opp-rank">2</span><span class="opp-city">达拉斯</span></span><span class="risk-tag high">高风险</span></div>
<div class="opp-question">> 76°F 出现时间 5月22日</div>
<div class="opp-prices"><span class="opp-yes">YES 58¢</span><span style="color:var(--text-muted);font-size:10px">·</span><span class="opp-edge">Edge +10.1%</span></div>
<div class="opp-chart"><svg viewBox="0 0 160 28"><polyline points="5,20 25,22 45,18 65,20 85,14 105,16 125,10 145,8 155,12" fill="none" stroke="#f59e0b" stroke-width="1.5"/></svg></div>
</div>
<div class="opp-card">
<div class="opp-card-head"><span><span class="opp-rank">3</span><span class="opp-city">伦敦</span></span><span class="risk-tag mid">中等风险</span></div>
<div class="opp-question">> 76°F 出现时间 5月22日</div>
<div class="opp-prices"><span class="opp-yes">YES 54¢</span><span style="color:var(--text-muted);font-size:10px">·</span><span class="opp-edge">Edge +6.8%</span></div>
<div class="opp-chart"><svg viewBox="0 0 160 28"><polyline points="5,16 25,18 45,14 65,16 85,12 105,14 125,10 145,12 155,8" fill="none" stroke="#3b82f6" stroke-width="1.5"/></svg></div>
</div>
<div class="opp-card">
<div class="opp-card-head"><span><span class="opp-rank">4</span><span class="opp-city">墨西哥城</span></span><span class="risk-tag low">低风险</span></div>
<div class="opp-question">> 76°F 出现时间 5月22日</div>
<div class="opp-prices"><span class="opp-yes">YES 53¢</span><span style="color:var(--text-muted);font-size:10px">·</span><span class="opp-edge">Edge +6.8%</span></div>
<div class="opp-chart"><svg viewBox="0 0 160 28"><polyline points="5,14 25,16 45,12 65,18 85,16 105,20 125,14 145,10 155,12" fill="none" stroke="#22c55e" stroke-width="1.5"/></svg></div>
</div>
<div class="opp-card">
<div class="opp-card-head"><span><span class="opp-rank">5</span><span class="opp-city">马尼拉</span></span><span class="risk-tag high">高风险</span></div>
<div class="opp-question">> 76°F 出现时间 5月22日</div>
<div class="opp-prices"><span class="opp-yes">YES 51¢</span><span style="color:var(--text-muted);font-size:10px">·</span><span class="opp-edge">Edge +4.7%</span></div>
<div class="opp-chart"><svg viewBox="0 0 160 28"><polyline points="5,20 25,16 45,18 65,12 85,14 105,10 125,16 145,14 155,10" fill="none" stroke="#ef4444" stroke-width="1.5"/></svg></div>
</div>
</div>
</div>
</div>
</main>
<!-- RIGHT PANEL -->
<aside class="right-panel">
<div class="rp-header"><span class="rp-title">焦点城市 </span><span class="rp-link">为什么是它?</span></div>
<div class="rp-city">
<div class="rp-city-name">芝加哥 <span class="star"></span></div>
<div class="rp-meta"><span class="code">KORD</span> · 芝加哥奥黑尔国际机场 <span class="risk-tag high" style="margin-left:4px">高风险</span></div>
<div class="rp-time"><span>本地时间 09:41</span><span><span class="live-dot-sm"></span>1 分钟前更新</span></div>
</div>
<div class="rp-temp-block">
<div>
<div class="rp-temp-main"><span class="rp-temp-num">72</span><span class="rp-temp-unit">°F</span></div>
<div class="rp-temp-sub">体感 70°F</div>
</div>
<div class="rp-temp-right">
<div class="rp-weather-icon">🌧️</div>
<div class="rp-forecast">最大预计<br><span class="val">76°F</span> 14:33</div>
</div>
</div>
<div class="rp-card">
<div class="rp-card-title"><span class="tag">DEB 预测</span> (24小时最高)</div>
<div class="rp-deb">
<div><span class="rp-deb-temp">76°F</span><span class="rp-deb-delta">+2°F</span></div>
<div class="rp-mini-chart">
<svg viewBox="0 0 140 32" width="140" height="32">
<polyline points="0,28 15,24 30,22 45,18 60,14 75,10 90,8 105,10 120,12 135,14" fill="none" stroke="var(--accent)" stroke-width="1.5"/>
<circle cx="90" cy="8" r="3" fill="var(--accent)"/>
<text x="85" y="6" font-size="7" fill="var(--accent)">76°F</text>
</svg>
</div>
</div>
</div>
<div class="rp-card rp-market" style="margin-top:6px">
<div class="rp-card-title">市场问题</div>
<div class="rp-market-q">> 76°F 出现时间 5月22日</div>
<div class="rp-market-prices">
<div class="rp-price-box rp-price-yes">YES 62¢</div>
<div class="rp-price-box rp-price-no">NO 38¢</div>
</div>
<div class="rp-market-stats">
<div><div class="stat-label">Edge</div><div class="stat-val" style="color:var(--green)">+12.4%</div></div>
<div><div class="stat-label">隐含概率</div><div class="stat-val">62%</div></div>
<div><div class="stat-label">模型概率</div><div class="stat-val">74%</div></div>
</div>
</div>
<div class="rp-signals">
<div class="title">关键信号</div>
<div class="signal-item"><span class="signal-dot" style="background:var(--accent)"></span> DEB > 市场隐含值</div>
<div class="signal-item"><span class="signal-dot" style="background:var(--orange)"></span> 气温上升趋势</div>
<div class="signal-item"><span class="signal-dot" style="background:var(--red)"></span> 高影响窗口 11:00-17:00 (北京)</div>
</div>
<div class="rp-cta">查看完整分析 →</div>
</aside>
</div>
<script>
// Hover effects for city items
document.querySelectorAll('.city-item').forEach(item=>{
item.addEventListener('click',()=>{
document.querySelectorAll('.city-item').forEach(i=>i.classList.remove('active'));
item.classList.add('active');
});
});
// Map tab switching
document.querySelectorAll('.map-tab').forEach(tab=>{
tab.addEventListener('click',()=>{
document.querySelectorAll('.map-tab').forEach(t=>t.classList.remove('active'));
tab.classList.add('active');
});
});
// Nav switching
document.querySelectorAll('.nav-item').forEach(n=>{
n.addEventListener('click',()=>{
document.querySelectorAll('.nav-item').forEach(i=>i.classList.remove('active'));
n.classList.add('active');
});
});
</script>
</body>
</html>