Improve dashboard maintainability before the next release
The dashboard had several oversized orchestration, component, and CSS files that made product-copy changes and mobile/performance work risky. This refactor preserves behavior while splitting scan terminal CSS, opportunity helpers, future forecast panels, history/detail charts, and probability/model sections into smaller ownership boundaries. Constraint: No user-visible version bump because this batch is architecture and performance cleanup, not a release announcement. Rejected: Rewrite dashboard state management in the same batch | too broad for a safe upload after CSS and component splitting. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep new component/CSS boundaries instead of moving product copy back into the large dashboard files. Tested: npm run build; npm run test:business; git diff --check Not-tested: Browser visual smoke test after push
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
"use client";
|
||||
|
||||
import type { ChartConfiguration } from "chart.js";
|
||||
import { useMemo } from "react";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { getTemperatureChartData } from "@/lib/chart-utils";
|
||||
import { getFutureModalView } from "@/lib/dashboard-utils";
|
||||
import { formatMinuteAxisLabel } from "./FutureForecastModal.utils";
|
||||
|
||||
export function DailyTemperatureChart({
|
||||
dateStr,
|
||||
forceToday = false,
|
||||
}: {
|
||||
dateStr: string;
|
||||
forceToday?: boolean;
|
||||
}) {
|
||||
const store = useDashboardStore();
|
||||
const { locale, t } = useI18n();
|
||||
const detail = store.selectedDetail;
|
||||
const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
|
||||
const isToday =
|
||||
forceToday || (detail ? dateStr === detail.local_date : false);
|
||||
const todayChartData = useMemo(
|
||||
() => (detail && isToday ? getTemperatureChartData(detail, locale) : null),
|
||||
[detail, isToday, locale],
|
||||
);
|
||||
|
||||
const canvasRef = useChart(() => {
|
||||
if (!detail || !view) {
|
||||
return {
|
||||
data: { datasets: [], labels: [] },
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}
|
||||
|
||||
if (isToday && todayChartData) {
|
||||
const datasets: NonNullable<
|
||||
ChartConfiguration<"line">["data"]
|
||||
>["datasets"] = [];
|
||||
|
||||
if (todayChartData.datasets.hasMgmHourly) {
|
||||
datasets.push({
|
||||
backgroundColor: "rgba(234, 179, 8, 0.05)",
|
||||
borderColor: "rgba(234, 179, 8, 0.8)",
|
||||
borderWidth: 2,
|
||||
data: todayChartData.datasets.mgmHourlySeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "MGM Forecast" : "MGM 预测",
|
||||
parsing: false,
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 3,
|
||||
spanGaps: true,
|
||||
tension: 0.3,
|
||||
});
|
||||
} else {
|
||||
datasets.push({
|
||||
backgroundColor: "rgba(77, 163, 255, 0.06)",
|
||||
borderColor: "rgba(77, 163, 255, 0.66)",
|
||||
borderWidth: 1.5,
|
||||
data: todayChartData.datasets.debPastSeries,
|
||||
fill: true,
|
||||
label: locale === "en-US" ? "DEB Forecast" : "DEB 预测",
|
||||
parsing: false,
|
||||
pointHoverRadius: 3,
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
datasets.push({
|
||||
borderColor: "rgba(77, 163, 255, 0.36)",
|
||||
borderDash: [5, 3],
|
||||
borderWidth: 1.5,
|
||||
data: todayChartData.datasets.debFutureSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "DEB Forecast" : "DEB 预测",
|
||||
parsing: false,
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
datasets.push({
|
||||
backgroundColor: "#4DA3FF",
|
||||
borderColor: "#4DA3FF",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.metarSeries,
|
||||
fill: false,
|
||||
label:
|
||||
todayChartData.observationLabel ||
|
||||
(locale === "en-US" ? "Observation" : "观测实况"),
|
||||
order: 0,
|
||||
parsing: false,
|
||||
pointHoverRadius: 7,
|
||||
pointRadius: 5,
|
||||
showLine: false,
|
||||
});
|
||||
|
||||
if (todayChartData.datasets.airportMetarSeries?.length > 0) {
|
||||
datasets.push({
|
||||
backgroundColor: "#60a5fa",
|
||||
borderColor: "#60a5fa",
|
||||
borderWidth: 1,
|
||||
data: todayChartData.datasets.airportMetarSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "Airport METAR" : "机场 METAR",
|
||||
order: 0,
|
||||
parsing: false,
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 4,
|
||||
showLine: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (todayChartData.datasets.mgmSeries?.length > 0) {
|
||||
datasets.push({
|
||||
backgroundColor: "#facc15",
|
||||
borderColor: "#facc15",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.mgmSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
|
||||
order: -1,
|
||||
parsing: false,
|
||||
pointHoverRadius: 9,
|
||||
pointRadius: 7,
|
||||
showLine: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!todayChartData.datasets.hasMgmHourly &&
|
||||
Math.abs(todayChartData.datasets.offset) > 0.3
|
||||
) {
|
||||
datasets.push({
|
||||
borderColor: "rgba(77, 163, 255, 0.22)",
|
||||
borderDash: [2, 4],
|
||||
borderWidth: 1,
|
||||
data: todayChartData.datasets.tempsSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "OM Raw" : "OM 原始",
|
||||
parsing: false,
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
}
|
||||
if ((todayChartData.tafMarkers || []).length > 0) {
|
||||
datasets.push({
|
||||
backgroundColor: "#f59e0b",
|
||||
borderColor: "#f59e0b",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.tafCurrentMarkerSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "Current TAF" : "当前 TAF",
|
||||
order: -3,
|
||||
parsing: false,
|
||||
pointHoverRadius: 8,
|
||||
pointRadius: 6,
|
||||
showLine: false,
|
||||
});
|
||||
datasets.push({
|
||||
backgroundColor: "rgba(250, 204, 21, 0.72)",
|
||||
borderColor: "rgba(250, 204, 21, 0.72)",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.tafPeakWindowMarkerSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "Peak-window TAF" : "峰值窗口 TAF",
|
||||
order: -2,
|
||||
parsing: false,
|
||||
pointHoverRadius: 7,
|
||||
pointRadius: 4,
|
||||
showLine: false,
|
||||
});
|
||||
datasets.push({
|
||||
backgroundColor: "#f59e0b",
|
||||
borderColor: "#f59e0b",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.tafMarkerSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "TAF Timing" : "TAF 时段",
|
||||
order: -4,
|
||||
parsing: false,
|
||||
pointHoverRadius: 0,
|
||||
pointRadius: 0,
|
||||
showLine: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets,
|
||||
labels: [],
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "nearest" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: "#9FB2C7",
|
||||
filter: (legendItem, chartData) => {
|
||||
const text = String(legendItem.text || "");
|
||||
if (!text) return false;
|
||||
if (text === "TAF Timing" || text === "TAF 时段")
|
||||
return false;
|
||||
if (!text.includes("DEB")) return true;
|
||||
|
||||
const firstDebIndex = (chartData.datasets || []).findIndex(
|
||||
(dataset) => String(dataset.label || "").includes("DEB"),
|
||||
);
|
||||
return legendItem.datasetIndex === firstDebIndex;
|
||||
},
|
||||
font: { family: "Inter", size: 11 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.96)",
|
||||
borderColor: "rgba(77, 163, 255, 0.24)",
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
title: (items) => {
|
||||
const rawX = items?.[0]?.parsed?.x;
|
||||
return rawX != null
|
||||
? formatMinuteAxisLabel(Number(rawX))
|
||||
: "";
|
||||
},
|
||||
label: (ctx) => {
|
||||
const label = String(ctx.dataset.label || "");
|
||||
const raw = ctx.raw as
|
||||
| {
|
||||
marker?: {
|
||||
summary?: string;
|
||||
markerType?: string;
|
||||
displayType?: string;
|
||||
isCurrent?: boolean;
|
||||
isPeakWindow?: boolean;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
if (
|
||||
label === "TAF Timing" ||
|
||||
label === "TAF 时段" ||
|
||||
label === "Current TAF" ||
|
||||
label === "当前 TAF" ||
|
||||
label === "Peak-window TAF" ||
|
||||
label === "峰值窗口 TAF"
|
||||
) {
|
||||
const marker = raw?.marker;
|
||||
if (!marker) return label;
|
||||
const markerType = String(marker.markerType || "");
|
||||
const displayType = String(
|
||||
marker.displayType || marker.markerType || "",
|
||||
);
|
||||
const summary = String(marker.summary || "");
|
||||
const prefix =
|
||||
marker.isCurrent && marker.isPeakWindow
|
||||
? locale === "en-US"
|
||||
? "Current / peak-window TAF"
|
||||
: "当前 / 峰值窗口 TAF"
|
||||
: marker.isCurrent
|
||||
? locale === "en-US"
|
||||
? "Current TAF"
|
||||
: "当前 TAF"
|
||||
: marker.isPeakWindow
|
||||
? locale === "en-US"
|
||||
? "Peak-window TAF"
|
||||
: "峰值窗口 TAF"
|
||||
: label;
|
||||
return `${prefix}: ${
|
||||
markerType
|
||||
? summary.replace(markerType, displayType)
|
||||
: summary
|
||||
}`;
|
||||
}
|
||||
const value = ctx.parsed.y;
|
||||
if (value == null) return label;
|
||||
return `${label}: ${value.toFixed(1)}${detail.temp_symbol || "°C"}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
max: todayChartData.xMax,
|
||||
min: todayChartData.xMin,
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
type: "linear",
|
||||
ticks: {
|
||||
callback: (value) => {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return "";
|
||||
const minutes = Math.round(num);
|
||||
if (
|
||||
minutes !== todayChartData.xMin &&
|
||||
minutes !== todayChartData.xMax &&
|
||||
minutes % 120 !== 0
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
return formatMinuteAxisLabel(minutes);
|
||||
},
|
||||
color: "#6B7A90",
|
||||
font: { family: "Inter", size: 10 },
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
max: todayChartData.max,
|
||||
min: todayChartData.min,
|
||||
ticks: {
|
||||
callback: (value) =>
|
||||
`${Number(value).toFixed(todayChartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
|
||||
color: "#6B7A90",
|
||||
font: { family: "Inter", size: 10 },
|
||||
stepSize: todayChartData.yTickStep,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}
|
||||
|
||||
const labels = view.slice.map((point) => point.label);
|
||||
const unit = detail.temp_symbol || "°C";
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor: "rgba(77, 163, 255, 0.08)",
|
||||
borderColor: "#4DA3FF",
|
||||
data: view.slice.map((point) => point.temp),
|
||||
fill: false,
|
||||
label:
|
||||
locale === "en-US" ? "Open-Meteo Temperature" : "Open-Meteo 温度",
|
||||
pointRadius: 2,
|
||||
tension: 0.28,
|
||||
},
|
||||
{
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "#93C5FD",
|
||||
borderDash: [5, 4],
|
||||
data: view.slice.map((point) => point.dewPoint),
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "Dew Point" : "露点",
|
||||
pointRadius: 0,
|
||||
tension: 0.24,
|
||||
},
|
||||
],
|
||||
labels,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: "#9FB2C7",
|
||||
font: { family: "Inter", size: 11 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.96)",
|
||||
borderColor: "rgba(77, 163, 255, 0.24)",
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: (ctx) =>
|
||||
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${unit}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
color: "#6B7A90",
|
||||
font: { family: "Inter", size: 10 },
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
callback: (value) => `${value}${unit}`,
|
||||
color: "#6B7A90",
|
||||
font: { family: "Inter", size: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}, [detail, isToday, locale, todayChartData, view]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="history-chart-wrapper future-chart-wrapper">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
{isToday && (
|
||||
<div className="chart-legend">
|
||||
{todayChartData?.legendText || t("future.chartLegendEmpty")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user