518 lines
16 KiB
TypeScript
518 lines
16 KiB
TypeScript
"use client";
|
|
// /components/main/system/DetailModal.tsx
|
|
import React, { useEffect, useRef, useState, useCallback } from "react";
|
|
import { Line } from "react-chartjs-2";
|
|
import { useSelector } from "react-redux";
|
|
import { RootState, useAppDispatch } from "@/redux/store";
|
|
import { setFullScreen } from "@/redux/slices/kabelueberwachungChartSlice";
|
|
import { resetDateRange } from "@/redux/slices/dateRangePickerSlice";
|
|
|
|
// Import Thunks
|
|
import SystemChartActionBar from "@/components/main/system/SystemChartActionBar";
|
|
import { getSystemspannung5VplusThunk } from "@/redux/thunks/getSystemspannung5VplusThunk";
|
|
import { getSystemspannung15VplusThunk } from "@/redux/thunks/getSystemspannung15VplusThunk";
|
|
import { getSystemspannung15VminusThunk } from "@/redux/thunks/getSystemspannung15VminusThunk";
|
|
import { getSystemspannung98VminusThunk } from "@/redux/thunks/getSystemspannung98VminusThunk";
|
|
import { getTemperaturAdWandlerThunk } from "@/redux/thunks/getTemperaturAdWandlerThunk";
|
|
import { getTemperaturProzessorThunk } from "@/redux/thunks/getTemperaturProzessorThunk";
|
|
|
|
import {
|
|
Chart as ChartJS,
|
|
LineElement,
|
|
PointElement,
|
|
CategoryScale,
|
|
LinearScale,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler,
|
|
TimeScale,
|
|
type ChartDataset,
|
|
type ChartOptions,
|
|
type ChartData,
|
|
type Chart,
|
|
} from "chart.js";
|
|
|
|
import "chartjs-adapter-date-fns";
|
|
import { de } from "date-fns/locale";
|
|
|
|
ChartJS.register(
|
|
LineElement,
|
|
PointElement,
|
|
CategoryScale,
|
|
LinearScale,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler,
|
|
TimeScale
|
|
);
|
|
|
|
// Tailwind-basierte Farbdefinitionen für Chart.js
|
|
const chartColors = {
|
|
gray: {
|
|
line: "#6B7280", // tailwind gray-500
|
|
background: "rgba(107, 114, 128, 0.2)", // tailwind gray-500 mit opacity
|
|
},
|
|
littwinBlue: {
|
|
line: "#00AEEF", // littwin-blue
|
|
background: "rgba(0, 174, 239, 0.2)", // littwin-blue mit opacity
|
|
},
|
|
};
|
|
|
|
type ReduxDataEntry = {
|
|
//Alle DIA0 t,m,i,a , DIA1 und DIA2 t,i,a,g
|
|
t: string; // Zeitstempel
|
|
i: number; // Minimum
|
|
a: number; // Maximum
|
|
g?: number; // Durchschnitt (optional, falls vorhanden)
|
|
m?: number; // aktueller Messwert (optional, falls vorhanden)
|
|
};
|
|
|
|
const chartOptions: ChartOptions<"line"> = {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { position: "top" as const },
|
|
title: {
|
|
display: true,
|
|
text: "Verlauf",
|
|
},
|
|
tooltip: {
|
|
mode: "index" as const,
|
|
intersect: false,
|
|
callbacks: {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
label: function (ctx: any) {
|
|
return `Messwert: ${ctx.parsed.y}`;
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
title: function (items: any[]) {
|
|
const date = items[0].parsed.x;
|
|
return `Zeitpunkt: ${new Date(date).toLocaleString("de-DE")}`;
|
|
},
|
|
},
|
|
},
|
|
zoom: {
|
|
pan: { enabled: true, mode: "x" as const },
|
|
zoom: {
|
|
wheel: { enabled: true },
|
|
pinch: { enabled: true },
|
|
mode: "x" as const,
|
|
},
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
type: "time" as const,
|
|
time: {
|
|
unit: "day" as const,
|
|
tooltipFormat: "dd.MM.yyyy HH:mm",
|
|
displayFormats: {
|
|
day: "dd.MM.yyyy",
|
|
},
|
|
},
|
|
adapters: {
|
|
date: { locale: de },
|
|
},
|
|
title: {
|
|
display: true,
|
|
text: "Zeit",
|
|
},
|
|
},
|
|
y: {
|
|
title: {
|
|
display: true,
|
|
text: "Messwert",
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
type Props = {
|
|
isOpen: boolean;
|
|
selectedKey: string | null;
|
|
onClose: () => void;
|
|
zeitraum: "DIA0" | "DIA1" | "DIA2";
|
|
setZeitraum: (typ: "DIA0" | "DIA1" | "DIA2") => void;
|
|
};
|
|
|
|
export const DetailModal = ({
|
|
isOpen,
|
|
selectedKey,
|
|
onClose,
|
|
zeitraum,
|
|
setZeitraum,
|
|
}: Props) => {
|
|
// Stable empty reference to avoid React-Redux dev warning about selector returning new [] each call
|
|
const EMPTY_REDUX_DATA: ReadonlyArray<ReduxDataEntry> = Object.freeze([]);
|
|
const chartRef = useRef<Chart<"line"> | null>(null);
|
|
const [chartData, setChartData] = useState<ChartData<"line">>({
|
|
datasets: [],
|
|
});
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [shouldUpdateChart, setShouldUpdateChart] = useState(false);
|
|
// const [forceUpdate, setForceUpdate] = useState(0); // Für periodische UI-Updates (derzeit nicht benötigt)
|
|
|
|
const reduxData = useSelector((state: RootState) => {
|
|
switch (selectedKey) {
|
|
case "+5V":
|
|
return state.systemspannung5Vplus[zeitraum];
|
|
case "+15V":
|
|
return state.systemspannung15Vplus[zeitraum];
|
|
case "-15V":
|
|
return state.systemspannung15Vminus[zeitraum];
|
|
case "-96V":
|
|
return state.systemspannung98Vminus[zeitraum];
|
|
case "ADC Temp":
|
|
return state.temperaturAdWandler[zeitraum];
|
|
case "CPU Temp":
|
|
return state.temperaturProzessor[zeitraum];
|
|
default:
|
|
return EMPTY_REDUX_DATA;
|
|
}
|
|
}) as ReduxDataEntry[];
|
|
|
|
const isFullScreen = useSelector(
|
|
(state: RootState) => state.kabelueberwachungChartSlice.isFullScreen
|
|
);
|
|
const dispatch = useAppDispatch();
|
|
|
|
// API-Request beim Klick auf "Daten laden" - memoized für useEffect dependency
|
|
const handleFetchData = useCallback(() => {
|
|
setIsLoading(true);
|
|
|
|
// Clear previous chart data
|
|
setChartData({ datasets: [] });
|
|
|
|
// Flag setzen, dass Chart nach Datenempfang aktualisiert werden soll
|
|
setShouldUpdateChart(true);
|
|
|
|
switch (selectedKey) {
|
|
case "+5V":
|
|
dispatch(getSystemspannung5VplusThunk(zeitraum));
|
|
break;
|
|
case "+15V":
|
|
dispatch(getSystemspannung15VplusThunk(zeitraum));
|
|
break;
|
|
case "-15V":
|
|
dispatch(getSystemspannung15VminusThunk(zeitraum));
|
|
break;
|
|
case "-96V":
|
|
dispatch(getSystemspannung98VminusThunk(zeitraum));
|
|
break;
|
|
case "ADC Temp":
|
|
dispatch(getTemperaturAdWandlerThunk(zeitraum));
|
|
break;
|
|
case "CPU Temp":
|
|
dispatch(getTemperaturProzessorThunk(zeitraum));
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}, [selectedKey, zeitraum, dispatch]);
|
|
|
|
// Reset Zeitraum auf DIA0 und Datumswerte wenn Modal geöffnet wird
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setZeitraum("DIA0");
|
|
// Reset DateRangePicker to its defaults (it sets 30 days → today on mount)
|
|
dispatch(resetDateRange());
|
|
|
|
// Chart-Daten zurücksetzen beim Öffnen
|
|
setChartData({ datasets: [] });
|
|
}
|
|
}, [isOpen, setZeitraum, dispatch]);
|
|
|
|
// Periodische UI-Updates alle 2 Sekunden während Wartezeit
|
|
useEffect(() => {
|
|
if (isOpen && (!chartData.datasets || chartData.datasets.length === 0)) {
|
|
// Optional: periodische Re-Renders wurden deaktiviert, da nicht mehr notwendig
|
|
// (kann wieder aktiviert werden falls Cursor-Animation erwünscht ist)
|
|
}
|
|
}, [isOpen, chartData.datasets]);
|
|
|
|
// Automatisches "Daten laden" alle 4 Sekunden, maximal 2 Versuche
|
|
useEffect(() => {
|
|
if (isOpen && (!chartData.datasets || chartData.datasets.length === 0)) {
|
|
let attempts = 0;
|
|
const interval = setInterval(() => {
|
|
if (attempts < 2) {
|
|
console.log("Auto-clicking 'Daten laden' button...");
|
|
handleFetchData();
|
|
attempts++;
|
|
} else {
|
|
clearInterval(interval);
|
|
}
|
|
}, 4000);
|
|
|
|
return () => clearInterval(interval);
|
|
}
|
|
}, [isOpen, chartData.datasets, handleFetchData]);
|
|
|
|
const toggleFullScreen = () => {
|
|
dispatch(setFullScreen(!isFullScreen));
|
|
setTimeout(() => {
|
|
chartRef.current?.resize();
|
|
}, 50);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
dispatch(setFullScreen(false));
|
|
dispatch(resetDateRange());
|
|
onClose();
|
|
};
|
|
|
|
useEffect(() => {
|
|
const loadZoomPlugin = async () => {
|
|
if (typeof window !== "undefined") {
|
|
const zoomPlugin = (await import("chartjs-plugin-zoom")).default;
|
|
if (!ChartJS.registry.plugins.get("zoom")) {
|
|
ChartJS.register(zoomPlugin);
|
|
}
|
|
}
|
|
};
|
|
loadZoomPlugin();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (chartRef.current && selectedKey) {
|
|
const opts = chartRef.current.options as ChartOptions<"line"> & {
|
|
plugins?: { title?: { text?: string } };
|
|
};
|
|
if (opts.plugins?.title) {
|
|
opts.plugins.title.text = `Verlauf ${selectedKey}`;
|
|
}
|
|
chartRef.current.update("none");
|
|
}
|
|
}, [selectedKey]);
|
|
|
|
useEffect(() => {
|
|
if (chartRef.current) {
|
|
chartRef.current.resetZoom();
|
|
}
|
|
}, [zeitraum]);
|
|
|
|
// Chart.js animation complete callback to set isLoading false
|
|
useEffect(() => {
|
|
if (chartRef.current && isLoading) {
|
|
const chartInstance = chartRef.current;
|
|
// Save previous callback to restore later
|
|
const animation: any = chartInstance.options.animation || {}; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
const prevCallback = animation.onComplete;
|
|
animation.onComplete = () => {
|
|
setIsLoading(false);
|
|
if (typeof prevCallback === "function") prevCallback();
|
|
};
|
|
chartInstance.options.animation = animation;
|
|
chartInstance.update();
|
|
}
|
|
}, [chartData, isLoading]);
|
|
|
|
// DateRange from global DateRangePicker slice
|
|
const pickerVonDatum = useSelector(
|
|
(state: RootState) => state.dateRangePicker.vonDatum
|
|
);
|
|
const pickerBisDatum = useSelector(
|
|
(state: RootState) => state.dateRangePicker.bisDatum
|
|
);
|
|
|
|
// Update chart data when Redux data changes (only after button click)
|
|
useEffect(() => {
|
|
if (shouldUpdateChart && reduxData && reduxData.length > 0) {
|
|
// Filter data by selected date range (inclusive end date)
|
|
let filtered = reduxData;
|
|
try {
|
|
if (pickerVonDatum && pickerBisDatum) {
|
|
const start = new Date(`${pickerVonDatum}T00:00:00`);
|
|
const end = new Date(`${pickerBisDatum}T23:59:59`);
|
|
const s = start.getTime();
|
|
const e = end.getTime();
|
|
filtered = reduxData.filter((entry) => {
|
|
const t = new Date(entry.t).getTime();
|
|
return t >= s && t <= e;
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.warn("Zeitfilter konnte nicht angewendet werden:", err);
|
|
}
|
|
|
|
console.log("Redux data for chart (filtered):", filtered.length);
|
|
if (!filtered.length) {
|
|
setChartData({ datasets: [] });
|
|
setShouldUpdateChart(false);
|
|
return;
|
|
}
|
|
|
|
// Create datasets array for multiple lines
|
|
const datasets: ChartDataset<"line">[] = [];
|
|
|
|
// Check which data fields are available and create datasets accordingly
|
|
const hasMinimum = filtered.some(
|
|
(entry) => entry.i !== undefined && entry.i !== null && entry.i !== 0
|
|
);
|
|
const hasMaximum = filtered.some(
|
|
(entry) => entry.a !== undefined && entry.a !== null
|
|
);
|
|
const hasAverage = filtered.some(
|
|
(entry) => entry.g !== undefined && entry.g !== null
|
|
);
|
|
const hasCurrent = filtered.some(
|
|
(entry) => entry.m !== undefined && entry.m !== null
|
|
);
|
|
|
|
// Zuerst Hintergrund-Linien (Minimum/Maximum) - grau
|
|
if (hasMinimum) {
|
|
datasets.push({
|
|
label: "Messwert Minimum",
|
|
data: filtered.map((entry) => ({
|
|
x: new Date(entry.t).getTime(),
|
|
y: entry.i || 0,
|
|
})),
|
|
borderColor: "gray",
|
|
borderWidth: 1,
|
|
pointRadius: 0,
|
|
tension: 0.1,
|
|
order: 1,
|
|
});
|
|
}
|
|
|
|
if (hasMaximum) {
|
|
datasets.push({
|
|
label: "Messwert Maximum",
|
|
data: filtered.map((entry) => ({
|
|
x: new Date(entry.t).getTime(),
|
|
y: entry.a || 0,
|
|
})),
|
|
borderColor: "gray",
|
|
borderWidth: 1,
|
|
pointRadius: 0,
|
|
tension: 0.1,
|
|
order: 3,
|
|
});
|
|
}
|
|
|
|
// Dann Vordergrund-Linien (Durchschnitt/Messwert) - littwin-blue
|
|
if (hasAverage) {
|
|
datasets.push({
|
|
label: "Durchschnitt",
|
|
data: filtered.map((entry) => ({
|
|
x: new Date(entry.t).getTime(),
|
|
y: entry.g || 0,
|
|
})),
|
|
borderColor: chartColors.littwinBlue.line,
|
|
backgroundColor: chartColors.littwinBlue.background,
|
|
tension: 0.1,
|
|
fill: false,
|
|
order: 2,
|
|
});
|
|
}
|
|
|
|
if (hasCurrent) {
|
|
datasets.push({
|
|
label: "Messwert",
|
|
data: filtered.map((entry) => ({
|
|
x: new Date(entry.t).getTime(),
|
|
y: entry.m || 0,
|
|
})),
|
|
borderColor: chartColors.littwinBlue.line,
|
|
backgroundColor: chartColors.littwinBlue.background,
|
|
tension: 0.1,
|
|
fill: false,
|
|
order: 2,
|
|
});
|
|
}
|
|
|
|
const newChartData: ChartData<"line"> = {
|
|
labels: [],
|
|
datasets: datasets,
|
|
};
|
|
|
|
console.log("Chart datasets:", datasets.length, "lines");
|
|
setChartData(newChartData);
|
|
setShouldUpdateChart(false); // Reset flag
|
|
} else if (shouldUpdateChart && (!reduxData || reduxData.length === 0)) {
|
|
console.log("No Redux data available");
|
|
setChartData({ datasets: [] });
|
|
setShouldUpdateChart(false); // Reset flag
|
|
}
|
|
}, [
|
|
reduxData,
|
|
selectedKey,
|
|
shouldUpdateChart,
|
|
pickerVonDatum,
|
|
pickerBisDatum,
|
|
]);
|
|
|
|
if (!isOpen || !selectedKey) return null;
|
|
|
|
// Prüfen ob Chart Daten haben (für cursor-wait)
|
|
const hasChartData = chartData.datasets && chartData.datasets.length > 0;
|
|
|
|
return (
|
|
<div
|
|
className={`fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50 ${
|
|
!hasChartData ? "cursor-wait" : ""
|
|
}`}
|
|
>
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
className={`bg-[var(--color-surface)] text-fg border border-base rounded-xl shadow-xl flex flex-col overflow-hidden transition-all duration-300 ${
|
|
isFullScreen
|
|
? "w-[90vw] h-[90vh]"
|
|
: "w-[70rem] max-w-[95vw] h-[40rem]"
|
|
} ${!hasChartData ? "cursor-wait" : ""}`}
|
|
>
|
|
{/* Header */}
|
|
<header className="flex items-center justify-between px-6 py-4 border-b border-base select-none bg-[var(--color-surface)]">
|
|
<h2 className="text-base font-bold tracking-wide">
|
|
Detailansicht: {selectedKey}
|
|
</h2>
|
|
<div className="flex items-center gap-3 text-lg">
|
|
<button
|
|
onClick={toggleFullScreen}
|
|
className="icon-btn text-[1.4rem] hover:text-fg transition"
|
|
aria-label={isFullScreen ? "Vollbild verlassen" : "Vollbild"}
|
|
type="button"
|
|
>
|
|
<i
|
|
className={
|
|
isFullScreen
|
|
? "bi bi-fullscreen-exit"
|
|
: "bi bi-arrows-fullscreen"
|
|
}
|
|
/>
|
|
</button>
|
|
<button
|
|
onClick={handleClose}
|
|
className="icon-btn text-[1.4rem] transition"
|
|
aria-label="Modal schließen"
|
|
type="button"
|
|
>
|
|
<i className="bi bi-x-circle-fill" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Body */}
|
|
<div className="flex-1 min-h-0 flex flex-col px-6 pt-4 pb-5 bg-[var(--color-surface)] overflow-hidden">
|
|
<div className="mb-3">
|
|
<SystemChartActionBar
|
|
zeitraum={zeitraum}
|
|
setZeitraum={setZeitraum}
|
|
onFetchData={handleFetchData}
|
|
isLoading={isLoading}
|
|
className="mb-0"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-h-0 rounded-lg border border-base bg-[var(--color-surface-alt)] px-3 py-2 shadow-inner">
|
|
<Line ref={chartRef} data={chartData} options={chartOptions} />
|
|
</div>
|
|
</div>
|
|
{/* Optional Footer (currently empty, reserved for future) */}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|