This commit is contained in:
jhartworks
2026-06-23 10:59:58 +02:00
parent 9b651f1cd6
commit a445fef521
21 changed files with 3161 additions and 664 deletions
+4 -2
View File
@@ -1,4 +1,4 @@
export default function Gauge({ value, min = 0, max = 100, label, unit }) {
export default function Gauge({ value, min = 0, max = 100, label, unit, timestamp }) {
const safeMin = Number.isFinite(Number(min)) ? Number(min) : 0;
const safeMax = Number.isFinite(Number(max)) ? Number(max) : 100;
const safeValue = Number.isFinite(Number(value)) ? Number(value) : safeMin;
@@ -9,6 +9,7 @@ export default function Gauge({ value, min = 0, max = 100, label, unit }) {
const cy = 110;
const x = cx + Math.cos(radians) * 72;
const y = cy + Math.sin(radians) * 72;
const timestampLabel = timestamp ? new Date(timestamp).toLocaleString("de-DE") : "Aktueller Wert";
return (
<div className="gauge-card">
@@ -20,6 +21,7 @@ export default function Gauge({ value, min = 0, max = 100, label, unit }) {
/>
<circle cx="110" cy="110" r="7" className="gauge-center" />
</svg>
<div className="gauge-current">Aktuell</div>
<div className="gauge-value">
<span>{Number.isFinite(safeValue) ? safeValue.toFixed(2) : "--"}</span>
<small>{unit}</small>
@@ -29,7 +31,7 @@ export default function Gauge({ value, min = 0, max = 100, label, unit }) {
<span>{safeMin}</span>
<span>{safeMax}</span>
</div>
<div className="gauge-stamp">{timestampLabel}</div>
</div>
);
}
+410 -58
View File
@@ -1,93 +1,445 @@
import { useEffect, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { jsPDF } from "jspdf";
import * as XLSX from "xlsx";
import {
CartesianGrid,
Legend,
Line,
LineChart,
ReferenceArea,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
const COLORS = ["#2563eb", "#dc2626", "#1d4ed8", "#b91c1c", "#3b82f6", "#ef4444"];
function downloadBlob(filename, blob) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
export default function TrendChart({ result, title = "Trendansicht" }) {
const hostRef = useRef(null);
const [isFullscreen, setIsFullscreen] = useState(false);
function quoteCell(value) {
return `"${String(value ?? "").replaceAll('"', '""')}"`;
}
useEffect(() => {
const onChange = () => {
setIsFullscreen(document.fullscreenElement === hostRef.current);
};
function buildRandomColor(index) {
const hue = Math.round((Math.random() * 360 + index * 41) % 360);
const saturation = 72 + (index % 3) * 4;
const lightness = 46 + (index % 4) * 3;
return `hsl(${hue} ${saturation}% ${lightness}%)`;
}
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);
export default function TrendChart({
result,
title = "Trendansicht",
expanded = false,
onToggleExpanded,
csvDelimiter = ";",
onCsvDelimiterChange,
allowExports = true,
allowFocusMode = true,
compact = false,
}) {
const wrapperRef = useRef(null);
const chartHostRef = useRef(null);
const [selectedKeys, setSelectedKeys] = useState([]);
const [dragBox, setDragBox] = useState(null);
const [zoomDomain, setZoomDomain] = useState(null);
const [exportFormat, setExportFormat] = useState("csv");
const [transparentBackground, setTransparentBackground] = useState(false);
if (!result?.rows?.length) {
const series = useMemo(() => {
const fallbackColors = [];
return (result?.series || []).map((item, index) => ({
...item,
color: item.color || (fallbackColors[index] ||= buildRandomColor(index)),
name: `${item.displayName} · ${item.alias}${item.unit ? ` (${item.unit})` : ""}`,
}));
}, [result]);
const rows = useMemo(() => (result?.rows || []).map((row) => ({
...row,
timestampMs: new Date(row.timestamp).getTime(),
})), [result]);
const visibleSeries = selectedKeys.length ? series.filter((item) => selectedKeys.includes(item.key)) : series;
const baseXDomain = useMemo(() => {
if (!rows.length) {
return [0, 1];
}
return [rows[0].timestampMs, rows[rows.length - 1].timestampMs];
}, [rows]);
const baseYDomain = useMemo(() => {
const values = [];
rows.forEach((row) => {
visibleSeries.forEach((item) => {
const value = row[item.key];
if (Number.isFinite(value)) {
values.push(value);
}
});
});
if (!values.length) {
return [0, 1];
}
const min = Math.min(...values);
const max = Math.max(...values);
if (min === max) {
return [min - 1, max + 1];
}
const padding = (max - min) * 0.08;
return [min - padding, max + padding];
}, [rows, visibleSeries]);
const xDomain = zoomDomain?.x || baseXDomain;
const yDomain = zoomDomain?.y || baseYDomain;
const hasFocusSelection = selectedKeys.length > 0 && selectedKeys.length < series.length;
if (!rows.length) {
return (
<div className="panel chart-panel empty-state">
<div className={compact ? "panel chart-panel compact-chart empty-state" : "panel chart-panel empty-state"}>
<h3>{title}</h3>
<p>Keine Daten im gewaehlten Zeitraum.</p>
<p>Keine Daten im gewählten Zeitraum.</p>
</div>
);
}
const rows = result.rows.map((row) => ({
...row,
timestampLabel: new Date(row.timestamp).toLocaleString("de-DE"),
}));
const mapDragToDomain = (clientX, clientY) => {
const rect = wrapperRef.current?.getBoundingClientRect();
if (!rect) {
return null;
}
const toggleFullscreen = async () => {
if (!hostRef.current) {
const xRatio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const yRatio = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
const xValue = xDomain[0] + (xDomain[1] - xDomain[0]) * xRatio;
const yValue = yDomain[1] - (yDomain[1] - yDomain[0]) * yRatio;
return {
xValue,
yValue,
xPixel: clientX - rect.left,
yPixel: clientY - rect.top,
};
};
const handleMouseDown = (event) => {
if (event.button !== 0) {
return;
}
const mapped = mapDragToDomain(event.clientX, event.clientY);
if (!mapped) {
return;
}
setDragBox({ start: mapped, end: mapped });
};
const handleMouseMove = (event) => {
if (!dragBox) {
return;
}
const mapped = mapDragToDomain(event.clientX, event.clientY);
if (!mapped) {
return;
}
setDragBox((current) => current ? { ...current, end: mapped } : current);
};
const handleMouseUp = () => {
if (!dragBox) {
return;
}
if (document.fullscreenElement === hostRef.current) {
await document.exitFullscreen();
const deltaX = Math.abs(dragBox.end.xPixel - dragBox.start.xPixel);
const deltaY = Math.abs(dragBox.end.yPixel - dragBox.start.yPixel);
if (deltaX < 12 || deltaY < 12) {
setDragBox(null);
return;
}
await hostRef.current.requestFullscreen();
setZoomDomain({
x: [Math.min(dragBox.start.xValue, dragBox.end.xValue), Math.max(dragBox.start.xValue, dragBox.end.xValue)],
y: [Math.min(dragBox.start.yValue, dragBox.end.yValue), Math.max(dragBox.start.yValue, dragBox.end.yValue)],
});
setDragBox(null);
};
const resetZoom = () => {
setZoomDomain(null);
};
const clearFocus = () => {
setSelectedKeys([]);
};
const handleLegendClick = (event, key) => {
if (event.ctrlKey || event.metaKey) {
setSelectedKeys((current) => current.includes(key) ? current.filter((item) => item !== key) : current.concat(key));
return;
}
setSelectedKeys((current) => current.length === 1 && current[0] === key ? [] : [key]);
};
const buildTabularRows = () => rows.map((row) => {
const item = {
Zeitstempel: new Date(row.timestampMs).toLocaleString("de-DE"),
ISO: new Date(row.timestampMs).toISOString(),
};
series.forEach((entry) => {
item[entry.name] = row[entry.key] ?? "";
});
return item;
});
const exportCsv = () => {
const headers = ["timestamp"].concat(series.map((entry) => entry.name));
const lines = [headers]
.concat(rows.map((row) => [
new Date(row.timestampMs).toISOString(),
...series.map((entry) => row[entry.key] ?? ""),
]))
.map((line) => line.map((cell) => quoteCell(cell)).join(csvDelimiter));
downloadBlob("trend-export.csv", new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8;" }));
};
const exportXlsx = () => {
const worksheet = XLSX.utils.json_to_sheet(buildTabularRows());
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "Trend");
XLSX.writeFile(workbook, "trend-export.xlsx");
};
const getSvgMarkup = (transparent = false) => {
const svg = chartHostRef.current?.querySelector("svg");
if (!svg) {
throw new Error("SVG für Export nicht gefunden.");
}
const clone = svg.cloneNode(true);
const rect = svg.getBoundingClientRect();
const width = Math.max(900, Math.round(rect.width));
const height = Math.max(360, Math.round(rect.height));
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
clone.setAttribute("width", String(width));
clone.setAttribute("height", String(height));
clone.setAttribute("viewBox", `0 0 ${width} ${height}`);
if (!transparent) {
const background = document.createElementNS("http://www.w3.org/2000/svg", "rect");
background.setAttribute("x", "0");
background.setAttribute("y", "0");
background.setAttribute("width", String(width));
background.setAttribute("height", String(height));
background.setAttribute("fill", "#ffffff");
clone.insertBefore(background, clone.firstChild);
}
return new XMLSerializer().serializeToString(clone);
};
const svgToImage = async (type = "png", transparent = false) => {
const svgMarkup = getSvgMarkup(transparent && type === "png");
const svgBlob = new Blob([svgMarkup], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svgBlob);
const image = new Image();
const rect = chartHostRef.current?.getBoundingClientRect();
const width = Math.max(1200, Math.round(rect?.width || 1200));
const height = Math.max(expanded ? 720 : 420, Math.round(rect?.height || 420));
const dataUrl = await new Promise((resolve, reject) => {
image.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!transparent) {
context.fillStyle = "#ffffff";
context.fillRect(0, 0, width, height);
}
context.drawImage(image, 0, 0, width, height);
URL.revokeObjectURL(url);
resolve(canvas.toDataURL(type === "jpg" ? "image/jpeg" : "image/png", 0.95));
};
image.onerror = reject;
image.src = url;
});
return dataUrl;
};
const exportGraphic = async () => {
if (exportFormat === "svg") {
downloadBlob("trend-export.svg", new Blob([getSvgMarkup(transparentBackground)], { type: "image/svg+xml;charset=utf-8" }));
return;
}
if (exportFormat === "png" || exportFormat === "jpg") {
const dataUrl = await svgToImage(exportFormat, exportFormat === "png" ? transparentBackground : false);
const response = await fetch(dataUrl);
const blob = await response.blob();
downloadBlob(`trend-export.${exportFormat}`, blob);
return;
}
if (exportFormat === "pdf") {
const dataUrl = await svgToImage("png", false);
const image = new Image();
await new Promise((resolve) => {
image.onload = resolve;
image.src = dataUrl;
});
const pdf = new jsPDF({ orientation: "landscape", unit: "pt", format: [image.width, image.height] });
pdf.addImage(dataUrl, "PNG", 0, 0, image.width, image.height);
pdf.save("trend-export.pdf");
}
};
const handleExport = async () => {
if (exportFormat === "csv") {
exportCsv();
return;
}
if (exportFormat === "xlsx") {
exportXlsx();
return;
}
await exportGraphic();
};
return (
<div ref={hostRef} className="panel chart-panel">
<div className="panel-header">
<h3>{title}</h3>
<button className="ghost-button" type="button" onClick={toggleFullscreen}>
{isFullscreen ? "Zurueck" : "Vollbild"}
</button>
<div className={expanded ? "panel chart-panel focus-mode" : compact ? "panel chart-panel compact-chart" : "panel chart-panel"}>
<div className="panel-header compact-header chart-toolbar">
<div>
<h3>{title}</h3>
<p>Ziehen mit gedrückter Maustaste zoomt Zeit und Wertebereich gemeinsam.</p>
</div>
<div className="chart-actions">
{allowExports ? (
<>
<select value={exportFormat} onChange={(event) => setExportFormat(event.target.value)}>
<option value="csv">CSV</option>
<option value="xlsx">XLSX</option>
<option value="png">PNG</option>
<option value="jpg">JPG</option>
<option value="svg">SVG</option>
<option value="pdf">PDF</option>
</select>
{exportFormat === "csv" ? (
<select value={csvDelimiter} onChange={(event) => onCsvDelimiterChange?.(event.target.value)}>
<option value=";">; Trennzeichen</option>
<option value=",">, Trennzeichen</option>
<option value="\t">Tab Trennzeichen</option>
</select>
) : null}
{(exportFormat === "png" || exportFormat === "svg") ? (
<label className="chart-check">
<input type="checkbox" checked={transparentBackground} onChange={(event) => setTransparentBackground(event.target.checked)} />
<span>Transparent</span>
</label>
) : null}
<button className="ghost-button" type="button" onClick={handleExport}>Export</button>
</>
) : null}
{zoomDomain ? <button className="ghost-button" type="button" onClick={resetZoom}>Zoom zurück</button> : null}
{allowFocusMode ? <button className="ghost-button" type="button" onClick={onToggleExpanded}>{expanded ? "Normalansicht" : "Fokus"}</button> : null}
</div>
</div>
<div className="chart-wrapper">
<ResponsiveContainer width="100%" height={isFullscreen ? 520 : 320}>
<LineChart data={rows}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--grid)" />
<XAxis dataKey="timestampLabel" minTickGap={30} stroke="var(--muted)" />
<YAxis stroke="var(--muted)" />
<Tooltip
contentStyle={{
background: "var(--surface-strong)",
border: "1px solid var(--border-strong)",
borderRadius: 10,
fontSize: 12,
}}
/>
<Legend />
{result.series.map((series, index) => (
<Line
key={series.key}
type="monotone"
dataKey={series.key}
dot={false}
strokeWidth={1.75}
stroke={COLORS[index % COLORS.length]}
name={series.displayName + " / " + series.alias + (series.unit ? " (" + series.unit + ")" : "")}
<div
ref={wrapperRef}
className={dragBox ? "chart-wrapper dragging" : "chart-wrapper"}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div ref={chartHostRef} className="chart-host">
<ResponsiveContainer width="100%" height={expanded ? 680 : compact ? 280 : 360}>
<LineChart data={rows} margin={{ top: 8, right: 18, left: 0, bottom: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--grid)" />
<XAxis
dataKey="timestampMs"
type="number"
scale="time"
domain={xDomain}
tickFormatter={(value) => new Date(value).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
minTickGap={28}
stroke="var(--muted)"
/>
))}
</LineChart>
</ResponsiveContainer>
<YAxis stroke="var(--muted)" domain={yDomain} width={52} />
<Tooltip
labelFormatter={(value) => new Date(value).toLocaleString("de-DE")}
contentStyle={{
background: "var(--surface-strong)",
border: "1px solid var(--border-strong)",
borderRadius: 10,
fontSize: 12,
}}
/>
{dragBox ? (
<ReferenceArea
x1={Math.min(dragBox.start.xValue, dragBox.end.xValue)}
x2={Math.max(dragBox.start.xValue, dragBox.end.xValue)}
y1={Math.min(dragBox.start.yValue, dragBox.end.yValue)}
y2={Math.max(dragBox.start.yValue, dragBox.end.yValue)}
strokeOpacity={0.5}
fillOpacity={0.12}
fill="var(--brand-2)"
/>
) : null}
{series.map((entry) => {
const isolated = selectedKeys.length > 0 && !selectedKeys.includes(entry.key);
return (
<Line
key={entry.key}
type="monotone"
dataKey={entry.key}
dot={false}
strokeWidth={selectedKeys.includes(entry.key) ? 2.35 : 1.8}
stroke={entry.color}
strokeOpacity={isolated ? 0.1 : 1}
isAnimationActive={false}
name={entry.name}
/>
);
})}
</LineChart>
</ResponsiveContainer>
</div>
</div>
<div className="legend-row">
<div className="legend-grid">
{series.map((entry) => {
const dimmed = selectedKeys.length > 0 && !selectedKeys.includes(entry.key);
const active = selectedKeys.includes(entry.key);
return (
<button
key={entry.key}
type="button"
className={active ? "legend-item active" : dimmed ? "legend-item dimmed" : "legend-item"}
onClick={(event) => handleLegendClick(event, entry.key)}
>
<span className="legend-swatch" style={{ background: entry.color }} />
<span>{entry.name}</span>
</button>
);
})}
</div>
{hasFocusSelection ? (
<div className="legend-side-actions">
<button className="ghost-button" type="button" onClick={clearFocus}>Alle Werte</button>
</div>
) : null}
</div>
</div>
);