Files
CPLv4.0/components/main/analogInputs/AnalogInputsChart.tsx
2025-09-10 12:09:48 +02:00

446 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
// components/main/analogInputs/AnalogInputsChart.tsx
import React, { useEffect, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import { RootState, AppDispatch } from "@/redux/store";
import { Dialog } from "@headlessui/react";
import { Line } from "react-chartjs-2";
import {
Chart as ChartJS,
LineElement,
PointElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
Filler,
TimeScale,
TooltipItem,
} from "chart.js";
import "chartjs-adapter-date-fns";
import { de } from "date-fns/locale";
import { Listbox } from "@headlessui/react";
import { getAnalogInputsHistoryThunk } from "@/redux/thunks/getAnalogInputsHistoryThunk";
import {
setVonDatum,
setBisDatum,
setZeitraum,
setAutoLoad,
} from "@/redux/slices/analogInputs/analogInputsHistorySlice";
import { getColor } from "@/utils/colors";
import AnalogInputsDatePicker from "./AnalogInputsDatePicker";
import type { ChartJSOrUndefined } from "react-chartjs-2/dist/types";
// ✅ Nur die Basis-ChartJS-Module registrieren
ChartJS.register(
LineElement,
PointElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
Filler,
TimeScale
);
export default function AnalogInputsChart({
setLoading,
loading,
}: {
setLoading: (loading: boolean) => void;
loading: boolean;
}) {
useEffect(() => {
if (typeof window !== "undefined") {
import("chartjs-plugin-zoom").then((zoom) => {
if (!ChartJS.registry.plugins.get("zoom")) {
ChartJS.register(zoom.default);
}
});
}
}, []);
const dispatch = useDispatch<AppDispatch>();
const chartRef =
useRef<
ChartJSOrUndefined<"line", { x: Date; y: number | undefined }[], unknown>
>(null);
// Redux Werte für Chart-Daten
const { zeitraum, vonDatum, bisDatum, data, autoLoad, selectedId } =
useSelector((state: RootState) => state.analogInputsHistory);
const selectedAnalogInput = useSelector(
(state: RootState) => state.selectedAnalogInput
);
// Redux initiale Datum-Werte
const vonDatumRedux = useSelector(
(state: RootState) => state.dateRangePicker.vonDatum
);
const bisDatumRedux = useSelector(
(state: RootState) => state.dateRangePicker.bisDatum
);
// Hilfsfunktion für Default-Datum
const getDefaultDate = (type: "from" | "to") => {
const today = new Date();
if (type === "to") return today.toISOString().slice(0, 10);
const fromDateObj = new Date(today);
fromDateObj.setDate(today.getDate() - 30);
return fromDateObj.toISOString().slice(0, 10);
};
// ✅ Lokale States für Picker + Zeitraum
const [localVonDatum, setLocalVonDatum] = React.useState(
vonDatumRedux || getDefaultDate("from")
);
const [localBisDatum, setLocalBisDatum] = React.useState(
bisDatumRedux || getDefaultDate("to")
);
const [localZeitraum, setLocalZeitraum] = React.useState(zeitraum);
// Synchronisiere lokale Werte mit Redux (z.B. nach AutoLoad Reset)
useEffect(() => {
setLocalVonDatum(vonDatumRedux || getDefaultDate("from"));
setLocalBisDatum(bisDatumRedux || getDefaultDate("to"));
setLocalZeitraum(zeitraum);
}, [vonDatumRedux, bisDatumRedux, zeitraum]);
// Initiale Default-Werte: 30 Tage zurück (nur wenn Redux-Werte fehlen)
useEffect(() => {
if (!vonDatumRedux || !bisDatumRedux) {
const today = new Date();
const toDate = today.toISOString().slice(0, 10);
const fromDateObj = new Date(today);
fromDateObj.setDate(today.getDate() - 30);
const fromDate = fromDateObj.toISOString().slice(0, 10);
setLocalVonDatum(fromDate);
setLocalBisDatum(toDate);
}
}, [vonDatumRedux, bisDatumRedux]);
// ✅ Nur lokale Änderung beim Picker
const handleDateChange = (from: string, to: string) => {
setLocalVonDatum(from);
setLocalBisDatum(to);
};
// ✅ Button → Redux + Fetch triggern
const handleFetchData = () => {
if (!selectedAnalogInput?.id) return;
setLoading(true); // Set loading to true when fetching data
// Fallback auf Redux-Werte, falls lokale Werte leer sind
const from = localVonDatum || vonDatumRedux || "";
const to = localBisDatum || bisDatumRedux || "";
// Redux aktualisieren
dispatch(setVonDatum(from));
dispatch(setBisDatum(to));
dispatch(setZeitraum(localZeitraum));
// Umgebung erkennen und URL generieren
const isDev =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1";
let fetchUrl = "";
if (isDev) {
fetchUrl = `/api/cpl/getAnalogInputsHistory?eingang=${selectedAnalogInput.id}&zeitraum=${localZeitraum}&von=${from}&bis=${to}`;
} else {
// Produktion: CPL-Webserver direkt abfragen
const [vonJahr, vonMonat, vonTag] = from.split("-");
const [bisJahr, bisMonat, bisTag] = to.split("-");
const aeEingang = 100 + (selectedAnalogInput.id - 1);
let diaType = "DIA1";
if (localZeitraum === "DIA0") diaType = "DIA0";
if (localZeitraum === "DIA2") diaType = "DIA2";
fetchUrl = `${window.location.origin}/CPL?seite.ACP&${diaType}=${vonJahr};${vonMonat};${vonTag};${bisJahr};${bisMonat};${bisTag};${aeEingang};1`;
}
console.log("Fetch-URL:", fetchUrl);
// Thunk-Fetch mit neuen Werten
dispatch(
getAnalogInputsHistoryThunk({
eingang: selectedAnalogInput.id,
zeitraum: localZeitraum,
vonDatum: from,
bisDatum: to,
})
).finally(() => setLoading(false)); // Reset loading after fetch
};
// Auto-trigger fetch when a row is selected and id is not 0 (only once per selection)
React.useEffect(() => {
if (selectedAnalogInput?.id && selectedAnalogInput.id !== 0) {
handleFetchData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAnalogInput?.id]);
// ✅ Chart-Daten aus Redux filtern (Chart reagiert nur nach Button)
const chartKey = selectedAnalogInput?.id
? String(selectedAnalogInput.id + 99)
: null;
const inputData = chartKey ? data[chartKey] ?? [] : [];
const filteredData = inputData.filter((point) => {
const date = new Date(point.t);
const from = vonDatumRedux ? new Date(vonDatumRedux) : null;
const to = bisDatumRedux ? new Date(bisDatumRedux) : null;
return (!from || date >= from) && (!to || date <= to);
});
const memoizedChartData = React.useMemo(() => {
return {
datasets:
filteredData.length > 0
? zeitraum === "DIA0"
? [
{
label: "Messwert Minimum ", // (i)
data: filteredData
.filter((p) => typeof p.i === "number")
.map((p) => ({ x: new Date(p.t), y: p.i })),
borderColor: "gray",
borderWidth: 1,
pointRadius: 0,
tension: 0.1,
order: 1,
},
{
label: selectedAnalogInput?.label
? //? `Messwert ${selectedAnalogInput.label}` // (m)
`Messwert ` // (m)
: "Messwert ", // (m)
data: filteredData
.filter((p) => typeof p.m === "number")
.map((p) => ({ x: new Date(p.t), y: p.m })),
borderColor: getColor("littwin-blue"),
backgroundColor: "rgba(59,130,246,0.3)",
borderWidth: 2,
pointRadius: 0,
tension: 0.1,
order: 2,
},
{
label: "Messwert Maximum ", // (a)
data: filteredData
.filter((p) => typeof p.a === "number")
.map((p) => ({ x: new Date(p.t), y: p.a })),
borderColor: "gray",
borderWidth: 1,
pointRadius: 0,
tension: 0.1,
order: 3,
},
]
: [
{
label: "Messwert Minimum", // (i)
data: filteredData
.filter((p) => typeof p.i === "number")
.map((p) => ({ x: new Date(p.t), y: p.i })),
borderColor: "gray",
borderWidth: 1,
pointRadius: 0,
tension: 0.1,
order: 1,
},
{
label: "Durchschnitt", // (g)
data: filteredData
.filter((p) => typeof p.g === "number")
.map((p) => ({ x: new Date(p.t), y: p.g })),
borderColor: getColor("littwin-blue"),
backgroundColor: "rgba(59,130,246,0.3)",
borderWidth: 2,
pointRadius: 0,
tension: 0.1,
order: 2,
},
{
label: "Messwert Maximum", // (a)
data: filteredData
.filter((p) => typeof p.a === "number")
.map((p) => ({ x: new Date(p.t), y: p.a })),
borderColor: "gray",
borderWidth: 1,
pointRadius: 0,
tension: 0.1,
order: 3,
},
]
: [],
};
}, [filteredData, zeitraum, selectedAnalogInput]);
const memoizedChartOptions = React.useMemo(() => {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: "top" as const },
tooltip: {
mode: "index" as const,
intersect: false,
callbacks: {
label: (context: TooltipItem<"line">) => {
const label = context.dataset.label || "";
return `${label}: ${context.parsed.y}`;
},
title: (items: TooltipItem<"line">[]) => {
const date = items[0].parsed.x;
return `Zeitpunkt: ${new Date(date).toLocaleString("de-DE")}`;
},
},
},
title: {
display: true,
text: selectedAnalogInput?.label
? `Verlauf: ${selectedAnalogInput.label}`
: "Messwert-Verlauf",
},
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" },
min: vonDatum ? new Date(vonDatum).getTime() : undefined,
max: bisDatum ? new Date(bisDatum).getTime() : undefined,
},
y: {
title: {
display: true,
text: `Messwert ${selectedAnalogInput?.unit || ""}`,
},
},
},
};
}, [vonDatum, bisDatum, selectedAnalogInput]);
// ✅ AutoLoad nur beim ersten Laden
useEffect(() => {
if (autoLoad && selectedId) {
dispatch(
getAnalogInputsHistoryThunk({
eingang: selectedId,
zeitraum,
vonDatum,
bisDatum,
})
);
dispatch(setAutoLoad(false));
}
}, [autoLoad, selectedId, dispatch, zeitraum, vonDatum, bisDatum]);
// Dynamisches Importieren von chartjs-plugin-zoom nur im Browser
useEffect(() => {
if (typeof window !== "undefined") {
import("chartjs-plugin-zoom").then((module) => {
ChartJS.register(module.default);
});
}
}, []);
return (
<div
className={`flex flex-col gap-2 h-full ${loading ? "cursor-wait" : ""}`}
>
<div className="flex justify-between items-center p-2 rounded-lg space-x-2 bg-[var(--color-surface-alt)] border border-base">
<div className="flex justify-start">
<Dialog.Title className="text-lg font-semibold text-fg">
Eingang {selectedId ?? ""}
</Dialog.Title>
</div>
<div className="flex justify-end">
<div className="flex flex-wrap items-center gap-4 mb-2">
{/* ✅ Neuer DatePicker mit schönem Styling (lokal, ohne Redux) */}
<AnalogInputsDatePicker
from={localVonDatum}
to={localBisDatum}
onChange={handleDateChange}
/>
{/* ✅ Zeitraum-Auswahl (Listbox nur lokal) */}
<Listbox value={localZeitraum} onChange={setLocalZeitraum}>
<div className="relative w-48">
<Listbox.Button className="w-full border border-base px-3 py-1 rounded bg-[var(--color-surface)] text-fg flex justify-between items-center text-sm">
<span>
{localZeitraum === "DIA0"
? "Alle Messwerte"
: localZeitraum === "DIA1"
? "Stündlich"
: "Täglich"}
</span>
<i className="bi bi-chevron-down text-[var(--color-muted)]" />
</Listbox.Button>
<Listbox.Options className="absolute z-10 mt-1 w-full border border-base bg-[var(--color-surface)] shadow rounded text-sm">
{["DIA0", "DIA1", "DIA2"].map((option) => (
<Listbox.Option
key={option}
value={option}
className="px-4 py-1 cursor-pointer hover:bg-[var(--color-surface-alt)] text-fg"
>
{option === "DIA0"
? "Alle Messwerte"
: option === "DIA1"
? "Stündlich"
: "Täglich"}
</Listbox.Option>
))}
</Listbox.Options>
</div>
</Listbox>
{/* ✅ Button: lädt die Daten & aktualisiert Redux */}
<button
onClick={handleFetchData}
className="btn-primary px-4 py-1 rounded text-sm"
>
Daten laden
</button>
</div>
</div>
</div>
{/* Chart-Anzeige */}
<div className="flex-1 min-h-0 w-full">
{!selectedAnalogInput?.id ? (
<div className="flex items-center justify-center h-full text-fg-secondary text-lg gap-2">
<i className="bi bi-info-circle text-2xl mr-2" />
<span>Bitte Eingang auswählen</span>
</div>
) : (
<Line
ref={chartRef}
data={memoizedChartData}
options={memoizedChartOptions}
style={{ height: "100%", width: "100%" }}
/>
)}
</div>
</div>
);
}