2026-03-09 10:36:03 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2026-03-10 04:45:40 +08:00
|
|
|
|
import { ChartConfiguration } from "chart.js/auto";
|
2026-03-09 10:36:03 +08:00
|
|
|
|
import clsx from "clsx";
|
2026-03-11 11:14:03 +08:00
|
|
|
|
import { useEffect, useRef } from "react";
|
2026-03-10 04:45:40 +08:00
|
|
|
|
import { ForecastTable } from "@/components/dashboard/PanelSections";
|
|
|
|
|
|
import { useChart } from "@/hooks/useChart";
|
2026-03-09 10:36:03 +08:00
|
|
|
|
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
2026-03-10 04:45:40 +08:00
|
|
|
|
import { useI18n } from "@/hooks/useI18n";
|
2026-03-09 10:36:03 +08:00
|
|
|
|
import { getCityScenery } from "@/lib/dashboard-scenery";
|
2026-03-10 04:45:40 +08:00
|
|
|
|
import { CityDetail } from "@/lib/dashboard-types";
|
2026-03-09 10:36:03 +08:00
|
|
|
|
import {
|
|
|
|
|
|
getCityProfileStats,
|
|
|
|
|
|
getRiskBadgeLabel,
|
2026-03-10 04:45:40 +08:00
|
|
|
|
getTemperatureChartData,
|
2026-03-09 10:36:03 +08:00
|
|
|
|
} from "@/lib/dashboard-utils";
|
2026-03-10 04:45:40 +08:00
|
|
|
|
|
|
|
|
|
|
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
|
|
|
|
|
|
const { locale, t } = useI18n();
|
|
|
|
|
|
const chartData = getTemperatureChartData(detail, locale);
|
|
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const canvasRef = useChart(() => {
|
|
|
|
|
|
if (!chartData) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
data: { datasets: [], labels: [] },
|
|
|
|
|
|
type: "line",
|
|
|
|
|
|
} satisfies ChartConfiguration<"line">;
|
|
|
|
|
|
}
|
2026-03-10 04:45:40 +08:00
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
const forecastPoints = chartData.datasets.hasMgmHourly
|
|
|
|
|
|
? chartData.datasets.mgmHourlyPoints
|
|
|
|
|
|
: chartData.datasets.debPast.map(
|
|
|
|
|
|
(value, index) => value ?? chartData.datasets.debFuture[index],
|
|
|
|
|
|
);
|
2026-03-10 04:45:40 +08:00
|
|
|
|
|
2026-03-13 08:15:27 +08:00
|
|
|
|
return {
|
|
|
|
|
|
data: {
|
|
|
|
|
|
datasets: [
|
|
|
|
|
|
{
|
|
|
|
|
|
borderColor: chartData.datasets.hasMgmHourly
|
|
|
|
|
|
? "rgba(250, 204, 21, 0.92)"
|
|
|
|
|
|
: "rgba(52, 211, 153, 0.86)",
|
|
|
|
|
|
borderWidth: 1.8,
|
|
|
|
|
|
data: forecastPoints,
|
|
|
|
|
|
fill: false,
|
|
|
|
|
|
label: chartData.datasets.hasMgmHourly
|
|
|
|
|
|
? locale === "en-US"
|
|
|
|
|
|
? "MGM Forecast"
|
|
|
|
|
|
: "MGM 预测"
|
|
|
|
|
|
: locale === "en-US"
|
|
|
|
|
|
? "DEB Forecast"
|
|
|
|
|
|
: "DEB 预测",
|
|
|
|
|
|
pointRadius: 0,
|
|
|
|
|
|
spanGaps: true,
|
|
|
|
|
|
tension: 0.28,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
backgroundColor: "#22d3ee",
|
|
|
|
|
|
borderColor: "#22d3ee",
|
|
|
|
|
|
borderWidth: 0,
|
|
|
|
|
|
data: chartData.datasets.metarPoints,
|
|
|
|
|
|
fill: false,
|
|
|
|
|
|
label: locale === "en-US" ? "METAR Observation" : "METAR 实测",
|
|
|
|
|
|
pointHoverRadius: 6,
|
|
|
|
|
|
pointRadius: 3.8,
|
|
|
|
|
|
showLine: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
labels: chartData.times,
|
|
|
|
|
|
},
|
|
|
|
|
|
options: {
|
|
|
|
|
|
interaction: { intersect: false, mode: "index" },
|
|
|
|
|
|
maintainAspectRatio: false,
|
|
|
|
|
|
plugins: {
|
|
|
|
|
|
legend: { display: false },
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
backgroundColor: "rgba(15, 23, 42, 0.95)",
|
|
|
|
|
|
borderColor: "rgba(34, 211, 238, 0.25)",
|
|
|
|
|
|
borderWidth: 1,
|
|
|
|
|
|
},
|
2026-03-10 04:45:40 +08:00
|
|
|
|
},
|
2026-03-13 08:15:27 +08:00
|
|
|
|
responsive: true,
|
|
|
|
|
|
scales: {
|
|
|
|
|
|
x: {
|
|
|
|
|
|
grid: { color: "rgba(255,255,255,0.03)" },
|
|
|
|
|
|
ticks: {
|
|
|
|
|
|
callback: (_value, index) =>
|
|
|
|
|
|
typeof index === "number" && index % 4 === 0
|
|
|
|
|
|
? chartData.times[index]
|
|
|
|
|
|
: "",
|
|
|
|
|
|
color: "#64748b",
|
|
|
|
|
|
font: { size: 10 },
|
|
|
|
|
|
maxRotation: 0,
|
2026-03-10 04:45:40 +08:00
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-03-13 08:15:27 +08:00
|
|
|
|
y: {
|
|
|
|
|
|
grid: { color: "rgba(255,255,255,0.03)" },
|
|
|
|
|
|
max: chartData.max,
|
|
|
|
|
|
min: chartData.min,
|
|
|
|
|
|
ticks: {
|
|
|
|
|
|
callback: (value) => `${value}${detail.temp_symbol || "°C"}`,
|
|
|
|
|
|
color: "#64748b",
|
|
|
|
|
|
font: { size: 10 },
|
2026-03-10 04:45:40 +08:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-03-13 08:15:27 +08:00
|
|
|
|
},
|
|
|
|
|
|
type: "line",
|
|
|
|
|
|
} satisfies ChartConfiguration<"line">;
|
|
|
|
|
|
}, [chartData, detail.temp_symbol, locale]);
|
2026-03-10 04:45:40 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="detail-mini-chart-wrap">
|
|
|
|
|
|
<div className="detail-mini-chart">
|
|
|
|
|
|
<canvas ref={canvasRef} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="detail-mini-meta">
|
|
|
|
|
|
{chartData?.legendText || t("detail.chartLegendEmpty")}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-09 10:36:03 +08:00
|
|
|
|
|
|
|
|
|
|
export function DetailPanel() {
|
|
|
|
|
|
const store = useDashboardStore();
|
2026-03-10 04:45:40 +08:00
|
|
|
|
const { locale, t } = useI18n();
|
2026-03-09 10:36:03 +08:00
|
|
|
|
const detail = store.selectedDetail;
|
2026-03-13 06:41:33 +08:00
|
|
|
|
const isPro = store.proAccess.subscriptionActive;
|
2026-03-11 11:14:03 +08:00
|
|
|
|
const panelRef = useRef<HTMLElement | null>(null);
|
2026-03-09 10:36:03 +08:00
|
|
|
|
const isOverlayOpen =
|
|
|
|
|
|
Boolean(store.futureModalDate) ||
|
|
|
|
|
|
store.historyState.isOpen ||
|
|
|
|
|
|
store.isGuideOpen;
|
|
|
|
|
|
const isVisible =
|
|
|
|
|
|
store.isPanelOpen &&
|
|
|
|
|
|
Boolean(store.selectedCity) &&
|
|
|
|
|
|
Boolean(detail) &&
|
|
|
|
|
|
!store.loadingState.cityDetail &&
|
|
|
|
|
|
!isOverlayOpen;
|
2026-03-10 04:45:40 +08:00
|
|
|
|
const profileStats = detail ? getCityProfileStats(detail, locale) : [];
|
2026-03-09 10:36:03 +08:00
|
|
|
|
const scenery = getCityScenery(detail?.name);
|
2026-03-11 11:14:03 +08:00
|
|
|
|
const blurActiveElement = () => {
|
|
|
|
|
|
if (typeof document === "undefined") return;
|
|
|
|
|
|
const active = document.activeElement;
|
|
|
|
|
|
if (active instanceof HTMLElement) {
|
|
|
|
|
|
active.blur();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const panel = panelRef.current;
|
|
|
|
|
|
if (!panel) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (!isVisible) {
|
|
|
|
|
|
panel.setAttribute("inert", "");
|
|
|
|
|
|
if (
|
|
|
|
|
|
typeof document !== "undefined" &&
|
|
|
|
|
|
panel.contains(document.activeElement)
|
|
|
|
|
|
) {
|
|
|
|
|
|
const active = document.activeElement;
|
|
|
|
|
|
if (active instanceof HTMLElement) {
|
|
|
|
|
|
active.blur();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
panel.removeAttribute("inert");
|
|
|
|
|
|
}, [isVisible]);
|
2026-03-09 10:36:03 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<aside
|
2026-03-11 11:14:03 +08:00
|
|
|
|
ref={panelRef}
|
2026-03-09 10:36:03 +08:00
|
|
|
|
className={clsx("detail-panel", isVisible && "visible")}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="panel-header">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="panel-close"
|
2026-03-10 04:45:40 +08:00
|
|
|
|
aria-label={t("detail.closeAria")}
|
2026-03-11 11:14:03 +08:00
|
|
|
|
onClick={() => {
|
|
|
|
|
|
blurActiveElement();
|
|
|
|
|
|
store.closePanel();
|
|
|
|
|
|
}}
|
2026-03-09 10:36:03 +08:00
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div className="panel-title-area">
|
2026-03-10 04:45:40 +08:00
|
|
|
|
<h2>{detail?.display_name?.toUpperCase() || "..."}</h2>
|
2026-03-09 10:36:03 +08:00
|
|
|
|
<div className="panel-meta">
|
|
|
|
|
|
<span className={clsx("risk-badge", detail?.risk?.level || "low")}>
|
2026-03-10 04:45:40 +08:00
|
|
|
|
{getRiskBadgeLabel(detail?.risk?.level, locale)}
|
2026-03-09 10:36:03 +08:00
|
|
|
|
</span>
|
2026-03-13 08:15:27 +08:00
|
|
|
|
<div className="relative group">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={clsx("history-btn", !isPro && "pro-locked")}
|
|
|
|
|
|
title={
|
|
|
|
|
|
isPro
|
|
|
|
|
|
? t("detail.todayAnalysis")
|
|
|
|
|
|
: `${t("detail.todayAnalysis")} (Pro)`
|
|
|
|
|
|
}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
blurActiveElement();
|
|
|
|
|
|
void store.openTodayModal();
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={!detail}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isPro
|
2026-03-13 06:41:33 +08:00
|
|
|
|
? t("detail.todayAnalysis")
|
2026-03-13 08:15:27 +08:00
|
|
|
|
: `${t("detail.todayAnalysis")} · Pro`}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={clsx("history-btn", !isPro && "pro-locked")}
|
|
|
|
|
|
title={
|
|
|
|
|
|
isPro ? t("detail.history") : `${t("detail.history")} (Pro)`
|
|
|
|
|
|
}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
blurActiveElement();
|
|
|
|
|
|
void store.openHistory();
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={!detail}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isPro ? t("detail.history") : `${t("detail.history")} · Pro`}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-03-09 10:36:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="panel-body">
|
|
|
|
|
|
{!detail ? (
|
|
|
|
|
|
<section>
|
|
|
|
|
|
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>
|
|
|
|
|
|
{store.loadingState.cityDetail
|
2026-03-10 04:45:40 +08:00
|
|
|
|
? t("detail.loading")
|
|
|
|
|
|
: t("detail.emptyHint")}
|
2026-03-09 10:36:03 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<section className="detail-scenery-card">
|
|
|
|
|
|
{scenery ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<img
|
|
|
|
|
|
className="detail-scenery-image"
|
|
|
|
|
|
src={scenery.imageUrl}
|
2026-03-10 04:45:40 +08:00
|
|
|
|
alt={t("detail.sceneryAlt", { city: detail.display_name })}
|
2026-03-09 10:36:03 +08:00
|
|
|
|
/>
|
|
|
|
|
|
<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">
|
2026-03-13 08:15:27 +08:00
|
|
|
|
<span className="detail-scenery-kicker">
|
|
|
|
|
|
{detail.display_name}
|
|
|
|
|
|
</span>
|
2026-03-09 10:36:03 +08:00
|
|
|
|
<strong className="detail-scenery-title">
|
2026-03-10 04:45:40 +08:00
|
|
|
|
{t("detail.sceneryTitle")}
|
2026-03-09 10:36:03 +08:00
|
|
|
|
</strong>
|
|
|
|
|
|
<span className="detail-scenery-subtitle">
|
2026-03-10 04:45:40 +08:00
|
|
|
|
{t("detail.sceneryFallback")}
|
2026-03-09 10:36:03 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section className="detail-section">
|
2026-03-10 04:45:40 +08:00
|
|
|
|
<h3>{t("detail.profile")}</h3>
|
2026-03-09 10:36:03 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-03-13 09:06:45 +08:00
|
|
|
|
<section className="detail-section rounded-2xl">
|
2026-03-10 04:45:40 +08:00
|
|
|
|
<h3>{t("detail.todayMiniTrend")}</h3>
|
2026-03-13 09:06:45 +08:00
|
|
|
|
<DetailMiniTemperatureChart detail={detail} />
|
2026-03-09 10:36:03 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<ForecastTable />
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|