feat: Implement initial weather dashboard with interactive map, city details, and API integration.
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
context: { params: Promise<{ name: string }> },
|
||||||
|
) {
|
||||||
|
if (!API_BASE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name } = await context.params;
|
||||||
|
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||||
|
const url = `${API_BASE}/api/city/${encodeURIComponent(name)}/summary?force_refresh=${forceRefresh}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const raw = await res.text();
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch city summary", detail: String(error) },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,18 @@ export default function RootLayout({
|
|||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN" className="dark">
|
<html lang="zh-CN" className="dark">
|
||||||
|
<head>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<body className="min-h-screen font-sans antialiased">
|
<body className="min-h-screen font-sans antialiased">
|
||||||
{children}
|
{children}
|
||||||
<Analytics />
|
<Analytics />
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
export default function HomePage() {
|
import type { Metadata } from "next";
|
||||||
return (
|
import { DashboardEntry } from "@/components/dashboard/DashboardEntry";
|
||||||
<main className="h-screen w-screen overflow-hidden bg-black">
|
|
||||||
<iframe
|
|
||||||
title="PolyWeather Legacy Dashboard"
|
|
||||||
src="/legacy/index.html?v=legacy-v19"
|
|
||||||
className="h-full w-full border-0"
|
|
||||||
/>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "PolyWeather - 天气衍生品智能地图",
|
||||||
|
description:
|
||||||
|
"PolyWeather 天气衍生品智能地图,聚合 METAR、MGM、DEB、多模型预报与历史对账分析。",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return <DashboardEntry />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
|
|
||||||
|
export function CitySidebar() {
|
||||||
|
const store = useDashboardStore();
|
||||||
|
const sortedCities = [...store.cities].sort((a, b) => {
|
||||||
|
const order = { high: 0, medium: 1, low: 2 };
|
||||||
|
return (
|
||||||
|
(order[a.risk_level as keyof typeof order] ?? 3) -
|
||||||
|
(order[b.risk_level as keyof typeof order] ?? 3)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="city-list">
|
||||||
|
<div className="city-list-header">
|
||||||
|
<span>监控城市</span>
|
||||||
|
<span className="city-count">{store.cities.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="city-list-items">
|
||||||
|
{sortedCities.map((city) => {
|
||||||
|
const detail = store.cityDetailsByName[city.name];
|
||||||
|
const summary = store.citySummariesByName[city.name];
|
||||||
|
const snapshot = detail || summary;
|
||||||
|
const isActive = store.selectedCity === city.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={city.name}
|
||||||
|
type="button"
|
||||||
|
className={clsx("city-item", isActive && "active")}
|
||||||
|
onClick={() => void store.selectCity(city.name)}
|
||||||
|
>
|
||||||
|
<div className="city-item-main">
|
||||||
|
<span className={clsx("risk-dot", city.risk_level)} />
|
||||||
|
<span className="city-name-text">{city.display_name}</span>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"city-temp",
|
||||||
|
snapshot?.current?.temp != null && "loaded",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{snapshot?.current?.temp != null
|
||||||
|
? `${snapshot.current.temp}${snapshot.temp_symbol || "°C"}`
|
||||||
|
: "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="city-item-info">
|
||||||
|
<span className="city-local-time">
|
||||||
|
{snapshot?.local_time ? `🕐 ${snapshot.local_time}` : ""}
|
||||||
|
</span>
|
||||||
|
<span className="city-max-info">
|
||||||
|
{detail?.current?.max_temp_time
|
||||||
|
? `峰值 @ ${detail.current.max_temp_time}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const PolyWeatherDashboard = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/dashboard/PolyWeatherDashboard").then(
|
||||||
|
(module) => module.PolyWeatherDashboard,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function DashboardEntry() {
|
||||||
|
return <PolyWeatherDashboard />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
|
import { getCityScenery } from "@/lib/dashboard-scenery";
|
||||||
|
import {
|
||||||
|
getCityProfileStats,
|
||||||
|
getClimateDrivers,
|
||||||
|
getRiskBadgeLabel,
|
||||||
|
getSettlementRiskNarrative,
|
||||||
|
} from "@/lib/dashboard-utils";
|
||||||
|
import { ForecastTable } from "@/components/dashboard/PanelSections";
|
||||||
|
|
||||||
|
export function DetailPanel() {
|
||||||
|
const store = useDashboardStore();
|
||||||
|
const detail = store.selectedDetail;
|
||||||
|
const isOverlayOpen =
|
||||||
|
Boolean(store.futureModalDate) ||
|
||||||
|
store.historyState.isOpen ||
|
||||||
|
store.isGuideOpen;
|
||||||
|
const isVisible =
|
||||||
|
store.isPanelOpen &&
|
||||||
|
Boolean(store.selectedCity) &&
|
||||||
|
Boolean(detail) &&
|
||||||
|
!store.loadingState.cityDetail &&
|
||||||
|
!isOverlayOpen;
|
||||||
|
const profileStats = detail ? getCityProfileStats(detail) : [];
|
||||||
|
const riskLines = detail ? getSettlementRiskNarrative(detail) : [];
|
||||||
|
const climateDrivers = detail ? getClimateDrivers(detail) : [];
|
||||||
|
const scenery = getCityScenery(detail?.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={clsx("detail-panel", isVisible && "visible")}
|
||||||
|
aria-hidden={!isVisible}
|
||||||
|
>
|
||||||
|
<div className="panel-header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="panel-close"
|
||||||
|
aria-label="关闭城市详情面板"
|
||||||
|
onClick={store.closePanel}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<div className="panel-title-area">
|
||||||
|
<h2>{detail?.display_name?.toUpperCase() || "—"}</h2>
|
||||||
|
<div className="panel-meta">
|
||||||
|
<span className={clsx("risk-badge", detail?.risk?.level || "low")}>
|
||||||
|
{getRiskBadgeLabel(detail?.risk?.level)}
|
||||||
|
</span>
|
||||||
|
<span className="local-time">
|
||||||
|
{detail
|
||||||
|
? `${detail.local_date} ${detail.local_time}`
|
||||||
|
: "等待选择城市"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="history-btn"
|
||||||
|
title="查看今日日内分析"
|
||||||
|
onClick={store.openTodayModal}
|
||||||
|
disabled={!detail}
|
||||||
|
>
|
||||||
|
今日日内分析
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="history-btn"
|
||||||
|
title="查看历史对账"
|
||||||
|
onClick={() => void store.openHistory()}
|
||||||
|
disabled={!detail}
|
||||||
|
>
|
||||||
|
历史对账
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel-body">
|
||||||
|
{!detail ? (
|
||||||
|
<section>
|
||||||
|
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
|
||||||
|
{store.loadingState.cityDetail
|
||||||
|
? "正在加载城市详情..."
|
||||||
|
: "从左侧城市列表选择一个城市查看详情。"}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="detail-scenery-card">
|
||||||
|
{scenery ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
className="detail-scenery-image"
|
||||||
|
src={scenery.imageUrl}
|
||||||
|
alt={`${detail.display_name} 风景照`}
|
||||||
|
/>
|
||||||
|
<div className="detail-scenery-overlay">
|
||||||
|
<div className="detail-scenery-copy">
|
||||||
|
<span className="detail-scenery-kicker">
|
||||||
|
{detail.display_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
className="detail-scenery-credit"
|
||||||
|
href={scenery.creditUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{scenery.creditLabel}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="detail-scenery-fallback">
|
||||||
|
<span className="detail-scenery-kicker">
|
||||||
|
{detail.display_name}
|
||||||
|
</span>
|
||||||
|
<strong className="detail-scenery-title">
|
||||||
|
城市风景与微气候
|
||||||
|
</strong>
|
||||||
|
<span className="detail-scenery-subtitle">
|
||||||
|
当前没有匹配到风景图,仍可从下方档案与风险说明查看城市特征。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="detail-section">
|
||||||
|
<h3>城市档案</h3>
|
||||||
|
<div className="detail-grid">
|
||||||
|
{profileStats.map((item) => (
|
||||||
|
<div key={item.label} className="detail-card">
|
||||||
|
<span className="detail-label">{item.label}</span>
|
||||||
|
<span className="detail-value">{item.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="detail-section">
|
||||||
|
<h3>结算与偏差风险</h3>
|
||||||
|
<div className="risk-info">
|
||||||
|
{riskLines.map((line) => (
|
||||||
|
<div key={line} className="risk-row">
|
||||||
|
<span style={{ color: "var(--accent-cyan)", opacity: 0.6 }}>
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
<span>{line}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="detail-section">
|
||||||
|
<h3>当地气候主要受什么影响</h3>
|
||||||
|
<div className="insight-list">
|
||||||
|
{climateDrivers.map((driver) => (
|
||||||
|
<div key={driver.label} className="insight-item">
|
||||||
|
<div className="insight-title">{driver.label}</div>
|
||||||
|
<div className="insight-text">{driver.text}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<ForecastTable />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChartConfiguration } from "chart.js/auto";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { CSSProperties } from "react";
|
||||||
|
import { useChart } from "@/hooks/useChart";
|
||||||
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
|
import {
|
||||||
|
ModelForecast,
|
||||||
|
ProbabilityDistribution,
|
||||||
|
} from "@/components/dashboard/PanelSections";
|
||||||
|
import {
|
||||||
|
getFutureModalView,
|
||||||
|
getShortTermNowcastLines,
|
||||||
|
getTemperatureChartData,
|
||||||
|
getWeatherSummary,
|
||||||
|
parseAiAnalysis,
|
||||||
|
} from "@/lib/dashboard-utils";
|
||||||
|
|
||||||
|
function getConfidenceLabel(confidence: string) {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
high: "高",
|
||||||
|
medium: "中",
|
||||||
|
low: "低",
|
||||||
|
}[confidence] || confidence
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DailyTemperatureChart({ dateStr }: { dateStr: string }) {
|
||||||
|
const store = useDashboardStore();
|
||||||
|
const detail = store.selectedDetail;
|
||||||
|
const view = detail ? getFutureModalView(detail, dateStr) : null;
|
||||||
|
const isToday = detail ? dateStr === detail.local_date : false;
|
||||||
|
const todayChartData = detail && isToday ? getTemperatureChartData(detail) : null;
|
||||||
|
|
||||||
|
const canvasRef = useChart(
|
||||||
|
() => {
|
||||||
|
if (!detail || !view) {
|
||||||
|
return {
|
||||||
|
data: { datasets: [], labels: [] },
|
||||||
|
type: "line",
|
||||||
|
} satisfies ChartConfiguration<"line">;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isToday && todayChartData) {
|
||||||
|
const datasets: NonNullable<ChartConfiguration<"line">["data"]>["datasets"] = [];
|
||||||
|
|
||||||
|
if (todayChartData.datasets.hasMgmHourly) {
|
||||||
|
datasets.push({
|
||||||
|
backgroundColor: "rgba(234, 179, 8, 0.05)",
|
||||||
|
borderColor: "rgba(234, 179, 8, 0.8)",
|
||||||
|
borderWidth: 2,
|
||||||
|
data: todayChartData.datasets.mgmHourlyPoints,
|
||||||
|
fill: false,
|
||||||
|
label: "MGM 预报",
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
pointRadius: 3,
|
||||||
|
spanGaps: true,
|
||||||
|
tension: 0.3,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
datasets.push({
|
||||||
|
backgroundColor: "rgba(52, 211, 153, 0.05)",
|
||||||
|
borderColor: "rgba(52, 211, 153, 0.6)",
|
||||||
|
borderWidth: 1.5,
|
||||||
|
data: todayChartData.datasets.debPast,
|
||||||
|
fill: true,
|
||||||
|
label: "DEB 预报",
|
||||||
|
pointHoverRadius: 3,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.3,
|
||||||
|
});
|
||||||
|
datasets.push({
|
||||||
|
borderColor: "rgba(52, 211, 153, 0.35)",
|
||||||
|
borderDash: [5, 3],
|
||||||
|
borderWidth: 1.5,
|
||||||
|
data: todayChartData.datasets.debFuture,
|
||||||
|
fill: false,
|
||||||
|
label: "DEB 预报",
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
datasets.push({
|
||||||
|
backgroundColor: "#22d3ee",
|
||||||
|
borderColor: "#22d3ee",
|
||||||
|
borderWidth: 0,
|
||||||
|
data: todayChartData.datasets.metarPoints,
|
||||||
|
fill: false,
|
||||||
|
label: "METAR 实测",
|
||||||
|
order: 0,
|
||||||
|
pointHoverRadius: 7,
|
||||||
|
pointRadius: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (todayChartData.datasets.mgmPoints.some((value) => value != null)) {
|
||||||
|
datasets.push({
|
||||||
|
backgroundColor: "#facc15",
|
||||||
|
borderColor: "#facc15",
|
||||||
|
borderWidth: 0,
|
||||||
|
data: todayChartData.datasets.mgmPoints,
|
||||||
|
fill: false,
|
||||||
|
label: "MGM 实测",
|
||||||
|
order: -1,
|
||||||
|
pointHoverRadius: 9,
|
||||||
|
pointRadius: 7,
|
||||||
|
showLine: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!todayChartData.datasets.hasMgmHourly &&
|
||||||
|
Math.abs(todayChartData.datasets.offset) > 0.3
|
||||||
|
) {
|
||||||
|
datasets.push({
|
||||||
|
borderColor: "rgba(99, 102, 241, 0.2)",
|
||||||
|
borderDash: [2, 4],
|
||||||
|
borderWidth: 1,
|
||||||
|
data: todayChartData.datasets.temps,
|
||||||
|
fill: false,
|
||||||
|
label: "OM 原始",
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
datasets,
|
||||||
|
labels: todayChartData.times,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
interaction: { intersect: false, mode: "index" },
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
color: "#94a3b8",
|
||||||
|
filter: (legendItem, chartData) => {
|
||||||
|
const text = String(legendItem.text || "");
|
||||||
|
if (!text) return false;
|
||||||
|
if (!text.includes("DEB")) return true;
|
||||||
|
|
||||||
|
const firstDebIndex = (chartData.datasets || []).findIndex((dataset) =>
|
||||||
|
String(dataset.label || "").includes("DEB"),
|
||||||
|
);
|
||||||
|
return legendItem.datasetIndex === firstDebIndex;
|
||||||
|
},
|
||||||
|
font: { family: "Inter", size: 11 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: "rgba(15, 23, 42, 0.96)",
|
||||||
|
borderColor: "rgba(34, 211, 238, 0.2)",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responsive: true,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: "rgba(255,255,255,0.04)" },
|
||||||
|
ticks: {
|
||||||
|
callback: (_value, index) =>
|
||||||
|
typeof index === "number" && index % 3 === 0
|
||||||
|
? todayChartData.times[index]
|
||||||
|
: "",
|
||||||
|
color: "#64748b",
|
||||||
|
font: { family: "Inter", size: 10 },
|
||||||
|
maxRotation: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: "rgba(255,255,255,0.04)" },
|
||||||
|
max: todayChartData.max,
|
||||||
|
min: todayChartData.min,
|
||||||
|
ticks: {
|
||||||
|
callback: (value) => `${value}${detail.temp_symbol || "°C"}`,
|
||||||
|
color: "#64748b",
|
||||||
|
font: { family: "Inter", size: 10 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "line",
|
||||||
|
} satisfies ChartConfiguration<"line">;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = view.slice.map((point) => point.label);
|
||||||
|
const unit = detail.temp_symbol || "°C";
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
backgroundColor: "rgba(34, 211, 238, 0.08)",
|
||||||
|
borderColor: "#22d3ee",
|
||||||
|
data: view.slice.map((point) => point.temp),
|
||||||
|
fill: false,
|
||||||
|
label: "Open-Meteo 温度",
|
||||||
|
pointRadius: 2,
|
||||||
|
tension: 0.28,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
borderColor: "#a78bfa",
|
||||||
|
borderDash: [5, 4],
|
||||||
|
data: view.slice.map((point) => point.dewPoint),
|
||||||
|
fill: false,
|
||||||
|
label: "露点",
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.24,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
labels,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
interaction: { intersect: false, mode: "index" },
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
color: "#94a3b8",
|
||||||
|
font: { family: "Inter", size: 11 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: "rgba(15, 23, 42, 0.96)",
|
||||||
|
borderColor: "rgba(34, 211, 238, 0.2)",
|
||||||
|
borderWidth: 1,
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) =>
|
||||||
|
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${unit}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responsive: true,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: "rgba(255,255,255,0.04)" },
|
||||||
|
ticks: {
|
||||||
|
color: "#64748b",
|
||||||
|
font: { family: "Inter", size: 10 },
|
||||||
|
maxRotation: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: "rgba(255,255,255,0.04)" },
|
||||||
|
ticks: {
|
||||||
|
callback: (value) => `${value}${unit}`,
|
||||||
|
color: "#64748b",
|
||||||
|
font: { family: "Inter", size: 10 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "line",
|
||||||
|
} satisfies ChartConfiguration<"line">;
|
||||||
|
},
|
||||||
|
[detail, isToday, todayChartData, view],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="history-chart-wrapper future-chart-wrapper">
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
</div>
|
||||||
|
{isToday && (
|
||||||
|
<div className="chart-legend">
|
||||||
|
{todayChartData?.legendText || "暂无机场报文或小时级实测数据"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FutureForecastModal() {
|
||||||
|
const store = useDashboardStore();
|
||||||
|
const detail = store.selectedDetail;
|
||||||
|
const dateStr = store.futureModalDate;
|
||||||
|
|
||||||
|
if (!detail || !dateStr) return null;
|
||||||
|
|
||||||
|
const isToday = dateStr === detail.local_date;
|
||||||
|
const view = getFutureModalView(detail, dateStr);
|
||||||
|
const nowcastRows = getShortTermNowcastLines(detail, dateStr);
|
||||||
|
const ai = parseAiAnalysis(detail.ai_analysis);
|
||||||
|
const scorePosition = `${50 + view.front.score / 2}%`;
|
||||||
|
const barStyle = {
|
||||||
|
"--score-position": scorePosition,
|
||||||
|
} as CSSProperties & { "--score-position": string };
|
||||||
|
const weatherSummary = getWeatherSummary(detail);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="future-modal-title"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
store.closeFutureModal();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="modal-content large future-modal">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2 id="future-modal-title">
|
||||||
|
{isToday
|
||||||
|
? `${detail.display_name.toUpperCase()} · 今日日内分析`
|
||||||
|
: `${detail.display_name.toUpperCase()} · ${dateStr} 未来日期分析`}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-close"
|
||||||
|
aria-label={isToday ? "关闭今日日内分析" : "关闭未来日期分析"}
|
||||||
|
onClick={store.closeFutureModal}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body future-modal-body">
|
||||||
|
<div className="history-stats">
|
||||||
|
{isToday && (
|
||||||
|
<>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">当前实测</span>
|
||||||
|
<span className="val">
|
||||||
|
{detail.current?.temp ?? "--"}
|
||||||
|
{detail.temp_symbol} @{detail.current?.obs_time || "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">当前天气</span>
|
||||||
|
<span className="val">
|
||||||
|
{weatherSummary.weatherIcon} {weatherSummary.weatherText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">WU 结算参考</span>
|
||||||
|
<span className="val">
|
||||||
|
{detail.current?.wu_settlement ?? "--"}
|
||||||
|
{detail.temp_symbol}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">日出时间</span>
|
||||||
|
<span className="val">{detail.forecast?.sunrise || "--"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">日落时间</span>
|
||||||
|
<span className="val">{detail.forecast?.sunset || "--"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">日照时长</span>
|
||||||
|
<span className="val">
|
||||||
|
{detail.forecast?.sunshine_hours != null
|
||||||
|
? `${detail.forecast.sunshine_hours}h`
|
||||||
|
: "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">{isToday ? "今日预报高温" : "目标日预报"}</span>
|
||||||
|
<span className="val">
|
||||||
|
{view.forecastEntry?.max_temp ?? "--"}
|
||||||
|
{detail.temp_symbol}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">DEB 预测</span>
|
||||||
|
<span className="val">
|
||||||
|
{view.deb ?? "--"}
|
||||||
|
{detail.temp_symbol}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">动态分布中心</span>
|
||||||
|
<span className="val">
|
||||||
|
{view.mu != null ? `${view.mu.toFixed(1)}${detail.temp_symbol}` : "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">趋势评分</span>
|
||||||
|
<span className="val">
|
||||||
|
{view.front.score > 0 ? "+" : ""}
|
||||||
|
{view.front.score}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="future-modal-section">
|
||||||
|
<h3>{isToday ? "今日温度走势" : "目标日小时走势"}</h3>
|
||||||
|
<DailyTemperatureChart dateStr={dateStr} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="future-modal-grid">
|
||||||
|
<section className="future-modal-section">
|
||||||
|
<h3>结算概率分布</h3>
|
||||||
|
<ProbabilityDistribution detail={detail} targetDate={dateStr} hideTitle />
|
||||||
|
</section>
|
||||||
|
<section className="future-modal-section">
|
||||||
|
<h3>多模型预报</h3>
|
||||||
|
<ModelForecast detail={detail} targetDate={dateStr} hideTitle />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="future-modal-grid">
|
||||||
|
<section className="future-modal-section">
|
||||||
|
<h3>
|
||||||
|
<span className="section-inline-icon" aria-hidden="true">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.9"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M4 19V5" />
|
||||||
|
<path d="M10 19V10" />
|
||||||
|
<path d="M16 19V7" />
|
||||||
|
<path d="M22 19V13" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{isToday ? "今日日内结构信号" : "未来 6-48 小时趋势"}
|
||||||
|
</h3>
|
||||||
|
<div className="future-front-score">
|
||||||
|
<div className="future-front-bar" style={barStyle} />
|
||||||
|
<div className="future-front-meta">
|
||||||
|
<span className="future-front-pill">判断: {view.front.label}</span>
|
||||||
|
<span className="future-front-pill">
|
||||||
|
置信度: {getConfidenceLabel(view.front.confidence)}
|
||||||
|
</span>
|
||||||
|
<span className="future-front-pill">
|
||||||
|
最大降水概率: {Math.round(view.front.precipMax)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="future-text-block">{view.front.summary}</div>
|
||||||
|
</div>
|
||||||
|
<div className="future-trend-grid">
|
||||||
|
{view.front.metrics.map((metric) => (
|
||||||
|
<div key={metric.label} className="future-trend-card">
|
||||||
|
<div className="future-trend-label">{metric.label}</div>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"future-trend-value",
|
||||||
|
metric.tone === "warm" && "warm",
|
||||||
|
metric.tone === "cold" && "cold",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{metric.value}
|
||||||
|
</div>
|
||||||
|
<div className="future-trend-note">{metric.note}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="future-modal-section">
|
||||||
|
<h3>AI 深度分析</h3>
|
||||||
|
<div className="future-text-block">
|
||||||
|
{ai.summary ? <div>{ai.summary}</div> : null}
|
||||||
|
|
||||||
|
{ai.bullets.length > 0 && (
|
||||||
|
<div style={{ marginTop: ai.summary ? "10px" : 0 }}>
|
||||||
|
{ai.bullets.map((item) => (
|
||||||
|
<div key={item}>{item}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!ai.summary && ai.bullets.length === 0 && (
|
||||||
|
<div>暂无 AI 分析,当前以结构化气象与模型数据为主。</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: "14px" }}>
|
||||||
|
{nowcastRows.map(([label, value]) => (
|
||||||
|
<div key={label}>
|
||||||
|
<strong>{label}: </strong>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view.front.weatherGovPeriods.length > 0 && (
|
||||||
|
<div style={{ marginTop: "10px" }}>
|
||||||
|
<strong>weather.gov 文本: </strong>
|
||||||
|
{view.front.weatherGovPeriods
|
||||||
|
.map((period) => period.short_forecast || period.detailed_forecast)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
|
|
||||||
|
const GUIDE_CARDS = [
|
||||||
|
{
|
||||||
|
body: "Dynamic Ensemble Blending 是系统的核心预测层。它不是对 ECMWF、GFS、ICON、GEM、JMA 等模型的简单平均,而是结合近期样本表现、当前实况与城市偏置后得到的动态加权结果。",
|
||||||
|
title: "DEB 动态融合预测",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: "右侧的结算概率分布基于 DEB 预测值与多模型离散度动态计算。μ 代表当前分布中心,会随着模型、实况和时间变化而变化,不是固定结算值。",
|
||||||
|
title: "结算概率引擎",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: "Polymarket 结算逻辑以机场 METAR 为主。系统优先使用 Aviation Weather API 的机场报文与原始 METAR,并区分观测时间与接收时间,避免把发布延迟误认为温度变化。",
|
||||||
|
title: "结算点与主观测源",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: "Ankara 不走通用城市逻辑。结算主站以 LTAC / Esenboğa 为准,周边领先信号优先参考 Turkish MGM 站网,其中 Ankara (Bölge/Center) 是重点监控站,不用 Etimesgut 代替。",
|
||||||
|
title: "Ankara 专属增强",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: "点击多日预报后的模态框,主要用于分析下一个交易日。6-48 小时趋势以 weather.gov 和 Open-Meteo 为主,部分城市补充 Meteoblue;0-2 小时临近判断优先看 METAR 与周边站。",
|
||||||
|
title: "未来日期分析",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: "历史准确率对账只统计已结算样本。网页端采用近 15 天滚动视图,不把当天尚未结算的样本算入胜率和 MAE。",
|
||||||
|
title: "历史对账规则",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function GuideModal() {
|
||||||
|
const store = useDashboardStore();
|
||||||
|
|
||||||
|
if (!store.isGuideOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="guide-modal-title"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
store.closeGuide();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="modal-content large">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2 id="guide-modal-title">📎 PolyWeather 系统技术说明</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-close"
|
||||||
|
aria-label="关闭技术说明"
|
||||||
|
onClick={store.closeGuide}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="guide-grid">
|
||||||
|
{GUIDE_CARDS.map((card) => (
|
||||||
|
<div key={card.title} className="guide-card">
|
||||||
|
<h3>{card.title}</h3>
|
||||||
|
<p>{card.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="guide-footer">
|
||||||
|
数据源以 Aviation Weather / METAR、Turkish MGM、Open-Meteo、weather.gov
|
||||||
|
为主,部分城市补充 Meteoblue。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
|
|
||||||
|
export function HeaderBar() {
|
||||||
|
const store = useDashboardStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="header">
|
||||||
|
<div className="brand">
|
||||||
|
<h1>PolyWeather</h1>
|
||||||
|
<span className="subtitle">天气衍生品智能分析</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="info-btn"
|
||||||
|
title="查看系统技术说明"
|
||||||
|
aria-label="查看系统技术说明"
|
||||||
|
onClick={store.openGuide}
|
||||||
|
>
|
||||||
|
技术说明
|
||||||
|
</button>
|
||||||
|
<div className="live-badge" id="liveBadge">
|
||||||
|
<span className="pulse-dot" />
|
||||||
|
<span>实时</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={clsx("refresh-btn", store.loadingState.refresh && "spinning")}
|
||||||
|
title="刷新所有数据"
|
||||||
|
aria-label="刷新所有数据"
|
||||||
|
onClick={() => void store.refreshAll()}
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChartConfiguration } from "chart.js/auto";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useChart } from "@/hooks/useChart";
|
||||||
|
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
|
||||||
|
import { getHistorySummary } from "@/lib/dashboard-utils";
|
||||||
|
|
||||||
|
function HistoryChart() {
|
||||||
|
const store = useDashboardStore();
|
||||||
|
const { data } = useHistoryData();
|
||||||
|
const summary = useMemo(
|
||||||
|
() => getHistorySummary(data, store.selectedDetail?.local_date),
|
||||||
|
[data, store.selectedDetail?.local_date],
|
||||||
|
);
|
||||||
|
const hasMgm =
|
||||||
|
store.selectedCity === "ankara" &&
|
||||||
|
summary.mgms.some((value) => value != null);
|
||||||
|
|
||||||
|
const canvasRef = useChart(
|
||||||
|
() => {
|
||||||
|
const datasets: NonNullable<ChartConfiguration<"line">["data"]>["datasets"] = [
|
||||||
|
{
|
||||||
|
backgroundColor: "rgba(248, 113, 113, 0.1)",
|
||||||
|
borderColor: "#f87171",
|
||||||
|
borderWidth: 2,
|
||||||
|
data: summary.actuals,
|
||||||
|
label: "实测最高温",
|
||||||
|
pointBackgroundColor: "#f87171",
|
||||||
|
pointBorderColor: "#fff",
|
||||||
|
pointRadius: 4,
|
||||||
|
tension: 0.2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
borderColor: "#34d399",
|
||||||
|
borderDash: [5, 4],
|
||||||
|
borderWidth: 2,
|
||||||
|
data: summary.debs,
|
||||||
|
label: "DEB 融合",
|
||||||
|
pointRadius: 3,
|
||||||
|
tension: 0.2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (hasMgm) {
|
||||||
|
datasets.push({
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
borderColor: "#fb923c",
|
||||||
|
borderWidth: 2,
|
||||||
|
data: summary.mgms,
|
||||||
|
label: "MGM 官方预报",
|
||||||
|
pointRadius: 3,
|
||||||
|
tension: 0.2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
datasets,
|
||||||
|
labels: summary.dates,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
interaction: { intersect: false, mode: "index" },
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
color: "#94a3b8",
|
||||||
|
font: { family: "Inter", size: 12 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: "rgba(15, 23, 42, 0.9)",
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||||
|
borderWidth: 1,
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responsive: true,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: "rgba(255,255,255,0.04)" },
|
||||||
|
ticks: { color: "#64748b", font: { family: "Inter", size: 10 } },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: "rgba(255,255,255,0.04)" },
|
||||||
|
ticks: { color: "#64748b", font: { family: "Inter", size: 10 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "line",
|
||||||
|
} satisfies ChartConfiguration<"line">;
|
||||||
|
},
|
||||||
|
[hasMgm, summary],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!summary.recentData.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="history-chart-wrapper">
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistoryModal() {
|
||||||
|
const store = useDashboardStore();
|
||||||
|
const { data, error, isLoading, isOpen } = useHistoryData();
|
||||||
|
const summary = useMemo(
|
||||||
|
() => getHistorySummary(data, store.selectedDetail?.local_date),
|
||||||
|
[data, store.selectedDetail?.local_date],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="history-modal-title"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
store.closeHistory();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2 id="history-modal-title">
|
||||||
|
📊 历史准确率对账 - {store.selectedCity?.toUpperCase()}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-close"
|
||||||
|
aria-label="关闭历史对账"
|
||||||
|
onClick={store.closeHistory}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="history-stats">
|
||||||
|
{isLoading ? (
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>正在获取历史数据...</span>
|
||||||
|
) : error ? (
|
||||||
|
<span style={{ color: "var(--accent-red)" }}>获取历史信息失败</span>
|
||||||
|
) : !summary.recentData.length ? (
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>近 15 天暂无该城市历史数据</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">DEB 结算胜率 (WU)</span>
|
||||||
|
<span className="val">
|
||||||
|
{summary.hitRate != null ? `${summary.hitRate}%` : "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">DEB MAE</span>
|
||||||
|
<span className="val">
|
||||||
|
{summary.debMae != null ? `${summary.debMae}°` : "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-stat-card">
|
||||||
|
<span className="label">近 15 天已结算样本</span>
|
||||||
|
<span className="val">{summary.settledCount} 天</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isLoading && !error && <HistoryChart />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
|
import { useLeafletMap } from "@/hooks/useLeafletMap";
|
||||||
|
|
||||||
|
export function MapCanvas() {
|
||||||
|
const store = useDashboardStore();
|
||||||
|
const { containerRef } = useLeafletMap({
|
||||||
|
cities: store.cities,
|
||||||
|
cityDetailsByName: store.cityDetailsByName,
|
||||||
|
citySummariesByName: store.citySummariesByName,
|
||||||
|
onClosePanel: store.closePanel,
|
||||||
|
onEnsureCityDetail: store.ensureCityDetail,
|
||||||
|
onRegisterStopMotion: store.registerMapStopMotion,
|
||||||
|
onSelectCity: (cityName) => {
|
||||||
|
void store.selectCity(cityName);
|
||||||
|
},
|
||||||
|
selectedCity: store.selectedCity,
|
||||||
|
selectedDetail: store.selectedDetail,
|
||||||
|
suspendMotion:
|
||||||
|
Boolean(store.futureModalDate) ||
|
||||||
|
store.historyState.isOpen ||
|
||||||
|
store.isGuideOpen,
|
||||||
|
isLoadingDetail: store.loadingState.cityDetail,
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div ref={containerRef} className="map" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChartConfiguration } from "chart.js/auto";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { useChart } from "@/hooks/useChart";
|
||||||
|
import { useCityData, useDashboardStore } from "@/hooks/useDashboardStore";
|
||||||
|
import { CityDetail } from "@/lib/dashboard-types";
|
||||||
|
import {
|
||||||
|
getHeroMetaItems,
|
||||||
|
getModelView,
|
||||||
|
getProbabilityView,
|
||||||
|
getTemperatureChartData,
|
||||||
|
getWeatherSummary,
|
||||||
|
parseAiAnalysis,
|
||||||
|
} from "@/lib/dashboard-utils";
|
||||||
|
|
||||||
|
function EmptyState({ text }: { text: string }) {
|
||||||
|
return <div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeroSummary() {
|
||||||
|
const { data } = useCityData();
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const { weatherIcon, weatherText } = getWeatherSummary(data);
|
||||||
|
const metaItems = getHeroMetaItems(data);
|
||||||
|
const current = data.current || {};
|
||||||
|
const isMax =
|
||||||
|
current.max_so_far != null &&
|
||||||
|
current.temp != null &&
|
||||||
|
current.max_so_far <= current.temp;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="hero-section">
|
||||||
|
<div className="hero-weather">
|
||||||
|
<span>
|
||||||
|
{weatherIcon} {weatherText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="hero-temp">
|
||||||
|
<span className="hero-value">
|
||||||
|
{current.temp != null ? current.temp.toFixed(1) : "--"}
|
||||||
|
</span>
|
||||||
|
<span className="hero-unit">{data.temp_symbol || "°C"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="hero-max-time">
|
||||||
|
{isMax && current.max_temp_time
|
||||||
|
? `该城市今日最高温出现在当地时间 ${current.max_temp_time}`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
<div className="hero-details">
|
||||||
|
<div className="hero-item">
|
||||||
|
<span className="label">当前实测</span>
|
||||||
|
<span className="value">
|
||||||
|
{current.temp != null
|
||||||
|
? `${current.temp}${data.temp_symbol} @${current.obs_time || "--"}`
|
||||||
|
: "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="hero-item">
|
||||||
|
<span className="label">WU 结算参考</span>
|
||||||
|
<span className="value highlight">
|
||||||
|
{current.wu_settlement != null
|
||||||
|
? `${current.wu_settlement}${data.temp_symbol}`
|
||||||
|
: "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="hero-item">
|
||||||
|
<span className="label">DEB 预测</span>
|
||||||
|
<span className="value">
|
||||||
|
{data.deb?.prediction != null
|
||||||
|
? `${data.deb.prediction}${data.temp_symbol}`
|
||||||
|
: "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-sub">
|
||||||
|
{metaItems.map((item) => (
|
||||||
|
<span key={item}>{item}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TemperatureChart() {
|
||||||
|
const { data } = useCityData();
|
||||||
|
const chartData = data ? getTemperatureChartData(data) : null;
|
||||||
|
|
||||||
|
const canvasRef = useChart(
|
||||||
|
() => {
|
||||||
|
if (!data || !chartData) {
|
||||||
|
return {
|
||||||
|
data: { datasets: [], labels: [] },
|
||||||
|
type: "line",
|
||||||
|
} satisfies ChartConfiguration<"line">;
|
||||||
|
}
|
||||||
|
|
||||||
|
const datasets: NonNullable<ChartConfiguration<"line">["data"]>["datasets"] = [];
|
||||||
|
|
||||||
|
if (chartData.datasets.hasMgmHourly) {
|
||||||
|
datasets.push({
|
||||||
|
backgroundColor: "rgba(234, 179, 8, 0.05)",
|
||||||
|
borderColor: "rgba(234, 179, 8, 0.8)",
|
||||||
|
borderWidth: 2,
|
||||||
|
data: chartData.datasets.mgmHourlyPoints,
|
||||||
|
fill: false,
|
||||||
|
label: "MGM 预报",
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
pointRadius: 3,
|
||||||
|
spanGaps: true,
|
||||||
|
tension: 0.3,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
datasets.push({
|
||||||
|
backgroundColor: "rgba(52, 211, 153, 0.05)",
|
||||||
|
borderColor: "rgba(52, 211, 153, 0.6)",
|
||||||
|
borderWidth: 1.5,
|
||||||
|
data: chartData.datasets.debPast,
|
||||||
|
fill: true,
|
||||||
|
label: "DEB 预报",
|
||||||
|
pointHoverRadius: 3,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.3,
|
||||||
|
});
|
||||||
|
datasets.push({
|
||||||
|
borderColor: "rgba(52, 211, 153, 0.35)",
|
||||||
|
borderDash: [5, 3],
|
||||||
|
borderWidth: 1.5,
|
||||||
|
data: chartData.datasets.debFuture,
|
||||||
|
fill: false,
|
||||||
|
label: "DEB 预报",
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
datasets.push({
|
||||||
|
backgroundColor: "#22d3ee",
|
||||||
|
borderColor: "#22d3ee",
|
||||||
|
borderWidth: 0,
|
||||||
|
data: chartData.datasets.metarPoints,
|
||||||
|
fill: false,
|
||||||
|
label: "METAR 实测",
|
||||||
|
order: 0,
|
||||||
|
pointHoverRadius: 7,
|
||||||
|
pointRadius: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (chartData.datasets.mgmPoints.some((value) => value != null)) {
|
||||||
|
datasets.push({
|
||||||
|
backgroundColor: "#facc15",
|
||||||
|
borderColor: "#facc15",
|
||||||
|
borderWidth: 0,
|
||||||
|
data: chartData.datasets.mgmPoints,
|
||||||
|
fill: false,
|
||||||
|
label: "MGM 实测",
|
||||||
|
order: -1,
|
||||||
|
pointHoverRadius: 9,
|
||||||
|
pointRadius: 7,
|
||||||
|
showLine: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!chartData.datasets.hasMgmHourly &&
|
||||||
|
Math.abs(chartData.datasets.offset) > 0.3
|
||||||
|
) {
|
||||||
|
datasets.push({
|
||||||
|
borderColor: "rgba(99, 102, 241, 0.2)",
|
||||||
|
borderDash: [2, 4],
|
||||||
|
borderWidth: 1,
|
||||||
|
data: chartData.datasets.temps,
|
||||||
|
fill: false,
|
||||||
|
label: "OM 原始",
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
datasets,
|
||||||
|
labels: chartData.times,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
interaction: { intersect: false, mode: "index" },
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: "rgba(15, 23, 42, 0.9)",
|
||||||
|
borderColor: "rgba(52, 211, 153, 0.3)",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responsive: true,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { color: "rgba(255,255,255,0.04)" },
|
||||||
|
ticks: {
|
||||||
|
callback: (_value, index) =>
|
||||||
|
typeof index === "number" && index % 3 === 0
|
||||||
|
? chartData.times[index]
|
||||||
|
: "",
|
||||||
|
color: "#64748b",
|
||||||
|
maxRotation: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: "rgba(255,255,255,0.04)" },
|
||||||
|
max: chartData.max,
|
||||||
|
min: chartData.min,
|
||||||
|
ticks: {
|
||||||
|
callback: (value) => `${value}${data.temp_symbol || "°C"}`,
|
||||||
|
color: "#64748b",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: "line",
|
||||||
|
} satisfies ChartConfiguration<"line">;
|
||||||
|
},
|
||||||
|
[data, chartData],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="chart-section">
|
||||||
|
<h3>今日温度走势</h3>
|
||||||
|
<div className="chart-wrapper">
|
||||||
|
<canvas ref={canvasRef} />
|
||||||
|
</div>
|
||||||
|
<div className="chart-legend">{chartData?.legendText || "暂无小时级数据"}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProbabilityDistribution({
|
||||||
|
detail,
|
||||||
|
hideTitle = false,
|
||||||
|
targetDate,
|
||||||
|
}: {
|
||||||
|
detail: CityDetail;
|
||||||
|
hideTitle?: boolean;
|
||||||
|
targetDate?: string | null;
|
||||||
|
}) {
|
||||||
|
const view = getProbabilityView(detail, targetDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="prob-section">
|
||||||
|
{!hideTitle && <h3>结算概率分布</h3>}
|
||||||
|
<div className="prob-bars">
|
||||||
|
{view.mu != null && (
|
||||||
|
<div
|
||||||
|
style={{ color: "var(--text-muted)", fontSize: "11px", marginBottom: "6px" }}
|
||||||
|
>
|
||||||
|
动态分布中心 μ = {view.mu.toFixed(1)}
|
||||||
|
{detail.temp_symbol}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{view.probabilities.length === 0 ? (
|
||||||
|
<EmptyState text="暂无概率数据" />
|
||||||
|
) : (
|
||||||
|
view.probabilities.slice(0, 6).map((bucket, index) => {
|
||||||
|
const probability = Math.round(Number(bucket.probability || 0) * 100);
|
||||||
|
return (
|
||||||
|
<div key={`${bucket.label || bucket.value || index}`} className="prob-row">
|
||||||
|
<div className="prob-label">
|
||||||
|
{bucket.label || `${bucket.value}${detail.temp_symbol}`}
|
||||||
|
</div>
|
||||||
|
<div className="prob-bar-track">
|
||||||
|
<div
|
||||||
|
className={clsx("prob-bar-fill", `rank-${index}`)}
|
||||||
|
style={{ width: `${Math.max(probability, 8)}%` }}
|
||||||
|
>
|
||||||
|
{probability}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelForecast({
|
||||||
|
detail,
|
||||||
|
hideTitle = false,
|
||||||
|
targetDate,
|
||||||
|
}: {
|
||||||
|
detail: CityDetail;
|
||||||
|
hideTitle?: boolean;
|
||||||
|
targetDate?: string | null;
|
||||||
|
}) {
|
||||||
|
const view = getModelView(detail, targetDate);
|
||||||
|
const modelEntries = Object.entries(view.models).filter(([, value]) =>
|
||||||
|
Number.isFinite(Number(value)),
|
||||||
|
);
|
||||||
|
const numericValues = modelEntries.map(([, value]) => Number(value));
|
||||||
|
const comparisonValues =
|
||||||
|
view.deb != null ? [...numericValues, Number(view.deb)] : numericValues;
|
||||||
|
const minValue = comparisonValues.length ? Math.min(...comparisonValues) - 1 : 0;
|
||||||
|
const maxValue = comparisonValues.length ? Math.max(...comparisonValues) + 1 : 1;
|
||||||
|
const range = Math.max(maxValue - minValue, 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="models-section">
|
||||||
|
{!hideTitle && <h3>多模型预报</h3>}
|
||||||
|
<div className="model-bars">
|
||||||
|
{!modelEntries.length ? (
|
||||||
|
<EmptyState text="暂无多模型预报" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{modelEntries
|
||||||
|
.sort((a, b) => Number(b[1] || 0) - Number(a[1] || 0))
|
||||||
|
.map(([name, value]) => {
|
||||||
|
const numeric = Number(value);
|
||||||
|
const width = ((numeric - minValue) / range) * 100;
|
||||||
|
const debLine =
|
||||||
|
view.deb != null
|
||||||
|
? ((Number(view.deb) - minValue) / range) * 100
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={name} className="model-row">
|
||||||
|
<div className="model-name" title={name}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
<div className="model-bar-track">
|
||||||
|
<div className="model-bar-fill" style={{ width: `${width}%` }}>
|
||||||
|
{numeric}
|
||||||
|
{detail.temp_symbol}
|
||||||
|
</div>
|
||||||
|
{debLine != null && (
|
||||||
|
<div className="model-deb-line" style={{ left: `${debLine}%` }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{view.deb != null && (
|
||||||
|
<div
|
||||||
|
className="model-row"
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid rgba(255,255,255,0.06)",
|
||||||
|
marginTop: "6px",
|
||||||
|
paddingTop: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="model-name"
|
||||||
|
style={{ color: "var(--accent-cyan)", fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
DEB
|
||||||
|
</div>
|
||||||
|
<div className="model-bar-track">
|
||||||
|
<div
|
||||||
|
className="model-bar-fill deb"
|
||||||
|
style={{
|
||||||
|
width: `${((Number(view.deb) - minValue) / range) * 100}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Number(view.deb)}
|
||||||
|
{detail.temp_symbol}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForecastTable() {
|
||||||
|
const store = useDashboardStore();
|
||||||
|
const { data } = useCityData();
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const daily = data.forecast?.daily || [];
|
||||||
|
return (
|
||||||
|
<section className="forecast-section">
|
||||||
|
<h3>多日预报</h3>
|
||||||
|
<div className="forecast-table">
|
||||||
|
{daily.length === 0 ? (
|
||||||
|
<EmptyState text="暂无多日预报" />
|
||||||
|
) : (
|
||||||
|
daily.map((day, index) => {
|
||||||
|
const isToday = day.date === data.local_date || index === 0;
|
||||||
|
const isSelected =
|
||||||
|
store.futureModalDate === day.date ||
|
||||||
|
store.selectedForecastDate === day.date;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day.date}
|
||||||
|
type="button"
|
||||||
|
className={clsx("forecast-day", isToday && "today", isSelected && "selected")}
|
||||||
|
onClick={() => {
|
||||||
|
store.openFutureModal(day.date);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="f-date">
|
||||||
|
{isToday ? "今天" : day.date.substring(5).replace("-", "/")}
|
||||||
|
</div>
|
||||||
|
<div className="f-temp">
|
||||||
|
{day.max_temp}
|
||||||
|
{data.temp_symbol}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiAnalysis() {
|
||||||
|
const { data } = useCityData();
|
||||||
|
if (!data) return null;
|
||||||
|
const ai = parseAiAnalysis(data.ai_analysis);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="ai-section">
|
||||||
|
<h3>AI 深度分析</h3>
|
||||||
|
<div className="ai-box">
|
||||||
|
{!ai.summary && ai.bullets.length === 0 ? (
|
||||||
|
<span className="ai-placeholder">
|
||||||
|
暂无 AI 分析,当前以结构化气象与模型数据为主。
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{ai.summary && <div className="ai-summary">{ai.summary}</div>}
|
||||||
|
{ai.bullets.length > 0 && (
|
||||||
|
<ul className="ai-list">
|
||||||
|
{ai.bullets.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RiskInfo() {
|
||||||
|
const { data } = useCityData();
|
||||||
|
if (!data) return null;
|
||||||
|
const risk = data.risk || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="risk-section">
|
||||||
|
<h3>数据偏差风险</h3>
|
||||||
|
<div className="risk-info">
|
||||||
|
{!risk.airport ? (
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>暂无风险档案</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="risk-row">
|
||||||
|
<span className="risk-label">机场</span>
|
||||||
|
<span>
|
||||||
|
{risk.airport} ({risk.icao})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="risk-row">
|
||||||
|
<span className="risk-label">距离</span>
|
||||||
|
<span>{risk.distance_km}km</span>
|
||||||
|
</div>
|
||||||
|
{risk.warning && (
|
||||||
|
<div className="risk-row">
|
||||||
|
<span className="risk-label">注意</span>
|
||||||
|
<span>{risk.warning}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import styles from "./Dashboard.module.css";
|
||||||
|
import {
|
||||||
|
DashboardStoreProvider,
|
||||||
|
useDashboardStore,
|
||||||
|
} from "@/hooks/useDashboardStore";
|
||||||
|
import { CitySidebar } from "@/components/dashboard/CitySidebar";
|
||||||
|
import { DetailPanel } from "@/components/dashboard/DetailPanel";
|
||||||
|
import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal";
|
||||||
|
import { GuideModal } from "@/components/dashboard/GuideModal";
|
||||||
|
import { HeaderBar } from "@/components/dashboard/HeaderBar";
|
||||||
|
import { HistoryModal } from "@/components/dashboard/HistoryModal";
|
||||||
|
import { MapCanvas } from "@/components/dashboard/MapCanvas";
|
||||||
|
|
||||||
|
function DashboardScreen() {
|
||||||
|
const store = useDashboardStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
if (store.futureModalDate) {
|
||||||
|
store.closeFutureModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (store.historyState.isOpen) {
|
||||||
|
store.closeHistory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (store.isGuideOpen) {
|
||||||
|
store.closeGuide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (store.isPanelOpen) {
|
||||||
|
store.closePanel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
// Avoid full-page flashing on initial load; only show this overlay for manual refresh.
|
||||||
|
const showLoading =
|
||||||
|
store.loadingState.cities ||
|
||||||
|
store.loadingState.cityDetail ||
|
||||||
|
store.loadingState.refresh;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.root}>
|
||||||
|
<MapCanvas />
|
||||||
|
<HeaderBar />
|
||||||
|
<CitySidebar />
|
||||||
|
<DetailPanel />
|
||||||
|
<GuideModal />
|
||||||
|
<HistoryModal />
|
||||||
|
<FutureForecastModal />
|
||||||
|
{showLoading && (
|
||||||
|
<div className="loading-overlay">
|
||||||
|
<div className="loading-spinner" />
|
||||||
|
<span>正在获取气象数据,请稍候...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PolyWeatherDashboard() {
|
||||||
|
return (
|
||||||
|
<DashboardStoreProvider>
|
||||||
|
<DashboardScreen />
|
||||||
|
</DashboardStoreProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Chart, ChartConfiguration, ChartType } from "chart.js/auto";
|
||||||
|
|
||||||
|
export function useChart<TType extends ChartType>(
|
||||||
|
createConfig: () => ChartConfiguration<TType>,
|
||||||
|
dependencies: React.DependencyList,
|
||||||
|
) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const chartRef = useRef<Chart<TType> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const config = createConfig();
|
||||||
|
if (chartRef.current) {
|
||||||
|
chartRef.current.destroy();
|
||||||
|
chartRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
chartRef.current = new Chart(canvas, config);
|
||||||
|
return () => {
|
||||||
|
chartRef.current?.destroy();
|
||||||
|
chartRef.current = null;
|
||||||
|
};
|
||||||
|
}, dependencies);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
chartRef.current?.destroy();
|
||||||
|
chartRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return canvasRef;
|
||||||
|
}
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
dashboardClient,
|
||||||
|
getCityRevision,
|
||||||
|
toCitySummary,
|
||||||
|
} from "@/lib/dashboard-client";
|
||||||
|
import {
|
||||||
|
CityDetail,
|
||||||
|
CityListItem,
|
||||||
|
CitySummary,
|
||||||
|
DashboardState,
|
||||||
|
HistoryPoint,
|
||||||
|
HistoryState,
|
||||||
|
LoadingState,
|
||||||
|
} from "@/lib/dashboard-types";
|
||||||
|
|
||||||
|
interface DashboardStoreValue extends DashboardState {
|
||||||
|
closeFutureModal: () => void;
|
||||||
|
closeGuide: () => void;
|
||||||
|
closeHistory: () => void;
|
||||||
|
closePanel: () => void;
|
||||||
|
ensureCityDetail: (cityName: string, force?: boolean) => Promise<CityDetail>;
|
||||||
|
futureModalDate: string | null;
|
||||||
|
isGuideOpen: boolean;
|
||||||
|
loadCities: () => Promise<void>;
|
||||||
|
openFutureModal: (dateStr: string) => void;
|
||||||
|
openGuide: () => void;
|
||||||
|
openHistory: () => Promise<void>;
|
||||||
|
openTodayModal: () => void;
|
||||||
|
registerMapStopMotion: (stopMotion: () => void) => void;
|
||||||
|
refreshAll: () => Promise<void>;
|
||||||
|
refreshSelectedCity: () => Promise<void>;
|
||||||
|
selectedDetail: CityDetail | null;
|
||||||
|
selectCity: (cityName: string) => Promise<void>;
|
||||||
|
setForecastDate: (dateStr: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardStoreContext = createContext<DashboardStoreValue | null>(null);
|
||||||
|
|
||||||
|
function getInitialLoadingState(): LoadingState {
|
||||||
|
return {
|
||||||
|
cities: false,
|
||||||
|
cityDetail: false,
|
||||||
|
history: false,
|
||||||
|
refresh: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialHistoryState(): HistoryState {
|
||||||
|
return {
|
||||||
|
dataByCity: {},
|
||||||
|
error: null,
|
||||||
|
isOpen: false,
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardStoreProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const initialCache = dashboardClient.readCityDetailCacheBundle();
|
||||||
|
const [cities, setCities] = useState<CityListItem[]>([]);
|
||||||
|
const [cityDetailsByName, setCityDetailsByName] = useState<
|
||||||
|
Record<string, CityDetail>
|
||||||
|
>(() => initialCache.details);
|
||||||
|
const [citySummariesByName, setCitySummariesByName] = useState<
|
||||||
|
Record<string, CitySummary>
|
||||||
|
>(() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(initialCache.details).map(([cityName, detail]) => [
|
||||||
|
cityName,
|
||||||
|
toCitySummary(detail),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
|
||||||
|
Record<string, { cachedAt: number; revision: string }>
|
||||||
|
>(() => initialCache.meta);
|
||||||
|
const [selectedCity, setSelectedCity] = useState<string | null>(null);
|
||||||
|
const [isPanelOpen, setIsPanelOpen] = useState(false);
|
||||||
|
const [selectedForecastDate, setSelectedForecastDate] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [futureModalDate, setFutureModalDate] = useState<string | null>(null);
|
||||||
|
const [loadingState, setLoadingState] = useState<LoadingState>(
|
||||||
|
getInitialLoadingState,
|
||||||
|
);
|
||||||
|
const [historyState, setHistoryState] = useState<HistoryState>(
|
||||||
|
getInitialHistoryState,
|
||||||
|
);
|
||||||
|
const [isGuideOpen, setIsGuideOpen] = useState(false);
|
||||||
|
|
||||||
|
const mapStopMotionRef = useRef<() => void>(() => {});
|
||||||
|
const citySummariesRef = useRef<Record<string, CitySummary>>(
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(initialCache.details).map(([cityName, detail]) => [
|
||||||
|
cityName,
|
||||||
|
toCitySummary(detail),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedDetail = selectedCity
|
||||||
|
? cityDetailsByName[selectedCity] || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dashboardClient.writeCityDetailCacheBundle(
|
||||||
|
cityDetailsByName,
|
||||||
|
cityDetailMetaByName,
|
||||||
|
);
|
||||||
|
}, [cityDetailMetaByName, cityDetailsByName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
citySummariesRef.current = citySummariesByName;
|
||||||
|
}, [citySummariesByName]);
|
||||||
|
|
||||||
|
const ensureCityDetail = async (cityName: string, force = false) => {
|
||||||
|
const cached = cityDetailsByName[cityName];
|
||||||
|
const cachedMeta = cityDetailMetaByName[cityName];
|
||||||
|
if (!force && cached && dashboardClient.isCityDetailFresh(cachedMeta)) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && cached) {
|
||||||
|
try {
|
||||||
|
const summary = await dashboardClient.getCitySummary(cityName);
|
||||||
|
const revision = getCityRevision(summary);
|
||||||
|
if (revision && revision === cachedMeta?.revision) {
|
||||||
|
setCityDetailMetaByName((current) => ({
|
||||||
|
...current,
|
||||||
|
[cityName]: {
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
revision,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = await dashboardClient.getCityDetail(cityName, { force });
|
||||||
|
setCityDetailsByName((current) => ({
|
||||||
|
...current,
|
||||||
|
[cityName]: detail,
|
||||||
|
}));
|
||||||
|
setCitySummariesByName((current) => ({
|
||||||
|
...current,
|
||||||
|
[cityName]: toCitySummary(detail),
|
||||||
|
}));
|
||||||
|
setCityDetailMetaByName((current) => ({
|
||||||
|
...current,
|
||||||
|
[cityName]: {
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
revision: getCityRevision(detail),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return detail;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCities = async () => {
|
||||||
|
setLoadingState((current) => ({ ...current, cities: true }));
|
||||||
|
try {
|
||||||
|
const nextCities = await dashboardClient.getCities();
|
||||||
|
setCities(nextCities);
|
||||||
|
} finally {
|
||||||
|
setLoadingState((current) => ({ ...current, cities: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCities();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cities.length) return;
|
||||||
|
|
||||||
|
const queue = cities
|
||||||
|
.map((city) => city.name)
|
||||||
|
.filter((cityName) => !citySummariesRef.current[cityName]);
|
||||||
|
if (!queue.length) return;
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
const concurrency = 4;
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
const worker = async () => {
|
||||||
|
while (active && cursor < queue.length) {
|
||||||
|
const cityName = queue[cursor];
|
||||||
|
cursor += 1;
|
||||||
|
if (citySummariesRef.current[cityName]) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = await dashboardClient.getCitySummary(cityName);
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
setCitySummariesByName((current) => {
|
||||||
|
if (current[cityName]) return current;
|
||||||
|
const next = {
|
||||||
|
...current,
|
||||||
|
[cityName]: summary,
|
||||||
|
};
|
||||||
|
citySummariesRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void Promise.all(
|
||||||
|
Array.from({ length: Math.min(concurrency, queue.length) }, () =>
|
||||||
|
worker(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [cities]);
|
||||||
|
|
||||||
|
const selectCity = async (cityName: string) => {
|
||||||
|
setSelectedCity(cityName);
|
||||||
|
setIsPanelOpen(true);
|
||||||
|
setSelectedForecastDate(null);
|
||||||
|
setFutureModalDate(null);
|
||||||
|
setLoadingState((current) => ({ ...current, cityDetail: true }));
|
||||||
|
try {
|
||||||
|
const detail = await ensureCityDetail(cityName);
|
||||||
|
setSelectedForecastDate(detail.local_date);
|
||||||
|
} finally {
|
||||||
|
setLoadingState((current) => ({ ...current, cityDetail: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshSelectedCity = async () => {
|
||||||
|
if (!selectedCity) return;
|
||||||
|
setLoadingState((current) => ({ ...current, refresh: true }));
|
||||||
|
try {
|
||||||
|
const detail = await ensureCityDetail(selectedCity, true);
|
||||||
|
setSelectedForecastDate(detail.local_date);
|
||||||
|
} finally {
|
||||||
|
setLoadingState((current) => ({ ...current, refresh: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshAll = async () => {
|
||||||
|
dashboardClient.clearCityDetailCache();
|
||||||
|
setCityDetailsByName({});
|
||||||
|
setCityDetailMetaByName({});
|
||||||
|
if (selectedCity) {
|
||||||
|
setLoadingState((current) => ({ ...current, refresh: true }));
|
||||||
|
try {
|
||||||
|
const detail = await dashboardClient.getCityDetail(selectedCity, {
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
setCityDetailsByName({ [selectedCity]: detail });
|
||||||
|
setCitySummariesByName((current) => ({
|
||||||
|
...current,
|
||||||
|
[selectedCity]: toCitySummary(detail),
|
||||||
|
}));
|
||||||
|
setCityDetailMetaByName({
|
||||||
|
[selectedCity]: {
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
revision: getCityRevision(detail),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setSelectedForecastDate(detail.local_date);
|
||||||
|
} finally {
|
||||||
|
setLoadingState((current) => ({ ...current, refresh: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openHistory = async () => {
|
||||||
|
if (!selectedCity) return;
|
||||||
|
setHistoryState((current) => ({
|
||||||
|
...current,
|
||||||
|
error: null,
|
||||||
|
isOpen: true,
|
||||||
|
loading: true,
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
const history = await dashboardClient.getHistory(selectedCity);
|
||||||
|
setHistoryState((current) => ({
|
||||||
|
...current,
|
||||||
|
dataByCity: {
|
||||||
|
...current.dataByCity,
|
||||||
|
[selectedCity]: history,
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
setHistoryState((current) => ({
|
||||||
|
...current,
|
||||||
|
error: String(error),
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = useMemo<DashboardStoreValue>(
|
||||||
|
() => ({
|
||||||
|
cities,
|
||||||
|
cityDetailsByName,
|
||||||
|
citySummariesByName,
|
||||||
|
closeFutureModal: () => setFutureModalDate(null),
|
||||||
|
closeGuide: () => setIsGuideOpen(false),
|
||||||
|
closeHistory: () =>
|
||||||
|
setHistoryState((current) => ({ ...current, isOpen: false })),
|
||||||
|
closePanel: () => {
|
||||||
|
setIsPanelOpen(false);
|
||||||
|
},
|
||||||
|
ensureCityDetail,
|
||||||
|
futureModalDate,
|
||||||
|
historyState,
|
||||||
|
isPanelOpen,
|
||||||
|
isGuideOpen,
|
||||||
|
loadCities,
|
||||||
|
loadingState,
|
||||||
|
openFutureModal: (dateStr: string) => {
|
||||||
|
mapStopMotionRef.current();
|
||||||
|
setFutureModalDate(dateStr);
|
||||||
|
},
|
||||||
|
openGuide: () => setIsGuideOpen(true),
|
||||||
|
openHistory,
|
||||||
|
openTodayModal: () => {
|
||||||
|
if (selectedDetail?.local_date) {
|
||||||
|
mapStopMotionRef.current();
|
||||||
|
setFutureModalDate(selectedDetail.local_date);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerMapStopMotion: (stopMotion: () => void) => {
|
||||||
|
mapStopMotionRef.current = stopMotion;
|
||||||
|
},
|
||||||
|
refreshAll,
|
||||||
|
refreshSelectedCity,
|
||||||
|
selectedCity,
|
||||||
|
selectedDetail,
|
||||||
|
selectedForecastDate,
|
||||||
|
selectCity,
|
||||||
|
setForecastDate: (dateStr: string | null) =>
|
||||||
|
setSelectedForecastDate(dateStr),
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
cities,
|
||||||
|
cityDetailsByName,
|
||||||
|
citySummariesByName,
|
||||||
|
futureModalDate,
|
||||||
|
historyState,
|
||||||
|
isPanelOpen,
|
||||||
|
isGuideOpen,
|
||||||
|
loadingState,
|
||||||
|
selectedCity,
|
||||||
|
selectedDetail,
|
||||||
|
selectedForecastDate,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardStoreContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</DashboardStoreContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboardStore() {
|
||||||
|
const context = useContext(DashboardStoreContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useDashboardStore must be used within DashboardStoreProvider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCityData(name?: string | null) {
|
||||||
|
const store = useDashboardStore();
|
||||||
|
const key = name || store.selectedCity;
|
||||||
|
return {
|
||||||
|
data: key ? store.cityDetailsByName[key] || null : null,
|
||||||
|
isLoading:
|
||||||
|
store.loadingState.cityDetail &&
|
||||||
|
Boolean(key) &&
|
||||||
|
store.selectedCity === key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHistoryData(name?: string | null) {
|
||||||
|
const store = useDashboardStore();
|
||||||
|
const key = name || store.selectedCity;
|
||||||
|
return {
|
||||||
|
data: key
|
||||||
|
? store.historyState.dataByCity[key] || ([] as HistoryPoint[])
|
||||||
|
: [],
|
||||||
|
error: store.historyState.error,
|
||||||
|
isLoading: store.historyState.loading,
|
||||||
|
isOpen: store.historyState.isOpen,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import L from "leaflet";
|
||||||
|
import {
|
||||||
|
CityDetail,
|
||||||
|
CityListItem,
|
||||||
|
CitySummary,
|
||||||
|
NearbyStation,
|
||||||
|
} from "@/lib/dashboard-types";
|
||||||
|
import { pickAnkaraNearbyStations } from "@/lib/dashboard-utils";
|
||||||
|
|
||||||
|
interface UseLeafletMapArgs {
|
||||||
|
cities: CityListItem[];
|
||||||
|
cityDetailsByName: Record<string, CityDetail>;
|
||||||
|
citySummariesByName: Record<string, CitySummary>;
|
||||||
|
onClosePanel: () => void;
|
||||||
|
onEnsureCityDetail: (
|
||||||
|
cityName: string,
|
||||||
|
force?: boolean,
|
||||||
|
) => Promise<CityDetail>;
|
||||||
|
onRegisterStopMotion: (stopMotion: () => void) => void;
|
||||||
|
onSelectCity: (cityName: string) => void;
|
||||||
|
selectedCity: string | null;
|
||||||
|
selectedDetail: CityDetail | null;
|
||||||
|
suspendMotion: boolean;
|
||||||
|
isLoadingDetail: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTO_NEARBY_MIN_ZOOM = 8;
|
||||||
|
const AUTO_NEARBY_MAX_DISTANCE_M = 120000;
|
||||||
|
const MAP_MAX_ZOOM = 19;
|
||||||
|
|
||||||
|
function createMarkerIcon(
|
||||||
|
city: CityListItem,
|
||||||
|
snapshot?: Pick<CityDetail, "current" | "temp_symbol"> | CitySummary,
|
||||||
|
) {
|
||||||
|
const riskClass = `risk-${city.risk_level}`;
|
||||||
|
const label = city.display_name;
|
||||||
|
const unit = city.temp_unit === "fahrenheit" ? "°F" : "°C";
|
||||||
|
const shortName = label.length > 10 ? `${label.substring(0, 8)}...` : label;
|
||||||
|
const tempText =
|
||||||
|
snapshot?.current?.temp != null ? `${snapshot.current.temp}${unit}` : "--";
|
||||||
|
|
||||||
|
return L.divIcon({
|
||||||
|
className: "",
|
||||||
|
html: `
|
||||||
|
<div class="city-marker" data-city="${city.name}">
|
||||||
|
<div class="marker-bubble ${riskClass}">${tempText}</div>
|
||||||
|
<div class="marker-name">${shortName}</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
iconAnchor: [40, 22],
|
||||||
|
iconSize: [80, 44],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
|
||||||
|
const symbol = detail.temp_symbol || "°C";
|
||||||
|
let windHtml = "";
|
||||||
|
|
||||||
|
if (station.wind_dir != null) {
|
||||||
|
const rotation = (Number(station.wind_dir) + 180) % 360;
|
||||||
|
const speedRaw = Number(station.wind_speed ?? station.wind_speed_kt);
|
||||||
|
const speed = Number.isFinite(speedRaw) ? `${speedRaw.toFixed(1)}k` : "";
|
||||||
|
windHtml = `
|
||||||
|
<div class="nearby-wind">
|
||||||
|
<span class="wind-arrow" style="transform: rotate(${rotation}deg)">↑</span>
|
||||||
|
<span class="wind-val">${speed}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="nearby-marker-premium">
|
||||||
|
<div class="nearby-pulse">
|
||||||
|
<div class="pulse-ring"></div>
|
||||||
|
<div class="pulse-core"></div>
|
||||||
|
</div>
|
||||||
|
<div class="nearby-content">
|
||||||
|
<span class="nearby-label">${station.name || station.icao || "OBS"}</span>
|
||||||
|
<div class="nearby-stats">
|
||||||
|
<span class="nearby-temp-val">${station.temp ?? "--"}</span>
|
||||||
|
<span class="nearby-temp-unit">${symbol}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${windHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLeafletMap({
|
||||||
|
cities,
|
||||||
|
cityDetailsByName,
|
||||||
|
citySummariesByName,
|
||||||
|
onClosePanel,
|
||||||
|
onEnsureCityDetail,
|
||||||
|
onRegisterStopMotion,
|
||||||
|
onSelectCity,
|
||||||
|
selectedCity,
|
||||||
|
selectedDetail,
|
||||||
|
suspendMotion,
|
||||||
|
isLoadingDetail,
|
||||||
|
}: UseLeafletMapArgs) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
const markersRef = useRef<
|
||||||
|
Record<string, { city: CityListItem; marker: L.Marker }>
|
||||||
|
>({});
|
||||||
|
const nearbyLayerRef = useRef<L.LayerGroup | null>(null);
|
||||||
|
const autoNearbyCityRef = useRef<string | null>(null);
|
||||||
|
const loadingAutoNearbyRef = useRef(false);
|
||||||
|
const lastMovedCityRef = useRef<string | null>(null);
|
||||||
|
const suspendMotionRef = useRef(suspendMotion);
|
||||||
|
const hasFittedInitialBoundsRef = useRef(false);
|
||||||
|
const onClosePanelRef = useRef(onClosePanel);
|
||||||
|
const onRegisterStopMotionRef = useRef(onRegisterStopMotion);
|
||||||
|
const onSelectCityRef = useRef(onSelectCity);
|
||||||
|
const onEnsureCityDetailRef = useRef(onEnsureCityDetail);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onClosePanelRef.current = onClosePanel;
|
||||||
|
}, [onClosePanel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onRegisterStopMotionRef.current = onRegisterStopMotion;
|
||||||
|
}, [onRegisterStopMotion]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onSelectCityRef.current = onSelectCity;
|
||||||
|
}, [onSelectCity]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onEnsureCityDetailRef.current = onEnsureCityDetail;
|
||||||
|
}, [onEnsureCityDetail]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
suspendMotionRef.current = suspendMotion;
|
||||||
|
}, [suspendMotion]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container || mapRef.current) return;
|
||||||
|
|
||||||
|
const map = L.map(container, {
|
||||||
|
attributionControl: true,
|
||||||
|
bounceAtZoomLimits: false,
|
||||||
|
center: [30, 10],
|
||||||
|
maxZoom: MAP_MAX_ZOOM,
|
||||||
|
minZoom: 2,
|
||||||
|
zoom: 3,
|
||||||
|
zoomControl: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
L.control.zoom({ position: "bottomright" }).addTo(map);
|
||||||
|
L.tileLayer(
|
||||||
|
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
|
||||||
|
{
|
||||||
|
attribution:
|
||||||
|
'© <a href="https://www.openstreetmap.org/">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
|
maxZoom: 19,
|
||||||
|
subdomains: "abcd",
|
||||||
|
},
|
||||||
|
).addTo(map);
|
||||||
|
|
||||||
|
const nearbyLayer = L.layerGroup().addTo(map);
|
||||||
|
mapRef.current = map;
|
||||||
|
nearbyLayerRef.current = nearbyLayer;
|
||||||
|
|
||||||
|
// Track which city we've already moved to for the current selection
|
||||||
|
onRegisterStopMotionRef.current(() => {
|
||||||
|
map.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMapClick = () => {
|
||||||
|
onClosePanelRef.current();
|
||||||
|
};
|
||||||
|
map.on("click", handleMapClick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
onRegisterStopMotionRef.current(() => {});
|
||||||
|
map.off("click", handleMapClick);
|
||||||
|
map.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
nearbyLayerRef.current = null;
|
||||||
|
markersRef.current = {};
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle initial view if cities are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !cities.length || hasFittedInitialBoundsRef.current) return;
|
||||||
|
|
||||||
|
// Only run fitBounds once for the initial list of cities
|
||||||
|
const bounds = cities.map((city) => [city.lat, city.lon]) as [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
][];
|
||||||
|
if (bounds.length) {
|
||||||
|
map.fitBounds(bounds, {
|
||||||
|
animate: false,
|
||||||
|
maxZoom: 4,
|
||||||
|
padding: [60, 60],
|
||||||
|
});
|
||||||
|
hasFittedInitialBoundsRef.current = true;
|
||||||
|
}
|
||||||
|
}, [cities]);
|
||||||
|
|
||||||
|
const lastCityDataRef = useRef<
|
||||||
|
Record<string, { temp?: number | null; risk?: string }>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
// Handle marker synchronization
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !cities.length) return;
|
||||||
|
|
||||||
|
const currentMarkers = markersRef.current;
|
||||||
|
const nextMarkers: typeof currentMarkers = {};
|
||||||
|
const nextLastData: typeof lastCityDataRef.current = {};
|
||||||
|
|
||||||
|
cities.forEach((city) => {
|
||||||
|
const detail = cityDetailsByName[city.name];
|
||||||
|
const summary = citySummariesByName[city.name];
|
||||||
|
const snapshot = detail || summary;
|
||||||
|
const existing = currentMarkers[city.name];
|
||||||
|
|
||||||
|
const currentTemp = snapshot?.current?.temp;
|
||||||
|
const currentRisk = city.risk_level;
|
||||||
|
const lastData = lastCityDataRef.current[city.name];
|
||||||
|
const dataChanged =
|
||||||
|
!lastData ||
|
||||||
|
lastData.temp !== currentTemp ||
|
||||||
|
lastData.risk !== currentRisk;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (dataChanged) {
|
||||||
|
existing.marker.setIcon(createMarkerIcon(city, snapshot));
|
||||||
|
}
|
||||||
|
nextMarkers[city.name] = { city, marker: existing.marker };
|
||||||
|
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new marker
|
||||||
|
const marker = L.marker([city.lat, city.lon], {
|
||||||
|
icon: createMarkerIcon(city, snapshot),
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
marker.on("click", () => {
|
||||||
|
map.stop();
|
||||||
|
// Reset lastMovedCity so we can re-fly if needed
|
||||||
|
lastMovedCityRef.current = null;
|
||||||
|
onSelectCityRef.current(city.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
nextMarkers[city.name] = { city, marker };
|
||||||
|
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup removed markers
|
||||||
|
Object.entries(currentMarkers).forEach(([name, entry]) => {
|
||||||
|
if (!nextMarkers[name]) {
|
||||||
|
map.removeLayer(entry.marker);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
markersRef.current = nextMarkers;
|
||||||
|
lastCityDataRef.current = nextLastData;
|
||||||
|
}, [cities, cityDetailsByName, citySummariesByName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Object.entries(markersRef.current).forEach(([name, entry]) => {
|
||||||
|
const element = entry.marker.getElement();
|
||||||
|
if (!element) return;
|
||||||
|
const markerRoot = element.querySelector(".city-marker");
|
||||||
|
markerRoot?.classList.toggle("selected", name === selectedCity);
|
||||||
|
});
|
||||||
|
}, [selectedCity]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapRef.current || !nearbyLayerRef.current) return;
|
||||||
|
const map = mapRef.current;
|
||||||
|
const layer = nearbyLayerRef.current;
|
||||||
|
|
||||||
|
function renderNearbyStations(detail: CityDetail, preserveView = false) {
|
||||||
|
layer.clearLayers();
|
||||||
|
|
||||||
|
const allNearby = Array.isArray(detail.mgm_nearby)
|
||||||
|
? detail.mgm_nearby
|
||||||
|
: [];
|
||||||
|
const nearbyStations =
|
||||||
|
String(detail.name || "").toLowerCase() === "ankara"
|
||||||
|
? pickAnkaraNearbyStations(allNearby)
|
||||||
|
: allNearby;
|
||||||
|
|
||||||
|
if (!nearbyStations.length) {
|
||||||
|
if (!preserveView && detail.lat != null && detail.lon != null) {
|
||||||
|
map.flyTo([detail.lat, detail.lon], 10, {
|
||||||
|
animate: true,
|
||||||
|
duration: 1.5,
|
||||||
|
easeLinearity: 0.25,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latLngs: Array<[number, number]> = [];
|
||||||
|
if (detail.lat != null && detail.lon != null) {
|
||||||
|
latLngs.push([detail.lat, detail.lon]);
|
||||||
|
}
|
||||||
|
|
||||||
|
nearbyStations.forEach((station) => {
|
||||||
|
const sLat = Number(station.lat);
|
||||||
|
const sLon = Number(station.lon);
|
||||||
|
// Ignore invalid (0,0) or null coordinates which cause global zoom-out
|
||||||
|
if (!Number.isFinite(sLat) || !Number.isFinite(sLon)) return;
|
||||||
|
if (Math.abs(sLat) < 0.1 && Math.abs(sLon) < 0.1) return;
|
||||||
|
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: "",
|
||||||
|
html: buildNearbyIconHtml(detail, station),
|
||||||
|
iconAnchor: [16, 19],
|
||||||
|
iconSize: [240, 38],
|
||||||
|
});
|
||||||
|
L.marker([sLat, sLon], { icon }).addTo(layer);
|
||||||
|
latLngs.push([sLat, sLon]);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (preserveView) return;
|
||||||
|
|
||||||
|
// Note: Movement for selected cities is now handled by the centralized effect.
|
||||||
|
// This section is primarily for auto-discovery movement if needed.
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeAutoShowNearbyStations() {
|
||||||
|
if (suspendMotion) {
|
||||||
|
map.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedDetail) {
|
||||||
|
// Just render stations, no camera move from here
|
||||||
|
renderNearbyStations(selectedDetail, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no city selected, reset the move tracker
|
||||||
|
lastMovedCityRef.current = null;
|
||||||
|
|
||||||
|
if (map.getZoom() < AUTO_NEARBY_MIN_ZOOM) {
|
||||||
|
autoNearbyCityRef.current = null;
|
||||||
|
layer.clearLayers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = map.getCenter();
|
||||||
|
let best: { cityName: string; distance: number } | null = null;
|
||||||
|
for (const [cityName, entry] of Object.entries(markersRef.current)) {
|
||||||
|
const distance = map.distance(
|
||||||
|
center,
|
||||||
|
L.latLng(entry.city.lat, entry.city.lon),
|
||||||
|
);
|
||||||
|
if (distance > AUTO_NEARBY_MAX_DISTANCE_M) continue;
|
||||||
|
if (!best || distance < best.distance) {
|
||||||
|
best = { cityName, distance };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCity = best?.cityName || null;
|
||||||
|
if (!targetCity) {
|
||||||
|
autoNearbyCityRef.current = null;
|
||||||
|
layer.clearLayers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
autoNearbyCityRef.current === targetCity &&
|
||||||
|
layer.getLayers().length > 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoNearbyCityRef.current = targetCity;
|
||||||
|
const cachedDetail = cityDetailsByName[targetCity];
|
||||||
|
if (cachedDetail) {
|
||||||
|
renderNearbyStations(cachedDetail, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingAutoNearbyRef.current) return;
|
||||||
|
loadingAutoNearbyRef.current = true;
|
||||||
|
try {
|
||||||
|
const detail = await onEnsureCityDetailRef.current(targetCity, false);
|
||||||
|
renderNearbyStations(detail, true);
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
loadingAutoNearbyRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncVisibility = () => {
|
||||||
|
if (suspendMotion) {
|
||||||
|
map.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map.getZoom() < 7) {
|
||||||
|
if (map.hasLayer(layer)) {
|
||||||
|
map.removeLayer(layer);
|
||||||
|
}
|
||||||
|
} else if (!map.hasLayer(layer)) {
|
||||||
|
map.addLayer(layer);
|
||||||
|
}
|
||||||
|
void maybeAutoShowNearbyStations();
|
||||||
|
};
|
||||||
|
|
||||||
|
syncVisibility();
|
||||||
|
map.on("zoomend", syncVisibility);
|
||||||
|
map.on("moveend", maybeAutoShowNearbyStations);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off("zoomend", syncVisibility);
|
||||||
|
map.off("moveend", maybeAutoShowNearbyStations);
|
||||||
|
};
|
||||||
|
}, [cityDetailsByName, selectedCity, selectedDetail, suspendMotion]);
|
||||||
|
|
||||||
|
// Centralized City Selection Zoom Effect
|
||||||
|
// Higher level than selection: we only flyTo once the data is loaded (selectedDetail)
|
||||||
|
// This satisfies "loading之后再出现动画吧"
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCity) {
|
||||||
|
lastMovedCityRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || suspendMotion || !selectedDetail || isLoadingDetail) return;
|
||||||
|
|
||||||
|
// Check if the detail matches the selection (case-insensitive)
|
||||||
|
if (selectedDetail.name?.toLowerCase() !== selectedCity.toLowerCase()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastMovedCityRef.current === selectedCity) return;
|
||||||
|
|
||||||
|
const entry = markersRef.current[selectedCity];
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
// Lock the move
|
||||||
|
lastMovedCityRef.current = selectedCity;
|
||||||
|
|
||||||
|
// We use a micro-delay (50ms) to allow the browser to settle
|
||||||
|
// after the loading overlay disappears and the detail panel renders.
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const currentMap = mapRef.current;
|
||||||
|
if (
|
||||||
|
!currentMap ||
|
||||||
|
lastMovedCityRef.current !== selectedCity ||
|
||||||
|
suspendMotion
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
currentMap.stop();
|
||||||
|
currentMap.flyTo([entry.city.lat, entry.city.lon], 11, {
|
||||||
|
animate: true,
|
||||||
|
duration: 1.1,
|
||||||
|
easeLinearity: 0.22,
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [selectedCity, selectedDetail, suspendMotion, isLoadingDetail]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!suspendMotion) return;
|
||||||
|
mapRef.current?.stop();
|
||||||
|
}, [suspendMotion]);
|
||||||
|
|
||||||
|
return { containerRef };
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CityDetail,
|
||||||
|
CityListItem,
|
||||||
|
CitySummary,
|
||||||
|
HistoryPoint,
|
||||||
|
} from "@/lib/dashboard-types";
|
||||||
|
|
||||||
|
const CACHE_KEY = "polyWeather_v1";
|
||||||
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const pendingCityDetailRequests = new Map<string, Promise<CityDetail>>();
|
||||||
|
const pendingHistoryRequests = new Map<string, Promise<HistoryPoint[]>>();
|
||||||
|
const pendingCitySummaryRequests = new Map<string, Promise<CitySummary>>();
|
||||||
|
|
||||||
|
type CityCacheMeta = {
|
||||||
|
cachedAt: number;
|
||||||
|
revision: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CityCacheBundle = {
|
||||||
|
details: Record<string, CityDetail>;
|
||||||
|
meta: Record<string, CityCacheMeta>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeCityName(cityName: string) {
|
||||||
|
return encodeURIComponent(String(cityName).replace(/\s/g, "-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isClient() {
|
||||||
|
return typeof window !== "undefined";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRevisionPart(value: unknown) {
|
||||||
|
return value == null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCityRevision(source?: CityDetail | CitySummary | null) {
|
||||||
|
if (!source) return "";
|
||||||
|
return [
|
||||||
|
normalizeRevisionPart(source.updated_at),
|
||||||
|
normalizeRevisionPart(source.current?.obs_time),
|
||||||
|
normalizeRevisionPart(source.current?.temp),
|
||||||
|
normalizeRevisionPart(source.deb?.prediction),
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCitySummary(detail: CityDetail): CitySummary {
|
||||||
|
return {
|
||||||
|
name: detail.name,
|
||||||
|
display_name: detail.display_name,
|
||||||
|
icao: detail.risk?.icao,
|
||||||
|
local_time: detail.local_time,
|
||||||
|
temp_symbol: detail.temp_symbol,
|
||||||
|
current: {
|
||||||
|
obs_time: detail.current?.obs_time,
|
||||||
|
temp: detail.current?.temp,
|
||||||
|
},
|
||||||
|
deb: {
|
||||||
|
prediction: detail.deb?.prediction,
|
||||||
|
},
|
||||||
|
risk: {
|
||||||
|
level: detail.risk?.level,
|
||||||
|
warning: detail.risk?.warning,
|
||||||
|
},
|
||||||
|
updated_at: detail.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFresh(meta?: CityCacheMeta | null) {
|
||||||
|
return Boolean(meta && Date.now() - meta.cachedAt < CACHE_TTL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLegacyCache(raw: string): CityCacheBundle {
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
timestamp?: number;
|
||||||
|
data?: Record<string, CityDetail>;
|
||||||
|
};
|
||||||
|
const details = parsed.data || {};
|
||||||
|
const cachedAt = parsed.timestamp || 0;
|
||||||
|
const meta = Object.fromEntries(
|
||||||
|
Object.entries(details).map(([cityName, detail]) => [
|
||||||
|
cityName,
|
||||||
|
{
|
||||||
|
cachedAt,
|
||||||
|
revision: getCityRevision(detail),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return { details, meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dashboardClient = {
|
||||||
|
clearCityDetailCache() {
|
||||||
|
if (!isClient()) return;
|
||||||
|
window.sessionStorage.removeItem(CACHE_KEY);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCities() {
|
||||||
|
const data = await fetchJson<{ cities?: CityListItem[] }>("/api/cities");
|
||||||
|
return data.cities || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCitySummary(cityName: string, options?: { force?: boolean }) {
|
||||||
|
const force = options?.force ?? false;
|
||||||
|
const requestKey = `${cityName}::${force ? "force" : "cached"}`;
|
||||||
|
const existing = pendingCitySummaryRequests.get(requestKey);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = fetchJson<CitySummary>(
|
||||||
|
`/api/city/${normalizeCityName(cityName)}/summary?force_refresh=${force}`,
|
||||||
|
).finally(() => {
|
||||||
|
pendingCitySummaryRequests.delete(requestKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingCitySummaryRequests.set(requestKey, request);
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCityDetail(cityName: string, options?: { force?: boolean }) {
|
||||||
|
const force = options?.force ?? false;
|
||||||
|
const requestKey = `${cityName}::${force ? "force" : "cached"}`;
|
||||||
|
const existing = pendingCityDetailRequests.get(requestKey);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = fetchJson<CityDetail>(
|
||||||
|
`/api/city/${normalizeCityName(cityName)}?force_refresh=${force}`,
|
||||||
|
).finally(() => {
|
||||||
|
pendingCityDetailRequests.delete(requestKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingCityDetailRequests.set(requestKey, request);
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHistory(cityName: string) {
|
||||||
|
const requestKey = normalizeCityName(cityName);
|
||||||
|
const existing = pendingHistoryRequests.get(requestKey);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = fetchJson<{ history?: HistoryPoint[] }>(
|
||||||
|
`/api/history/${requestKey}`,
|
||||||
|
)
|
||||||
|
.then((data) => data.history || [])
|
||||||
|
.finally(() => {
|
||||||
|
pendingHistoryRequests.delete(requestKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingHistoryRequests.set(requestKey, request);
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
|
||||||
|
isCityDetailFresh(meta?: CityCacheMeta | null) {
|
||||||
|
return isFresh(meta);
|
||||||
|
},
|
||||||
|
|
||||||
|
readCityDetailCacheBundle() {
|
||||||
|
if (!isClient()) {
|
||||||
|
return {
|
||||||
|
details: {},
|
||||||
|
meta: {},
|
||||||
|
} satisfies CityCacheBundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cached = window.sessionStorage.getItem(CACHE_KEY);
|
||||||
|
if (!cached) {
|
||||||
|
return {
|
||||||
|
details: {},
|
||||||
|
meta: {},
|
||||||
|
} satisfies CityCacheBundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(cached) as
|
||||||
|
| {
|
||||||
|
entries?: Record<
|
||||||
|
string,
|
||||||
|
{ cachedAt?: number; detail?: CityDetail; revision?: string }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
timestamp?: number;
|
||||||
|
data?: Record<string, CityDetail>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if ("entries" in parsed && parsed.entries) {
|
||||||
|
const details: Record<string, CityDetail> = {};
|
||||||
|
const meta: Record<string, CityCacheMeta> = {};
|
||||||
|
Object.entries(parsed.entries).forEach(([cityName, entry]) => {
|
||||||
|
if (!entry?.detail) return;
|
||||||
|
details[cityName] = entry.detail;
|
||||||
|
meta[cityName] = {
|
||||||
|
cachedAt: entry.cachedAt || 0,
|
||||||
|
revision: entry.revision || getCityRevision(entry.detail),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { details, meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
return readLegacyCache(cached);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
details: {},
|
||||||
|
meta: {},
|
||||||
|
} satisfies CityCacheBundle;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
readCityDetailCache() {
|
||||||
|
return this.readCityDetailCacheBundle().details;
|
||||||
|
},
|
||||||
|
|
||||||
|
writeCityDetailCacheBundle(
|
||||||
|
details: Record<string, CityDetail>,
|
||||||
|
meta: Record<string, CityCacheMeta>,
|
||||||
|
) {
|
||||||
|
if (!isClient()) return;
|
||||||
|
const entries = Object.fromEntries(
|
||||||
|
Object.entries(details).map(([cityName, detail]) => [
|
||||||
|
cityName,
|
||||||
|
{
|
||||||
|
cachedAt: meta[cityName]?.cachedAt || Date.now(),
|
||||||
|
detail,
|
||||||
|
revision: meta[cityName]?.revision || getCityRevision(detail),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
CACHE_KEY,
|
||||||
|
JSON.stringify({ entries }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
writeCityDetailCache(data: Record<string, CityDetail>) {
|
||||||
|
const now = Date.now();
|
||||||
|
const meta = Object.fromEntries(
|
||||||
|
Object.entries(data).map(([cityName, detail]) => [
|
||||||
|
cityName,
|
||||||
|
{ cachedAt: now, revision: getCityRevision(detail) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
this.writeCityDetailCacheBundle(data, meta);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
export interface CityScenery {
|
||||||
|
creditLabel: string;
|
||||||
|
creditUrl: string;
|
||||||
|
imageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SCENERY: CityScenery = {
|
||||||
|
creditLabel: "Pexels / City scenery",
|
||||||
|
creditUrl: "https://www.pexels.com/",
|
||||||
|
imageUrl: "/scenery/city-default.jpg",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CITY_SCENERY: Record<string, CityScenery> = {
|
||||||
|
ankara: {
|
||||||
|
creditLabel: "Pexels / Ankara skyline",
|
||||||
|
creditUrl: "https://www.pexels.com/",
|
||||||
|
imageUrl: "/scenery/ankara.jpg",
|
||||||
|
},
|
||||||
|
chicago: {
|
||||||
|
creditLabel: "Pexels / Chicago skyline",
|
||||||
|
creditUrl: "https://www.pexels.com/",
|
||||||
|
imageUrl: "/scenery/chicago.jpg",
|
||||||
|
},
|
||||||
|
london: {
|
||||||
|
creditLabel: "Pexels / London skyline",
|
||||||
|
creditUrl: "https://www.pexels.com/",
|
||||||
|
imageUrl: "/scenery/london.jpg",
|
||||||
|
},
|
||||||
|
lucknow: {
|
||||||
|
creditLabel: "Pexels / Lucknow heritage",
|
||||||
|
creditUrl: "https://www.pexels.com/",
|
||||||
|
imageUrl: "/scenery/lucknow.jpg",
|
||||||
|
},
|
||||||
|
munich: {
|
||||||
|
creditLabel: "Pexels / Munich streetscape",
|
||||||
|
creditUrl: "https://www.pexels.com/",
|
||||||
|
imageUrl: "/scenery/munich.jpg",
|
||||||
|
},
|
||||||
|
"new york": {
|
||||||
|
creditLabel: "Pexels / New York skyline",
|
||||||
|
creditUrl: "https://www.pexels.com/",
|
||||||
|
imageUrl: "/scenery/new-york.jpg",
|
||||||
|
},
|
||||||
|
"new york city": {
|
||||||
|
creditLabel: "Pexels / New York skyline",
|
||||||
|
creditUrl: "https://www.pexels.com/",
|
||||||
|
imageUrl: "/scenery/new-york.jpg",
|
||||||
|
},
|
||||||
|
paris: {
|
||||||
|
creditLabel: "Pexels / Paris streetscape",
|
||||||
|
creditUrl: "https://www.pexels.com/",
|
||||||
|
imageUrl: "/scenery/paris.jpg",
|
||||||
|
},
|
||||||
|
seoul: {
|
||||||
|
creditLabel: "Pexels / Seoul cityscape",
|
||||||
|
creditUrl: "https://www.pexels.com/",
|
||||||
|
imageUrl: "/scenery/seoul.jpg",
|
||||||
|
},
|
||||||
|
"sao paulo": {
|
||||||
|
creditLabel: "Pexels / Sao Paulo skyline",
|
||||||
|
creditUrl: "https://www.pexels.com/",
|
||||||
|
imageUrl: "/scenery/sao-paulo.jpg",
|
||||||
|
},
|
||||||
|
"são paulo": {
|
||||||
|
creditLabel: "Pexels / Sao Paulo skyline",
|
||||||
|
creditUrl: "https://www.pexels.com/",
|
||||||
|
imageUrl: "/scenery/sao-paulo.jpg",
|
||||||
|
},
|
||||||
|
"s茫o paulo": {
|
||||||
|
creditLabel: "Pexels / Sao Paulo skyline",
|
||||||
|
creditUrl: "https://www.pexels.com/",
|
||||||
|
imageUrl: "/scenery/sao-paulo.jpg",
|
||||||
|
},
|
||||||
|
toronto: {
|
||||||
|
creditLabel: "Pexels / Toronto skyline",
|
||||||
|
creditUrl: "https://www.pexels.com/",
|
||||||
|
imageUrl: "/scenery/toronto.jpg",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getCityScenery(cityName?: string | null) {
|
||||||
|
if (!cityName) return null;
|
||||||
|
return CITY_SCENERY[String(cityName).toLowerCase()] || DEFAULT_SCENERY;
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
export type RiskLevel = "low" | "medium" | "high" | string;
|
||||||
|
|
||||||
|
export interface CityListItem {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
risk_level: RiskLevel;
|
||||||
|
risk_emoji?: string;
|
||||||
|
airport: string;
|
||||||
|
icao: string;
|
||||||
|
temp_unit: "celsius" | "fahrenheit";
|
||||||
|
is_major?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProbabilityBucket {
|
||||||
|
value?: number | null;
|
||||||
|
label?: string | null;
|
||||||
|
bucket?: string | null;
|
||||||
|
range?: string | null;
|
||||||
|
unit?: string | null;
|
||||||
|
probability?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelForecastEntry {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardRisk {
|
||||||
|
level: RiskLevel;
|
||||||
|
emoji?: string;
|
||||||
|
airport?: string;
|
||||||
|
icao?: string;
|
||||||
|
distance_km?: number | null;
|
||||||
|
warning?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudLayer {
|
||||||
|
cover: string;
|
||||||
|
base: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentConditions {
|
||||||
|
temp: number | null;
|
||||||
|
max_so_far: number | null;
|
||||||
|
max_temp_time: string | null;
|
||||||
|
wu_settlement: number | null;
|
||||||
|
obs_time: string | null;
|
||||||
|
obs_age_min: number | null;
|
||||||
|
wind_speed_kt: number | null;
|
||||||
|
wind_dir: number | null;
|
||||||
|
humidity: number | null;
|
||||||
|
cloud_desc: string | null;
|
||||||
|
clouds_raw: CloudLayer[];
|
||||||
|
visibility_mi: number | null;
|
||||||
|
wx_desc: string | null;
|
||||||
|
raw_metar?: string | null;
|
||||||
|
report_time?: string | null;
|
||||||
|
receipt_time?: string | null;
|
||||||
|
obs_time_epoch?: number | null;
|
||||||
|
dewpoint?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NearbyStation {
|
||||||
|
name?: string;
|
||||||
|
icao?: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
temp: number | null;
|
||||||
|
wind_dir?: number | null;
|
||||||
|
wind_speed?: number | null;
|
||||||
|
wind_speed_kt?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HourlyTrendPoint {
|
||||||
|
time: string;
|
||||||
|
temp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendInfo {
|
||||||
|
direction?: string;
|
||||||
|
recent?: HourlyTrendPoint[];
|
||||||
|
is_cooling?: boolean;
|
||||||
|
is_dead_market?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeakInfo {
|
||||||
|
hours?: string[];
|
||||||
|
first_h?: number;
|
||||||
|
last_h?: number;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MgmData {
|
||||||
|
temp?: number | null;
|
||||||
|
time?: string | null;
|
||||||
|
today_high?: number | null;
|
||||||
|
today_low?: number | null;
|
||||||
|
hourly?: Array<{
|
||||||
|
time?: string | null;
|
||||||
|
temp?: number | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForecastDay {
|
||||||
|
date: string;
|
||||||
|
max_temp: number | null;
|
||||||
|
min_temp?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForecastData {
|
||||||
|
today_high?: number | null;
|
||||||
|
daily?: ForecastDay[];
|
||||||
|
sunrise?: string | null;
|
||||||
|
sunset?: string | null;
|
||||||
|
sunshine_hours?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebForecast {
|
||||||
|
prediction: number | null;
|
||||||
|
weights_info?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CitySummary {
|
||||||
|
name: string;
|
||||||
|
display_name?: string | null;
|
||||||
|
icao?: string | null;
|
||||||
|
local_time?: string | null;
|
||||||
|
temp_symbol?: string | null;
|
||||||
|
current?: {
|
||||||
|
temp?: number | null;
|
||||||
|
obs_time?: string | null;
|
||||||
|
};
|
||||||
|
deb?: {
|
||||||
|
prediction?: number | null;
|
||||||
|
};
|
||||||
|
risk?: {
|
||||||
|
level?: RiskLevel;
|
||||||
|
warning?: string | null;
|
||||||
|
};
|
||||||
|
updated_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HourlySeries {
|
||||||
|
times?: string[];
|
||||||
|
temps?: Array<number | null>;
|
||||||
|
dew_point?: Array<number | null>;
|
||||||
|
pressure_msl?: Array<number | null>;
|
||||||
|
wind_speed_10m?: Array<number | null>;
|
||||||
|
wind_direction_10m?: Array<number | null>;
|
||||||
|
precipitation_probability?: Array<number | null>;
|
||||||
|
cloud_cover?: Array<number | null>;
|
||||||
|
radiation?: Array<number | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeatherGovPeriod {
|
||||||
|
name?: string;
|
||||||
|
start_time?: string;
|
||||||
|
end_time?: string;
|
||||||
|
short_forecast?: string | null;
|
||||||
|
detailed_forecast?: string | null;
|
||||||
|
temperature?: number | null;
|
||||||
|
temperature_unit?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceForecasts {
|
||||||
|
weather_gov?: {
|
||||||
|
forecast_periods?: WeatherGovPeriod[];
|
||||||
|
};
|
||||||
|
meteoblue?: {
|
||||||
|
daily_highs?: Array<number | null>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyModelForecast {
|
||||||
|
models?: Record<string, number | null>;
|
||||||
|
deb?: {
|
||||||
|
prediction?: number | null;
|
||||||
|
};
|
||||||
|
probabilities?: ProbabilityBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiAnalysisStructured {
|
||||||
|
summary?: string | null;
|
||||||
|
text?: string | null;
|
||||||
|
message?: string | null;
|
||||||
|
highlights?: string[];
|
||||||
|
points?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CityDetail {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
temp_symbol: string;
|
||||||
|
local_time: string;
|
||||||
|
local_date: string;
|
||||||
|
risk: DashboardRisk;
|
||||||
|
current: CurrentConditions;
|
||||||
|
mgm?: MgmData;
|
||||||
|
mgm_nearby?: NearbyStation[];
|
||||||
|
forecast?: ForecastData;
|
||||||
|
multi_model?: Record<string, number | null>;
|
||||||
|
deb?: DebForecast;
|
||||||
|
probabilities?: {
|
||||||
|
mu?: number | null;
|
||||||
|
distribution?: ProbabilityBucket[];
|
||||||
|
};
|
||||||
|
hourly?: {
|
||||||
|
times?: string[];
|
||||||
|
temps?: Array<number | null>;
|
||||||
|
};
|
||||||
|
hourly_next_48h?: HourlySeries;
|
||||||
|
metar_recent_obs?: Array<{
|
||||||
|
time?: string;
|
||||||
|
temp?: number | null;
|
||||||
|
}>;
|
||||||
|
metar_today_obs?: Array<{
|
||||||
|
time?: string;
|
||||||
|
temp?: number | null;
|
||||||
|
}>;
|
||||||
|
trend?: TrendInfo;
|
||||||
|
peak?: PeakInfo;
|
||||||
|
ai_analysis?: string | AiAnalysisStructured | null;
|
||||||
|
updated_at?: string;
|
||||||
|
multi_model_daily?: Record<string, DailyModelForecast>;
|
||||||
|
source_forecasts?: SourceForecasts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryPoint {
|
||||||
|
date: string;
|
||||||
|
actual: number | null;
|
||||||
|
deb: number | null;
|
||||||
|
mgm?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadingState {
|
||||||
|
cities: boolean;
|
||||||
|
cityDetail: boolean;
|
||||||
|
refresh: boolean;
|
||||||
|
history: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryState {
|
||||||
|
isOpen: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
dataByCity: Record<string, HistoryPoint[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardState {
|
||||||
|
cities: CityListItem[];
|
||||||
|
cityDetailsByName: Record<string, CityDetail>;
|
||||||
|
citySummariesByName: Record<string, CitySummary>;
|
||||||
|
selectedCity: string | null;
|
||||||
|
isPanelOpen: boolean;
|
||||||
|
selectedForecastDate: string | null;
|
||||||
|
loadingState: LoadingState;
|
||||||
|
historyState: HistoryState;
|
||||||
|
}
|
||||||
@@ -0,0 +1,882 @@
|
|||||||
|
import { AiAnalysisStructured, CityDetail, HistoryPoint, NearbyStation } from "@/lib/dashboard-types";
|
||||||
|
|
||||||
|
const METAR_WX_MAP: Record<string, { label: string; icon: string }> = {
|
||||||
|
RA: { label: "降雨", icon: "🌧️" },
|
||||||
|
"-RA": { label: "小雨", icon: "🌦️" },
|
||||||
|
"+RA": { label: "强降雨", icon: "⛈️" },
|
||||||
|
SN: { label: "降雪", icon: "❄️" },
|
||||||
|
"-SN": { label: "小雪", icon: "🌨️" },
|
||||||
|
"+SN": { label: "大雪", icon: "🌨️" },
|
||||||
|
DZ: { label: "毛毛雨", icon: "🌦️" },
|
||||||
|
FG: { label: "雾", icon: "🌫️" },
|
||||||
|
BR: { label: "薄雾", icon: "🌫️" },
|
||||||
|
HZ: { label: "霾", icon: "🌫️" },
|
||||||
|
TS: { label: "雷暴", icon: "⛈️" },
|
||||||
|
VCTS: { label: "附近雷暴", icon: "⛈️" },
|
||||||
|
SQ: { label: "飑线", icon: "💨" },
|
||||||
|
GS: { label: "冰雹", icon: "🌨️" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function translateMetar(code?: string | null) {
|
||||||
|
if (!code) return null;
|
||||||
|
for (const [key, value] of Object.entries(METAR_WX_MAP)) {
|
||||||
|
if (String(code).includes(key)) return value;
|
||||||
|
}
|
||||||
|
return { label: code, icon: "🌤️" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRiskBadgeLabel(level?: string | null) {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
high: "🔴 高风险",
|
||||||
|
medium: "🟠 中风险",
|
||||||
|
low: "🟢 低风险",
|
||||||
|
}[String(level || "low")] || "未知风险"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeatherSummary(detail: CityDetail) {
|
||||||
|
const current = detail.current || {};
|
||||||
|
let weatherText = current.cloud_desc || "未知";
|
||||||
|
let weatherIcon =
|
||||||
|
{
|
||||||
|
多云: "☁️",
|
||||||
|
阴天: "☁️",
|
||||||
|
少云: "🌤️",
|
||||||
|
散云: "⛅",
|
||||||
|
晴: "☀️",
|
||||||
|
晴朗: "☀️",
|
||||||
|
}[String(current.cloud_desc || "")] || "🌤️";
|
||||||
|
|
||||||
|
if (current.wx_desc) {
|
||||||
|
const translated = translateMetar(current.wx_desc);
|
||||||
|
if (translated) {
|
||||||
|
weatherText = translated.label;
|
||||||
|
weatherIcon = translated.icon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { weatherIcon, weatherText };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHeroMetaItems(detail: CityDetail) {
|
||||||
|
const current = detail.current || {};
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (current.obs_time) {
|
||||||
|
const ageText =
|
||||||
|
current.obs_age_min != null && current.obs_age_min >= 30
|
||||||
|
? `(${current.obs_age_min} 分钟前)`
|
||||||
|
: "";
|
||||||
|
parts.push(`✈️ METAR ${current.obs_time}${ageText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.wx_desc) {
|
||||||
|
const translated = translateMetar(current.wx_desc);
|
||||||
|
if (translated) {
|
||||||
|
parts.push(`${translated.icon} ${translated.label}`);
|
||||||
|
}
|
||||||
|
} else if (current.cloud_desc) {
|
||||||
|
parts.push(`☁️ ${current.cloud_desc}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.wind_speed_kt != null) {
|
||||||
|
parts.push(`💨 ${current.wind_speed_kt}kt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.visibility_mi != null) {
|
||||||
|
parts.push(`👁️ ${current.visibility_mi}mi`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.mgm?.temp != null) {
|
||||||
|
const timeMatch = detail.mgm.time?.match(/T?(\d{2}:\d{2})/);
|
||||||
|
const timeText = timeMatch ? ` @${timeMatch[1]}` : "";
|
||||||
|
parts.push(`📡 MGM 实测: ${detail.mgm.temp}${detail.temp_symbol}${timeText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trend = detail.trend || {};
|
||||||
|
if (trend.is_dead_market) {
|
||||||
|
parts.push("☠️ 死盘");
|
||||||
|
} else if (trend.direction && trend.direction !== "unknown") {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
rising: "📈 升温中",
|
||||||
|
falling: "📉 降温中",
|
||||||
|
stagnant: "⏸️ 持平",
|
||||||
|
mixed: "📊 波动中",
|
||||||
|
};
|
||||||
|
parts.push(labels[trend.direction] || trend.direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTemperatureChartData(detail: CityDetail) {
|
||||||
|
const hourly = detail.hourly || {};
|
||||||
|
const times = hourly.times || [];
|
||||||
|
const temps = hourly.temps || [];
|
||||||
|
|
||||||
|
if (!times.length) return null;
|
||||||
|
|
||||||
|
const currentHour = detail.local_time
|
||||||
|
? `${detail.local_time.split(":")[0]}:00`
|
||||||
|
: null;
|
||||||
|
const currentIndex = currentHour ? times.indexOf(currentHour) : -1;
|
||||||
|
const omMax = detail.forecast?.today_high;
|
||||||
|
const debMax = detail.deb?.prediction;
|
||||||
|
const offset =
|
||||||
|
debMax != null && omMax != null ? Number(debMax) - Number(omMax) : 0;
|
||||||
|
const debTemps = temps.map((temp) =>
|
||||||
|
temp != null ? Number((temp + offset).toFixed(1)) : null,
|
||||||
|
);
|
||||||
|
const debPast = debTemps.map((temp, index) =>
|
||||||
|
currentIndex >= 0 && index <= currentIndex ? temp : null,
|
||||||
|
);
|
||||||
|
const debFuture = debTemps.map((temp, index) =>
|
||||||
|
currentIndex < 0 || index >= currentIndex ? temp : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const metarPoints = new Array(times.length).fill(null);
|
||||||
|
const metarSource = detail.metar_today_obs?.length
|
||||||
|
? detail.metar_today_obs
|
||||||
|
: detail.trend?.recent || [];
|
||||||
|
|
||||||
|
metarSource.forEach((item) => {
|
||||||
|
const parts = String(item.time || "").split(":");
|
||||||
|
let hour = Number.parseInt(parts[0], 10);
|
||||||
|
const minute = Number.parseInt(parts[1] || "0", 10);
|
||||||
|
if (Number.isNaN(hour)) return;
|
||||||
|
if (minute >= 30) hour = (hour + 1) % 24;
|
||||||
|
const key = `${String(hour).padStart(2, "0")}:00`;
|
||||||
|
const index = times.indexOf(key);
|
||||||
|
if (index >= 0 && metarPoints[index] === null) {
|
||||||
|
metarPoints[index] = item.temp ?? null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mgmPoints = new Array(times.length).fill(null);
|
||||||
|
if (detail.mgm?.temp != null && detail.mgm?.time) {
|
||||||
|
const match = detail.mgm.time.match(/T?(\d{2}):(\d{2})/);
|
||||||
|
if (match) {
|
||||||
|
let hour = Number.parseInt(match[1], 10);
|
||||||
|
const minute = Number.parseInt(match[2], 10);
|
||||||
|
if (minute >= 30) hour = (hour + 1) % 24;
|
||||||
|
const key = `${String(hour).padStart(2, "0")}:00`;
|
||||||
|
const index = times.indexOf(key);
|
||||||
|
if (index >= 0) {
|
||||||
|
mgmPoints[index] = detail.mgm.temp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mgmHourlyPoints = new Array(times.length).fill(null);
|
||||||
|
let hasMgmHourly = false;
|
||||||
|
detail.mgm?.hourly?.forEach((item) => {
|
||||||
|
const match = String(item.time || "").match(/T?(\d{2}):(\d{2})/);
|
||||||
|
if (!match) return;
|
||||||
|
const key = `${match[1]}:00`;
|
||||||
|
const index = times.indexOf(key);
|
||||||
|
if (index >= 0) {
|
||||||
|
mgmHourlyPoints[index] = item.temp ?? null;
|
||||||
|
hasMgmHourly = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const allValues = [
|
||||||
|
...debTemps.filter((value) => value != null),
|
||||||
|
...metarPoints.filter((value) => value != null),
|
||||||
|
...mgmPoints.filter((value) => value != null),
|
||||||
|
...mgmHourlyPoints.filter((value) => value != null),
|
||||||
|
] as number[];
|
||||||
|
|
||||||
|
if (!allValues.length) return null;
|
||||||
|
|
||||||
|
const min = Math.floor(Math.min(...allValues)) - 1;
|
||||||
|
const max = Math.ceil(Math.max(...allValues)) + 1;
|
||||||
|
|
||||||
|
const legendParts: string[] = [];
|
||||||
|
if (detail.mgm?.temp != null) {
|
||||||
|
legendParts.push(`MGM: ${detail.mgm.temp}${detail.temp_symbol}`);
|
||||||
|
}
|
||||||
|
if (!hasMgmHourly && debMax != null && omMax != null && Math.abs(offset) > 0.3) {
|
||||||
|
const sign = offset > 0 ? "+" : "";
|
||||||
|
legendParts.push(`DEB 偏移 ${sign}${offset.toFixed(1)}${detail.temp_symbol} vs OM`);
|
||||||
|
}
|
||||||
|
if (hasMgmHourly) {
|
||||||
|
legendParts.push("已使用 MGM 小时预报替代 DEB 曲线");
|
||||||
|
}
|
||||||
|
if (detail.trend?.recent?.length) {
|
||||||
|
const recentText = [...detail.trend.recent]
|
||||||
|
.slice(0, 4)
|
||||||
|
.reverse()
|
||||||
|
.map((item) => `${item.temp}${detail.temp_symbol}@${item.time}`)
|
||||||
|
.join(" -> ");
|
||||||
|
legendParts.push(`METAR: ${recentText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
datasets: {
|
||||||
|
debFuture,
|
||||||
|
debPast,
|
||||||
|
hasMgmHourly,
|
||||||
|
metarPoints,
|
||||||
|
mgmHourlyPoints,
|
||||||
|
mgmPoints,
|
||||||
|
offset,
|
||||||
|
temps,
|
||||||
|
},
|
||||||
|
legendText: legendParts.join(" | "),
|
||||||
|
max,
|
||||||
|
min,
|
||||||
|
times,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProbabilityView(detail: CityDetail, targetDate?: string | null) {
|
||||||
|
const date = targetDate || detail.local_date;
|
||||||
|
if (date === detail.local_date) {
|
||||||
|
return {
|
||||||
|
mu: detail.probabilities?.mu ?? null,
|
||||||
|
probabilities: detail.probabilities?.distribution || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const daily = detail.multi_model_daily?.[date];
|
||||||
|
return {
|
||||||
|
mu: daily?.deb?.prediction ?? null,
|
||||||
|
probabilities: daily?.probabilities || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModelView(detail: CityDetail, targetDate?: string | null) {
|
||||||
|
const date = targetDate || detail.local_date;
|
||||||
|
const daily = detail.multi_model_daily?.[date];
|
||||||
|
if (daily) {
|
||||||
|
return {
|
||||||
|
deb: daily.deb?.prediction ?? null,
|
||||||
|
models: daily.models || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
deb: detail.deb?.prediction ?? null,
|
||||||
|
models: detail.multi_model || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAiAnalysis(analysis: CityDetail["ai_analysis"]) {
|
||||||
|
const fallback = {
|
||||||
|
bullets: [] as string[],
|
||||||
|
summary: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!analysis) return fallback;
|
||||||
|
|
||||||
|
if (typeof analysis === "string") {
|
||||||
|
return {
|
||||||
|
bullets: [],
|
||||||
|
summary: analysis.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const structured = analysis as AiAnalysisStructured;
|
||||||
|
return {
|
||||||
|
bullets: Array.isArray(structured.highlights)
|
||||||
|
? structured.highlights
|
||||||
|
: Array.isArray(structured.points)
|
||||||
|
? structured.points
|
||||||
|
: [],
|
||||||
|
summary: structured.summary || structured.text || structured.message || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickAnkaraNearbyStations(stations: NearbyStation[]) {
|
||||||
|
const preferredNames = [
|
||||||
|
"Airport (MGM/17128)",
|
||||||
|
"Ankara (Bölge/Center)",
|
||||||
|
"Ankara (Bolge/Center)",
|
||||||
|
"Etimesgut",
|
||||||
|
"Pursaklar",
|
||||||
|
"Cubuk",
|
||||||
|
"Çubuk",
|
||||||
|
"Kalecik",
|
||||||
|
];
|
||||||
|
|
||||||
|
const picks = preferredNames
|
||||||
|
.map((name) => stations.find((station) => station?.name === name))
|
||||||
|
.filter(Boolean) as NearbyStation[];
|
||||||
|
|
||||||
|
return picks.length ? picks : stations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFutureSlice(detail: CityDetail, dateStr: string) {
|
||||||
|
const hourly = detail.hourly_next_48h || {};
|
||||||
|
const times = hourly.times || [];
|
||||||
|
const slice: Array<{
|
||||||
|
cloudCover: number | null;
|
||||||
|
dewPoint: number | null;
|
||||||
|
label: string;
|
||||||
|
precipProb: number | null;
|
||||||
|
pressure: number | null;
|
||||||
|
radiation: number | null;
|
||||||
|
temp: number | null;
|
||||||
|
time: string;
|
||||||
|
windDir: number | null;
|
||||||
|
windSpeed: number | null;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < times.length; index += 1) {
|
||||||
|
const timestamp = times[index];
|
||||||
|
if (!timestamp || !String(timestamp).startsWith(dateStr)) continue;
|
||||||
|
|
||||||
|
slice.push({
|
||||||
|
cloudCover: hourly.cloud_cover?.[index] ?? null,
|
||||||
|
dewPoint: hourly.dew_point?.[index] ?? null,
|
||||||
|
label: String(timestamp).split("T")[1]?.slice(0, 5) || timestamp,
|
||||||
|
precipProb: hourly.precipitation_probability?.[index] ?? null,
|
||||||
|
pressure: hourly.pressure_msl?.[index] ?? null,
|
||||||
|
radiation: hourly.radiation?.[index] ?? null,
|
||||||
|
temp: hourly.temps?.[index] ?? null,
|
||||||
|
time: timestamp,
|
||||||
|
windDir: hourly.wind_direction_10m?.[index] ?? null,
|
||||||
|
windSpeed: hourly.wind_speed_10m?.[index] ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return slice;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trendBucketFromDir(direction?: number | null) {
|
||||||
|
const value = Number(direction);
|
||||||
|
if (!Number.isFinite(value)) return null;
|
||||||
|
if (value >= 135 && value <= 240) return "southerly";
|
||||||
|
if (value >= 290 || value <= 45) return "northerly";
|
||||||
|
if (value > 45 && value < 135) return "easterly";
|
||||||
|
return "westerly";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketLabel(bucket: string | null) {
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
southerly: "南 / 西南风",
|
||||||
|
northerly: "北 / 西北风",
|
||||||
|
easterly: "东风",
|
||||||
|
westerly: "西风",
|
||||||
|
}[bucket || ""] || "风向不明"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDelta(value: number | null | undefined, suffix = "") {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric)) return "--";
|
||||||
|
const sign = numeric > 0 ? "+" : "";
|
||||||
|
return `${sign}${numeric.toFixed(1)}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getForecastTextForDate(detail: CityDetail, dateStr: string) {
|
||||||
|
const periods = detail.source_forecasts?.weather_gov?.forecast_periods || [];
|
||||||
|
return periods.filter((period) =>
|
||||||
|
String(period.start_time || "").startsWith(dateStr),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeFrontTrendSignal(detail: CityDetail, dateStr: string) {
|
||||||
|
const slice = getFutureSlice(detail, dateStr);
|
||||||
|
const currentTemp = Number(detail.current?.temp);
|
||||||
|
const currentDew = Number(detail.current?.dewpoint);
|
||||||
|
|
||||||
|
if (!slice.length) {
|
||||||
|
return {
|
||||||
|
confidence: "low",
|
||||||
|
label: "监控中",
|
||||||
|
metrics: [] as Array<{
|
||||||
|
label: string;
|
||||||
|
note: string;
|
||||||
|
tone?: string;
|
||||||
|
value: string;
|
||||||
|
}>,
|
||||||
|
precipMax: 0,
|
||||||
|
score: 0,
|
||||||
|
summary: "未来 48 小时结构化数据不足,暂时只保留基础监控。",
|
||||||
|
weatherGovPeriods: [] as ReturnType<typeof getForecastTextForDate>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = slice[0];
|
||||||
|
const last = slice[slice.length - 1];
|
||||||
|
const firstTemp = Number.isFinite(Number(first.temp)) ? Number(first.temp) : currentTemp;
|
||||||
|
const lastTemp = Number.isFinite(Number(last.temp)) ? Number(last.temp) : firstTemp;
|
||||||
|
const tempDelta =
|
||||||
|
Number.isFinite(firstTemp) && Number.isFinite(lastTemp) ? lastTemp - firstTemp : 0;
|
||||||
|
const firstDew = Number.isFinite(Number(first.dewPoint))
|
||||||
|
? Number(first.dewPoint)
|
||||||
|
: currentDew;
|
||||||
|
const lastDew = Number.isFinite(Number(last.dewPoint))
|
||||||
|
? Number(last.dewPoint)
|
||||||
|
: firstDew;
|
||||||
|
const dewDelta =
|
||||||
|
Number.isFinite(firstDew) && Number.isFinite(lastDew) ? lastDew - firstDew : 0;
|
||||||
|
const firstPressure = Number.isFinite(Number(first.pressure))
|
||||||
|
? Number(first.pressure)
|
||||||
|
: null;
|
||||||
|
const lastPressure = Number.isFinite(Number(last.pressure))
|
||||||
|
? Number(last.pressure)
|
||||||
|
: firstPressure;
|
||||||
|
const pressureDelta =
|
||||||
|
Number.isFinite(Number(firstPressure)) && Number.isFinite(Number(lastPressure))
|
||||||
|
? Number(lastPressure) - Number(firstPressure)
|
||||||
|
: 0;
|
||||||
|
const firstCloud = Number.isFinite(Number(first.cloudCover))
|
||||||
|
? Number(first.cloudCover)
|
||||||
|
: null;
|
||||||
|
const lastCloud = Number.isFinite(Number(last.cloudCover))
|
||||||
|
? Number(last.cloudCover)
|
||||||
|
: firstCloud;
|
||||||
|
const cloudDelta =
|
||||||
|
Number.isFinite(Number(firstCloud)) && Number.isFinite(Number(lastCloud))
|
||||||
|
? Number(lastCloud) - Number(firstCloud)
|
||||||
|
: 0;
|
||||||
|
const precipMax = slice.reduce(
|
||||||
|
(max, point) => Math.max(max, Number(point.precipProb) || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const firstBucket = trendBucketFromDir(first.windDir);
|
||||||
|
const lastBucket = trendBucketFromDir(last.windDir);
|
||||||
|
const weatherGovPeriods = getForecastTextForDate(detail, dateStr);
|
||||||
|
const weatherGovText = weatherGovPeriods
|
||||||
|
.map(
|
||||||
|
(period) =>
|
||||||
|
`${period.short_forecast || ""} ${period.detailed_forecast || ""}`.toLowerCase(),
|
||||||
|
)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
let warmScore = 0;
|
||||||
|
let coldScore = 0;
|
||||||
|
if (tempDelta >= 2) warmScore += 24;
|
||||||
|
else if (tempDelta >= 0.8) warmScore += 12;
|
||||||
|
if (tempDelta <= -2) coldScore += 24;
|
||||||
|
else if (tempDelta <= -0.8) coldScore += 12;
|
||||||
|
if (dewDelta >= 1.2) warmScore += 14;
|
||||||
|
if (dewDelta <= -1.2) coldScore += 10;
|
||||||
|
if (pressureDelta >= 1.2) coldScore += 16;
|
||||||
|
if (pressureDelta <= -1.0) warmScore += 8;
|
||||||
|
if (lastBucket === "southerly") warmScore += 14;
|
||||||
|
if (firstBucket !== lastBucket && lastBucket === "southerly") warmScore += 10;
|
||||||
|
if (lastBucket === "northerly") coldScore += 14;
|
||||||
|
if (firstBucket !== lastBucket && lastBucket === "northerly") coldScore += 10;
|
||||||
|
if (cloudDelta >= 15 && tempDelta >= 0) warmScore += 6;
|
||||||
|
if (cloudDelta >= 15 && tempDelta < 0) coldScore += 8;
|
||||||
|
if (precipMax >= 40) coldScore += 8;
|
||||||
|
if (
|
||||||
|
weatherGovText.includes("cold front") ||
|
||||||
|
weatherGovText.includes("temperatures falling")
|
||||||
|
) {
|
||||||
|
coldScore += 18;
|
||||||
|
}
|
||||||
|
if (weatherGovText.includes("warm front") || weatherGovText.includes("warmer")) {
|
||||||
|
warmScore += 18;
|
||||||
|
}
|
||||||
|
if (weatherGovText.includes("thunder") || weatherGovText.includes("snow")) {
|
||||||
|
coldScore += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = Math.max(-100, Math.min(100, warmScore - coldScore));
|
||||||
|
const label =
|
||||||
|
score >= 18
|
||||||
|
? "暖平流 / 暖锋倾向"
|
||||||
|
: score <= -18
|
||||||
|
? "冷平流 / 冷锋倾向"
|
||||||
|
: "监控中";
|
||||||
|
const confidence =
|
||||||
|
Math.abs(score) >= 45 ? "high" : Math.abs(score) >= 22 ? "medium" : "low";
|
||||||
|
|
||||||
|
return {
|
||||||
|
confidence,
|
||||||
|
label,
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
label: "温度变化",
|
||||||
|
note: "Open-Meteo 未来小时温度变化",
|
||||||
|
tone: tempDelta >= 0.8 ? "warm" : tempDelta <= -0.8 ? "cold" : "",
|
||||||
|
value: formatDelta(tempDelta, detail.temp_symbol),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "露点变化",
|
||||||
|
note: "露点上升更偏向暖湿平流",
|
||||||
|
tone: dewDelta >= 0.8 ? "warm" : dewDelta <= -0.8 ? "cold" : "",
|
||||||
|
value: formatDelta(dewDelta, detail.temp_symbol),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "气压变化",
|
||||||
|
note: "气压回升更偏向冷空气压入",
|
||||||
|
tone: pressureDelta >= 1 ? "cold" : pressureDelta <= -1 ? "warm" : "",
|
||||||
|
value: formatDelta(pressureDelta, " hPa"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "风向演变",
|
||||||
|
note: "关注是否转南风或转北风",
|
||||||
|
value: `${bucketLabel(firstBucket)} -> ${bucketLabel(lastBucket)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "降水概率",
|
||||||
|
note: "weather.gov / Open-Meteo 降水提示",
|
||||||
|
tone: precipMax >= 50 ? "cold" : "",
|
||||||
|
value: `${Math.round(precipMax)}%`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "云量变化",
|
||||||
|
note: "云量抬升但未降温,常见于暖平流前段",
|
||||||
|
tone:
|
||||||
|
cloudDelta >= 15 && tempDelta >= 0
|
||||||
|
? "warm"
|
||||||
|
: cloudDelta >= 15 && tempDelta < 0
|
||||||
|
? "cold"
|
||||||
|
: "",
|
||||||
|
value: formatDelta(cloudDelta, "%"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
precipMax,
|
||||||
|
score,
|
||||||
|
summary:
|
||||||
|
label === "暖平流 / 暖锋倾向"
|
||||||
|
? "风向更偏南 / 西南,露点与温度整体抬升,未来 6-48 小时偏向暖平流。"
|
||||||
|
: label === "冷平流 / 冷锋倾向"
|
||||||
|
? "温度下滑、气压回升或风向转北,未来 6-48 小时更像冷锋或冷平流压制。"
|
||||||
|
: detail.name !== "ankara" && Boolean(detail.source_forecasts?.meteoblue)
|
||||||
|
? "结构化来源以 weather.gov、Open-Meteo、Meteoblue 为主,用于判断未来 6-48 小时冷暖平流趋势。"
|
||||||
|
: "结构化来源以 weather.gov 与 Open-Meteo 为主,用于判断未来 6-48 小时冷暖平流趋势。",
|
||||||
|
weatherGovPeriods,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFutureModalView(detail: CityDetail, dateStr: string) {
|
||||||
|
const forecastEntry =
|
||||||
|
detail.forecast?.daily?.find((item) => item.date === dateStr) || null;
|
||||||
|
const dailyModel = detail.multi_model_daily?.[dateStr] || {};
|
||||||
|
const probabilities = dailyModel.probabilities || [];
|
||||||
|
const totalProbability = probabilities.reduce((sum, item) => {
|
||||||
|
const probability = Number(item.probability);
|
||||||
|
return Number.isFinite(probability) ? sum + probability : sum;
|
||||||
|
}, 0);
|
||||||
|
const weightedProbability = probabilities.reduce((sum, item) => {
|
||||||
|
const value = Number(item.value);
|
||||||
|
const probability = Number(item.probability);
|
||||||
|
if (!Number.isFinite(value) || !Number.isFinite(probability)) {
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
return sum + value * probability;
|
||||||
|
}, 0);
|
||||||
|
const mu = totalProbability > 0 ? weightedProbability / totalProbability : null;
|
||||||
|
const deb = dailyModel.deb?.prediction ?? forecastEntry?.max_temp ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
deb,
|
||||||
|
forecastEntry,
|
||||||
|
front: computeFrontTrendSignal(detail, dateStr),
|
||||||
|
models: dailyModel.models || {},
|
||||||
|
mu: Number.isFinite(Number(mu)) ? Number(mu) : null,
|
||||||
|
probabilities,
|
||||||
|
slice: getFutureSlice(detail, dateStr),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShortTermNowcastLines(detail: CityDetail, dateStr: string) {
|
||||||
|
const slice = getFutureSlice(detail, dateStr);
|
||||||
|
if (dateStr !== detail.local_date) {
|
||||||
|
const afternoon = slice.filter((point) => {
|
||||||
|
const hour = Number.parseInt(String(point.label).split(":")[0], 10);
|
||||||
|
return Number.isFinite(hour) && hour >= 12 && hour <= 18;
|
||||||
|
});
|
||||||
|
const target = afternoon.length ? afternoon : slice;
|
||||||
|
if (!target.length) {
|
||||||
|
return [
|
||||||
|
["目标日期", dateStr],
|
||||||
|
["峰值窗口", "暂无足够的小时级 forecast 数据,无法生成目标日午后峰值窗口判断。"],
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxIndex = target.reduce((bestIndex, point, index, array) => {
|
||||||
|
const temp = Number(point.temp);
|
||||||
|
const bestTemp = Number(array[bestIndex]?.temp);
|
||||||
|
if (!Number.isFinite(temp)) return bestIndex;
|
||||||
|
if (!Number.isFinite(bestTemp) || temp > bestTemp) return index;
|
||||||
|
return bestIndex;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const peakSlice = target.slice(
|
||||||
|
Math.max(0, maxIndex - 1),
|
||||||
|
Math.min(target.length, maxIndex + 2),
|
||||||
|
);
|
||||||
|
const start = peakSlice[0];
|
||||||
|
const end = peakSlice[peakSlice.length - 1];
|
||||||
|
const peakPoint = target[maxIndex] || end;
|
||||||
|
const startTemp = Number(start.temp);
|
||||||
|
const endTemp = Number(end.temp);
|
||||||
|
const startDew = Number(start.dewPoint);
|
||||||
|
const endDew = Number(end.dewPoint);
|
||||||
|
const startPressure = Number(start.pressure);
|
||||||
|
const endPressure = Number(end.pressure);
|
||||||
|
const precipValues = peakSlice
|
||||||
|
.map((point) => Number(point.precipProb))
|
||||||
|
.filter(Number.isFinite);
|
||||||
|
const cloudValues = peakSlice
|
||||||
|
.map((point) => Number(point.cloudCover))
|
||||||
|
.filter(Number.isFinite);
|
||||||
|
const maxPrecip = precipValues.length ? Math.max(...precipValues) : 0;
|
||||||
|
const maxCloud = cloudValues.length ? Math.max(...cloudValues) : 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
["目标日期", dateStr],
|
||||||
|
["峰值窗口", `${start.label} - ${end.label}(优先取 12:00-18:00)`],
|
||||||
|
[
|
||||||
|
"峰值预估",
|
||||||
|
`${Number.isFinite(Number(peakPoint.temp)) ? Number(peakPoint.temp).toFixed(1) : "--"}${detail.temp_symbol} @ ${peakPoint.label || "--"}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"窗口温度",
|
||||||
|
`${Number.isFinite(startTemp) ? startTemp.toFixed(1) : "--"}${detail.temp_symbol} -> ${Number.isFinite(endTemp) ? endTemp.toFixed(1) : "--"}${detail.temp_symbol}(${formatDelta(endTemp - startTemp, detail.temp_symbol)})`,
|
||||||
|
],
|
||||||
|
["露点变化", `${formatDelta(endDew - startDew, detail.temp_symbol)},用于判断午后暖湿输送是否增强。`],
|
||||||
|
[
|
||||||
|
"风向演变",
|
||||||
|
`${bucketLabel(trendBucketFromDir(start.windDir))} -> ${bucketLabel(trendBucketFromDir(end.windDir))},关注峰值前后是否转南风或回摆北风。`,
|
||||||
|
],
|
||||||
|
["气压变化", `${formatDelta(endPressure - startPressure, " hPa")},上升更偏向冷空气压入。`],
|
||||||
|
["降水 / 云量", `${Math.round(maxPrecip)}% / ${Math.round(maxCloud)}%,用于判断峰值时段是否受云系压制。`],
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recent = Array.isArray(detail.metar_recent_obs)
|
||||||
|
? detail.metar_recent_obs.slice(-4)
|
||||||
|
: [];
|
||||||
|
const nearby = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby : [];
|
||||||
|
const sourceLabel = detail.name === "ankara" ? "MGM 周边站" : "METAR 周边站";
|
||||||
|
const currentTemp = Number(detail.current?.temp);
|
||||||
|
const recentTemps = recent
|
||||||
|
.map((point) => Number(point.temp))
|
||||||
|
.filter((value) => Number.isFinite(value));
|
||||||
|
const baseline = recentTemps.length ? recentTemps[0] : currentTemp;
|
||||||
|
const shortDelta =
|
||||||
|
Number.isFinite(currentTemp) && Number.isFinite(baseline)
|
||||||
|
? currentTemp - baseline
|
||||||
|
: 0;
|
||||||
|
let nearbyLead: { diff: number; name: string; temp: number } | null = null;
|
||||||
|
|
||||||
|
for (const station of nearby) {
|
||||||
|
const temp = Number(station.temp);
|
||||||
|
if (!Number.isFinite(temp) || !Number.isFinite(currentTemp)) continue;
|
||||||
|
const diff = temp - currentTemp;
|
||||||
|
if (!nearbyLead || Math.abs(diff) > Math.abs(nearbyLead.diff)) {
|
||||||
|
nearbyLead = {
|
||||||
|
diff,
|
||||||
|
name: station.name || station.icao || "周边站",
|
||||||
|
temp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: Array<readonly [string, string]> = [
|
||||||
|
["当前主站", `${detail.current?.temp ?? "--"}${detail.temp_symbol} @ ${detail.current?.obs_time || "--"}`],
|
||||||
|
["原始 METAR", detail.current?.raw_metar || "暂无"],
|
||||||
|
["近 0-2 小时", `${formatDelta(shortDelta, detail.temp_symbol)},依据最近 METAR 序列判断短时动量。`],
|
||||||
|
[sourceLabel, `${nearby.length} 个站点参与邻近监控。`],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (nearbyLead) {
|
||||||
|
const tone =
|
||||||
|
nearbyLead.diff > 0 ? "偏暖" : nearbyLead.diff < 0 ? "偏冷" : "持平";
|
||||||
|
rows.push([
|
||||||
|
"领先站",
|
||||||
|
`${nearbyLead.name} ${nearbyLead.temp}${detail.temp_symbol},相对主站 ${formatDelta(nearbyLead.diff, detail.temp_symbol)}(${tone})。`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHistorySummary(
|
||||||
|
history: HistoryPoint[],
|
||||||
|
cityLocalDate?: string | null,
|
||||||
|
) {
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setHours(0, 0, 0, 0);
|
||||||
|
cutoff.setDate(cutoff.getDate() - 14);
|
||||||
|
|
||||||
|
const recentData = history.filter((row) => {
|
||||||
|
if (!row?.date) return false;
|
||||||
|
const rowDate = new Date(`${row.date}T00:00:00`);
|
||||||
|
return !Number.isNaN(rowDate.getTime()) && rowDate >= cutoff;
|
||||||
|
});
|
||||||
|
|
||||||
|
const settledData = recentData.filter((row) => {
|
||||||
|
if (!row?.date) return false;
|
||||||
|
return cityLocalDate
|
||||||
|
? row.date < cityLocalDate
|
||||||
|
: row.date < new Date().toISOString().slice(0, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
let hits = 0;
|
||||||
|
const debErrors: number[] = [];
|
||||||
|
settledData.forEach((row) => {
|
||||||
|
if (row.actual != null && row.deb != null) {
|
||||||
|
debErrors.push(Math.abs(row.actual - row.deb));
|
||||||
|
if (Math.round(row.actual) === Math.round(row.deb)) {
|
||||||
|
hits += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dates: recentData.map((row) => row.date),
|
||||||
|
debMae: debErrors.length
|
||||||
|
? Number(
|
||||||
|
(
|
||||||
|
debErrors.reduce((sum, value) => sum + value, 0) / debErrors.length
|
||||||
|
).toFixed(1),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
debs: recentData.map((row) => row.deb),
|
||||||
|
hitRate: debErrors.length
|
||||||
|
? Number(((hits / debErrors.length) * 100).toFixed(0))
|
||||||
|
: null,
|
||||||
|
mgms: recentData.map((row) => row.mgm ?? null),
|
||||||
|
recentData,
|
||||||
|
settledCount: settledData.length,
|
||||||
|
actuals: recentData.map((row) => row.actual),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCityProfileStats(detail: CityDetail) {
|
||||||
|
const risk = detail.risk || {};
|
||||||
|
const current = detail.current || {};
|
||||||
|
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "结算机场",
|
||||||
|
value: risk.airport && risk.icao ? `${risk.airport} (${risk.icao})` : "暂无档案",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "站点距离",
|
||||||
|
value:
|
||||||
|
risk.distance_km != null && Number.isFinite(Number(risk.distance_km))
|
||||||
|
? `${risk.distance_km} km`
|
||||||
|
: "未标注",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "观测更新",
|
||||||
|
value: current.obs_time || detail.updated_at || "未提供",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "周边站点",
|
||||||
|
value: nearbyCount > 0 ? `${nearbyCount} 个参与监控` : "暂无周边站",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSettlementRiskNarrative(detail: CityDetail) {
|
||||||
|
const risk = detail.risk || {};
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
if (risk.warning) {
|
||||||
|
lines.push(`当前主要风险是:${risk.warning}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (risk.distance_km != null) {
|
||||||
|
if (risk.distance_km >= 60) {
|
||||||
|
lines.push("结算机场与城市核心区域距离偏大,盘面温度与结算值可能出现明显背离。");
|
||||||
|
} else if (risk.distance_km >= 25) {
|
||||||
|
lines.push("结算机场与城区存在可感知距离,午后峰值和夜间降温节奏需要优先看机场站。");
|
||||||
|
} else {
|
||||||
|
lines.push("结算机场距离较近,城市体感与结算温度通常更同步。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.name === "ankara") {
|
||||||
|
lines.push("Ankara 需要重点看 LTAC / Esenboğa 与 MGM 周边站联动,不能只看城区体感。");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.current?.obs_age_min != null) {
|
||||||
|
if (detail.current.obs_age_min >= 45) {
|
||||||
|
lines.push(`当前 METAR 已有 ${detail.current.obs_age_min} 分钟时滞,临近判断要结合周边站而不是只看主站快照。`);
|
||||||
|
} else {
|
||||||
|
lines.push("当前主站观测较新,短时判断可以把主站温度作为主要锚点。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClimateDrivers(detail: CityDetail) {
|
||||||
|
const drivers: Array<{ label: string; text: string }> = [];
|
||||||
|
const lat = Math.abs(Number(detail.lat));
|
||||||
|
const current = detail.current || {};
|
||||||
|
const temp = Number(current.temp);
|
||||||
|
const dewPoint = Number(current.dewpoint);
|
||||||
|
const humidity = Number(current.humidity);
|
||||||
|
const windSpeed = Number(current.wind_speed_kt);
|
||||||
|
const nearbyCount = Array.isArray(detail.mgm_nearby) ? detail.mgm_nearby.length : 0;
|
||||||
|
|
||||||
|
if (lat >= 50) {
|
||||||
|
drivers.push({
|
||||||
|
label: "高纬冷空气",
|
||||||
|
text: "这座城市处在较高纬度,气温更容易受冷空气南下、短波槽和日照角度变化影响,波动通常偏快。",
|
||||||
|
});
|
||||||
|
} else if (lat >= 35) {
|
||||||
|
drivers.push({
|
||||||
|
label: "中纬度西风带",
|
||||||
|
text: "这座城市主要受中纬度西风带和锋面活动控制,升温或降温往往来自气团切换,而不是单一的日照变化。",
|
||||||
|
});
|
||||||
|
} else if (lat >= 20) {
|
||||||
|
drivers.push({
|
||||||
|
label: "副热带高压",
|
||||||
|
text: "这座城市更容易受到副热带高压、晴空辐射和低层暖平流影响,午后冲高能力通常比高纬城市更强。",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
drivers.push({
|
||||||
|
label: "热带水汽与对流",
|
||||||
|
text: "这座城市更偏热带环境,温度与体感常受水汽输送、云对流和阵雨触发影响,不完全由晴空辐射主导。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(windSpeed) && windSpeed >= 12) {
|
||||||
|
drivers.push({
|
||||||
|
label: "平流输送",
|
||||||
|
text: `当前风速约 ${windSpeed}kt,说明低层输送比较明显,盘面短时方向更容易被外来气团带动。`,
|
||||||
|
});
|
||||||
|
} else if (detail.trend?.is_dead_market) {
|
||||||
|
drivers.push({
|
||||||
|
label: "本地辐射主导",
|
||||||
|
text: "近期更像本地辐射和地表热量收支在主导,若无新气团介入,温度节奏通常更平滑。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
Number.isFinite(temp) &&
|
||||||
|
Number.isFinite(dewPoint) &&
|
||||||
|
temp - dewPoint <= 3
|
||||||
|
) {
|
||||||
|
drivers.push({
|
||||||
|
label: "湿度与云量约束",
|
||||||
|
text: "当前温度和露点接近,说明低层湿度较高。午后峰值容易受云量和降水触发抑制。",
|
||||||
|
});
|
||||||
|
} else if (Number.isFinite(humidity) && humidity >= 70) {
|
||||||
|
drivers.push({
|
||||||
|
label: "湿层偏厚",
|
||||||
|
text: "相对湿度偏高,说明局地升温效率会受到水汽和云层反馈影响,冲高空间要比干空气场景更小心。",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
drivers.push({
|
||||||
|
label: "干暖边界层",
|
||||||
|
text: "低层空气相对偏干,晴空时段的升温效率通常更高,午后冲顶更依赖辐射和风向切换。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nearbyCount >= 4) {
|
||||||
|
drivers.push({
|
||||||
|
label: "局地差异",
|
||||||
|
text: "周边可用站点较多,说明地形、城区热岛或下垫面差异可能明显,结算站与城区体感需要分开看。",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return drivers;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@vercel/analytics": "^1.6.1",
|
"@vercel/analytics": "^1.6.1",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
@@ -528,6 +529,11 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
|
||||||
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.5.12",
|
"version": "15.5.12",
|
||||||
"resolved": "https://registry.npmmirror.com/@next/env/-/env-15.5.12.tgz",
|
"resolved": "https://registry.npmmirror.com/@next/env/-/env-15.5.12.tgz",
|
||||||
@@ -974,6 +980,17 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@@ -2334,6 +2351,11 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="
|
||||||
|
},
|
||||||
"@next/env": {
|
"@next/env": {
|
||||||
"version": "15.5.12",
|
"version": "15.5.12",
|
||||||
"resolved": "https://registry.npmmirror.com/@next/env/-/env-15.5.12.tgz",
|
"resolved": "https://registry.npmmirror.com/@next/env/-/env-15.5.12.tgz",
|
||||||
@@ -2567,6 +2589,14 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz",
|
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz",
|
||||||
"integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="
|
"integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="
|
||||||
},
|
},
|
||||||
|
"chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"requires": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"chokidar": {
|
"chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@vercel/analytics": "^1.6.1",
|
"@vercel/analytics": "^1.6.1",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 6.8 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 6.7 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 893 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |