update
This commit is contained in:
+1250
-320
File diff suppressed because it is too large
Load Diff
+69
-33
@@ -1,50 +1,86 @@
|
||||
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:18080/api";
|
||||
function resolveServiceUrl(explicitEnv, fallbackPort, suffix = "") {
|
||||
const explicit = String(explicitEnv || "").trim();
|
||||
if (explicit) {
|
||||
return explicit.replace(/\/$/, "") + suffix;
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(API_URL + path, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.token ? { Authorization: "Bearer " + options.token } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
if (typeof window !== "undefined") {
|
||||
const { protocol, hostname } = window.location;
|
||||
return `${protocol}//${hostname}:${fallbackPort}${suffix}`;
|
||||
}
|
||||
|
||||
return `http://localhost:${fallbackPort}${suffix}`;
|
||||
}
|
||||
|
||||
const API_URL = resolveServiceUrl(import.meta.env.VITE_API_URL, import.meta.env.VITE_API_PORT || "18080", "/api");
|
||||
const COLLECTOR_URL = resolveServiceUrl(import.meta.env.VITE_COLLECTOR_URL, import.meta.env.VITE_COLLECTOR_PORT || "18110");
|
||||
|
||||
async function request(baseUrl, path, options = {}) {
|
||||
const headers = {
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const isJsonBody = options.body !== undefined && !(options.body instanceof FormData);
|
||||
if (isJsonBody) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const response = await fetch(baseUrl + path, {
|
||||
headers,
|
||||
method: options.method || "GET",
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
body: options.body === undefined ? undefined : isJsonBody ? JSON.stringify(options.body) : options.body,
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Request failed.");
|
||||
throw new Error(data.error || "Anfrage fehlgeschlagen.");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
const apiRequest = (path, options) => request(API_URL, path, options);
|
||||
const collectorRequest = (path, options) => request(COLLECTOR_URL, path, options);
|
||||
|
||||
export const api = {
|
||||
login: (body) => request("/auth/login", { method: "POST", body }),
|
||||
me: (token) => request("/auth/me", { token }),
|
||||
health: () => request("/health"),
|
||||
getIsps: (token) => request("/isps", { token }),
|
||||
getAliases: (token, isp) => request("/isps/" + isp + "/aliases", { token }),
|
||||
saveAliases: (token, isp, body) =>
|
||||
request("/isps/" + isp + "/aliases", { method: "PUT", token, body }),
|
||||
importSql: (token, body) => request("/admin/import-sql", { method: "POST", token, body }),
|
||||
getUsers: (token) => request("/users", { token }),
|
||||
createUser: (token, body) => request("/users", { method: "POST", token, body }),
|
||||
updateUser: (token, id, body) => request("/users/" + id, { method: "PATCH", token, body }),
|
||||
getSelections: (token) => request("/selections", { token }),
|
||||
createSelection: (token, body) => request("/selections", { method: "POST", token, body }),
|
||||
updateSelection: (token, id, body) => request("/selections/" + id, { method: "PUT", token, body }),
|
||||
deleteSelection: (token, id) => request("/selections/" + id, { method: "DELETE", token }),
|
||||
queryTrends: (token, body) => request("/trends/query", { method: "POST", token, body }),
|
||||
latestValues: (token, body) => request("/points/latest", { method: "POST", token, body }),
|
||||
getDashboard: (token) => request("/dashboard", { token }),
|
||||
saveDashboard: (token, body) => request("/dashboard", { method: "PUT", token, body }),
|
||||
loadDashboardData: (token, body) => request("/dashboard/data", { method: "POST", token, body }),
|
||||
getPreferences: (token) => request("/preferences", { token }),
|
||||
savePreferences: (token, body) => request("/preferences", { method: "PUT", token, body }),
|
||||
login: (body) => apiRequest("/auth/login", { method: "POST", body }),
|
||||
me: (token) => apiRequest("/auth/me", { token }),
|
||||
changePassword: (token, body) => apiRequest("/auth/change-password", { method: "POST", token, body }),
|
||||
health: () => apiRequest("/health"),
|
||||
getIsps: (token) => apiRequest("/isps", { token }),
|
||||
getAliases: (token, isp) => apiRequest(`/isps/${isp}/aliases`, { token }),
|
||||
saveAliases: (token, isp, body) => apiRequest(`/isps/${isp}/aliases`, { method: "PUT", token, body }),
|
||||
createTrendTable: (token, body) => apiRequest("/trend-tables", { method: "POST", token, body }),
|
||||
normalizeAliasMetadata: (token, body) => apiRequest("/admin/normalize-alias-metadata", { method: "POST", token, body }),
|
||||
importSql: (token, body) => apiRequest("/admin/import-sql", { method: "POST", token, body }),
|
||||
getUsers: (token) => apiRequest("/users", { token }),
|
||||
createUser: (token, body) => apiRequest("/users", { method: "POST", token, body }),
|
||||
updateUser: (token, id, body) => apiRequest(`/users/${id}`, { method: "PATCH", token, body }),
|
||||
getSelections: (token) => apiRequest("/selections", { token }),
|
||||
createSelection: (token, body) => apiRequest("/selections", { method: "POST", token, body }),
|
||||
updateSelection: (token, id, body) => apiRequest(`/selections/${id}`, { method: "PUT", token, body }),
|
||||
deleteSelection: (token, id) => apiRequest(`/selections/${id}`, { method: "DELETE", token }),
|
||||
queryTrends: (token, body) => apiRequest("/trends/query", { method: "POST", token, body }),
|
||||
latestValues: (token, body) => apiRequest("/points/latest", { method: "POST", token, body }),
|
||||
getDashboard: (token) => apiRequest("/dashboard", { token }),
|
||||
saveDashboard: (token, body) => apiRequest("/dashboard", { method: "PUT", token, body }),
|
||||
loadDashboardData: (token, body) => apiRequest("/dashboard/data", { method: "POST", token, body }),
|
||||
getPreferences: (token) => apiRequest("/preferences", { token }),
|
||||
savePreferences: (token, body) => apiRequest("/preferences", { method: "PUT", token, body }),
|
||||
getCollectorCapabilities: () => collectorRequest("/capabilities"),
|
||||
getCollectorSources: () => collectorRequest("/sources"),
|
||||
getCollectorDatapoints: (sourceId = "") => collectorRequest(sourceId ? `/datapoints?sourceId=${encodeURIComponent(sourceId)}` : "/datapoints"),
|
||||
createCollectorSource: (body) => collectorRequest("/sources", { method: "POST", body }),
|
||||
updateCollectorSource: (id, body) => collectorRequest(`/sources/${id}`, { method: "PUT", body }),
|
||||
deleteCollectorSource: (id) => collectorRequest(`/sources/${id}`, { method: "DELETE" }),
|
||||
importModbusCsv: (body) => collectorRequest("/imports/modbus-csv", { method: "POST", body }),
|
||||
importKnxXml: (body) => collectorRequest("/imports/knx-xml", { method: "POST", body }),
|
||||
scanOpcua: (body) => collectorRequest("/scan/opcua", { method: "POST", body }),
|
||||
scanBacnet: (body) => collectorRequest("/scan/bacnet", { method: "POST", body }),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+521
-137
@@ -1,37 +1,43 @@
|
||||
:root {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7fb;
|
||||
--bg: #f4f6fb;
|
||||
--bg-accent: linear-gradient(180deg, #f8fbff 0%, #eef3fb 100%);
|
||||
--surface: rgba(255, 255, 255, 0.92);
|
||||
--surface: rgba(255, 255, 255, 0.96);
|
||||
--surface-strong: #ffffff;
|
||||
--text: #18212f;
|
||||
--muted: #6c7688;
|
||||
--muted: #667085;
|
||||
--border: rgba(37, 99, 235, 0.1);
|
||||
--border-strong: rgba(37, 99, 235, 0.18);
|
||||
--border-strong: rgba(214, 40, 57, 0.24);
|
||||
--brand: #2563eb;
|
||||
--brand-2: #dc2626;
|
||||
--brand-2: #d62839;
|
||||
--danger: #b91c1c;
|
||||
--grid: rgba(37, 99, 235, 0.12);
|
||||
--grid: rgba(214, 40, 57, 0.11);
|
||||
--shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
|
||||
--radius: 14px;
|
||||
--sidebar-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(249, 251, 255, 0.94) 100%);
|
||||
--sidebar-border: rgba(37, 99, 235, 0.08);
|
||||
--sidebar-accent: linear-gradient(180deg, var(--brand-2) 0%, #cf2d5c 55%, var(--brand) 100%);
|
||||
font-family: "Segoe UI", Tahoma, sans-serif;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1724;
|
||||
--bg-accent: linear-gradient(180deg, #101827 0%, #0c1420 100%);
|
||||
--surface: rgba(14, 22, 36, 0.95);
|
||||
--surface-strong: #121b2b;
|
||||
--bg: #0e1624;
|
||||
--bg-accent: linear-gradient(180deg, #101827 0%, #0b1320 100%);
|
||||
--surface: rgba(16, 24, 38, 0.96);
|
||||
--surface-strong: #121b2a;
|
||||
--text: #edf2f7;
|
||||
--muted: #93a0b5;
|
||||
--muted: #94a3b8;
|
||||
--border: rgba(96, 165, 250, 0.12);
|
||||
--border-strong: rgba(96, 165, 250, 0.22);
|
||||
--brand: #60a5fa;
|
||||
--brand-2: #f87171;
|
||||
--border-strong: rgba(248, 113, 113, 0.24);
|
||||
--brand: #4f8df7;
|
||||
--brand-2: #ef4444;
|
||||
--danger: #f87171;
|
||||
--grid: rgba(96, 165, 250, 0.16);
|
||||
--shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
|
||||
--grid: rgba(239, 68, 68, 0.15);
|
||||
--shadow: 0 14px 36px rgba(0, 0, 0, 0.28);
|
||||
--sidebar-bg: linear-gradient(180deg, rgba(15, 23, 38, 0.98) 0%, rgba(10, 16, 28, 0.96) 100%);
|
||||
--sidebar-border: rgba(148, 163, 184, 0.12);
|
||||
--sidebar-accent: linear-gradient(180deg, #ef4444 0%, #dc3b68 55%, #4f8df7 100%);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -48,7 +54,7 @@ body {
|
||||
margin: 0;
|
||||
background: var(--bg-accent);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
button,
|
||||
@@ -70,28 +76,36 @@ textarea {
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
padding: 0.46rem 0.62rem;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
border-color: var(--brand-2);
|
||||
box-shadow: 0 0 0 1px rgba(214, 40, 57, 0.18);
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
gap: 0.28rem;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
small,
|
||||
p,
|
||||
span {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.login-screen {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.25rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-card,
|
||||
@@ -103,33 +117,34 @@ label {
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(100%, 340px);
|
||||
width: min(100%, 336px);
|
||||
border-radius: 18px;
|
||||
padding: 1rem;
|
||||
padding: 0.95rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.logo-lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
gap: 0.62rem;
|
||||
}
|
||||
|
||||
.logo-lockup img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
font-size: 15px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--brand-2);
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -141,45 +156,173 @@ label {
|
||||
.point-item,
|
||||
.selection-card,
|
||||
.chip,
|
||||
.icon-button {
|
||||
.icon-button,
|
||||
.legend-item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-strong);
|
||||
color: var(--text);
|
||||
padding: 0.5rem 0.7rem;
|
||||
padding: 0.42rem 0.64rem;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.ghost-button,
|
||||
.nav-button,
|
||||
.theme-option,
|
||||
.icon-button,
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: linear-gradient(135deg, var(--brand), var(--brand-2));
|
||||
background: linear-gradient(135deg, var(--brand-2) 0%, #b8275a 62%, var(--brand) 100%);
|
||||
border-color: transparent;
|
||||
color: white;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ghost-button,
|
||||
.nav-button,
|
||||
.theme-option,
|
||||
.icon-button {
|
||||
.icon-button,
|
||||
.legend-item {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-button.active,
|
||||
.theme-option.active {
|
||||
border-color: var(--brand-2);
|
||||
.theme-option.active,
|
||||
.legend-item.active {
|
||||
border-color: rgba(214, 40, 57, 0.35);
|
||||
color: var(--brand-2);
|
||||
background: rgba(214, 40, 57, 0.08);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.icon-button svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.icon-button.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
grid-template-columns: 188px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell.trend-focus {
|
||||
grid-template-columns: 72px 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding: 0.9rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
padding: 0.65rem 0.55rem 0.7rem;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 1rem;
|
||||
border-right: 1px solid var(--border);
|
||||
gap: 0.75rem;
|
||||
width: 188px;
|
||||
overflow: hidden;
|
||||
background: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--sidebar-border);
|
||||
transition: width 160ms ease, padding 160ms ease;
|
||||
}
|
||||
|
||||
.sidebar::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 5px;
|
||||
background: var(--sidebar-accent);
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 64px;
|
||||
padding-inline: 0.35rem;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-top {
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-brand-row {
|
||||
width: 100%;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .logo-lockup {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .logo-lockup img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-list {
|
||||
width: 100%;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-button {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sidebar-top {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.sidebar-footer,
|
||||
.user-card {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.sidebar-brand-row,
|
||||
.panel-header,
|
||||
.point-item,
|
||||
.selection-card,
|
||||
.user-row,
|
||||
.stat-card,
|
||||
.chart-toolbar,
|
||||
.chart-actions,
|
||||
.view-strip,
|
||||
.alias-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-brand-row,
|
||||
.panel-header,
|
||||
.view-strip {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sidebar-brand-row {
|
||||
padding: 0.12rem 0.08rem 0.52rem 0.15rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-list,
|
||||
@@ -188,57 +331,90 @@ label {
|
||||
.selection-chips,
|
||||
.latest-grid,
|
||||
.theme-toggle,
|
||||
.selection-actions,
|
||||
.topbar-actions {
|
||||
.view-meta,
|
||||
.wrap-tight,
|
||||
.chart-actions,
|
||||
.legend-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-footer,
|
||||
.nav-button {
|
||||
min-height: 34px;
|
||||
padding: 0.38rem 0.58rem;
|
||||
border-radius: 12px;
|
||||
justify-content: flex-start;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding-top: 0.1rem;
|
||||
}
|
||||
|
||||
.user-card span,
|
||||
.panel p,
|
||||
.stat-time,
|
||||
.muted,
|
||||
.point-item span,
|
||||
.selection-card span {
|
||||
.selection-card span,
|
||||
.muted,
|
||||
.tiny-note {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0.9rem;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
.tiny-note {
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.7rem;
|
||||
align-items: center;
|
||||
.sidebar-footer {
|
||||
align-self: end;
|
||||
padding-top: 0.45rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-logout {
|
||||
justify-self: start;
|
||||
border-radius: 999px;
|
||||
padding-inline: 0.7rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0.55rem 0.65rem 0.7rem;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
align-content: start;
|
||||
grid-auto-rows: max-content;
|
||||
}
|
||||
|
||||
.view-strip {
|
||||
padding: 0.02rem 0.08rem 0;
|
||||
}
|
||||
|
||||
.small-badge,
|
||||
.small-label,
|
||||
.point-tag,
|
||||
.alias-index {
|
||||
font-size: 10px;
|
||||
color: var(--brand-2);
|
||||
}
|
||||
|
||||
.topbar h1,
|
||||
.panel h3,
|
||||
.login-card h1 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.grid.two,
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
gap: 0.6rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.grid.two {
|
||||
@@ -246,32 +422,47 @@ label {
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: var(--radius);
|
||||
padding: 0.8rem;
|
||||
padding: 0.64rem;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
gap: 0.58rem;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.7rem;
|
||||
align-items: center;
|
||||
.panel > .primary-button,
|
||||
.panel > .ghost-button {
|
||||
justify-self: start;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
margin: 0;
|
||||
.login-card > .primary-button {
|
||||
width: 100%;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.compact-button {
|
||||
min-height: 30px;
|
||||
padding: 0.36rem 0.68rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.compact-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.compact-header p,
|
||||
.view-strip p {
|
||||
margin: 0.08rem 0 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.6rem;
|
||||
gap: 0.52rem;
|
||||
}
|
||||
|
||||
.span-two {
|
||||
@@ -283,96 +474,187 @@ label {
|
||||
.user-list,
|
||||
.selection-list {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
gap: 0.36rem;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.point-item,
|
||||
.selection-card,
|
||||
.stack-card,
|
||||
.user-row,
|
||||
.stat-card,
|
||||
.gauge-card {
|
||||
background: var(--surface-strong);
|
||||
.selection-list.compact,
|
||||
.source-list,
|
||||
.datapoint-preview .selection-list {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.point-item,
|
||||
.selection-card,
|
||||
.user-row,
|
||||
.stat-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
.alias-table {
|
||||
max-height: 560px;
|
||||
}
|
||||
|
||||
.stack-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.45rem;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.selection-card,
|
||||
.point-item,
|
||||
.user-row,
|
||||
.stat-card,
|
||||
.legend-item,
|
||||
.chip {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.source-card.active {
|
||||
border-color: rgba(214, 40, 57, 0.38);
|
||||
background: rgba(214, 40, 57, 0.08);
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.selection-card,
|
||||
.point-item,
|
||||
.user-row,
|
||||
.stat-card {
|
||||
padding: 0.55rem 0.65rem;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.58rem;
|
||||
}
|
||||
|
||||
.selection-card {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.point-tag,
|
||||
.alias-index,
|
||||
.small-label {
|
||||
color: var(--brand-2);
|
||||
font-size: 11px;
|
||||
.point-copy,
|
||||
.user-copy {
|
||||
display: grid;
|
||||
gap: 0.06rem;
|
||||
}
|
||||
|
||||
.selection-chips {
|
||||
min-height: 42px;
|
||||
min-height: 0;
|
||||
gap: 0.34rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
text-align: left;
|
||||
background: rgba(37, 99, 235, 0.06);
|
||||
gap: 0.12rem;
|
||||
border-left: 3px solid var(--brand-2);
|
||||
min-width: 94px;
|
||||
padding: 0.34rem 0.42rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.chip strong,
|
||||
.point-copy strong,
|
||||
.selection-card strong,
|
||||
.user-card strong,
|
||||
.user-copy strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chip span {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chart-panel {
|
||||
min-height: 390px;
|
||||
}
|
||||
|
||||
.chart-panel.focus-mode {
|
||||
min-height: calc(100vh - 1.2rem);
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
width: 100%;
|
||||
min-height: 320px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chart-panel:fullscreen {
|
||||
padding: 1rem;
|
||||
background: var(--surface-strong);
|
||||
.chart-wrapper.dragging {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.chart-host {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chart-toolbar {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.chart-actions {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chart-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.32rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chart-check input {
|
||||
width: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.legend-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.55rem;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-grid {
|
||||
gap: 0.34rem;
|
||||
flex: 1 1 360px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
padding: 0.32rem 0.48rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.legend-item.dimmed {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.legend-side-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.latest-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 12px;
|
||||
.stat-card,
|
||||
.gauge-card,
|
||||
.user-row {
|
||||
border: 1px solid var(--border);
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.stat-card,
|
||||
.latest-card {
|
||||
border-left: 4px solid var(--brand-2);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
font-size: 20px;
|
||||
gap: 0.32rem;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -381,12 +663,17 @@ label {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.value-stamp {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
margin-top: 0.08rem;
|
||||
}
|
||||
|
||||
.gauge-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem;
|
||||
padding: 0.65rem;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
gap: 0.22rem;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
@@ -397,7 +684,7 @@ label {
|
||||
|
||||
.gauge-track {
|
||||
fill: none;
|
||||
stroke: rgba(37, 99, 235, 0.18);
|
||||
stroke: rgba(214, 40, 57, 0.16);
|
||||
stroke-width: 12;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
@@ -412,43 +699,60 @@ label {
|
||||
fill: var(--brand);
|
||||
}
|
||||
|
||||
.gauge-current {
|
||||
color: var(--brand-2);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gauge-value {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: baseline;
|
||||
font-size: 18px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gauge-range {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.gauge-range,
|
||||
.gauge-label,
|
||||
.gauge-stamp {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.gauge-label {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.alias-row {
|
||||
display: grid;
|
||||
grid-template-columns: 64px 1.2fr 0.7fr 0.55fr 0.55fr;
|
||||
gap: 0.4rem;
|
||||
grid-template-columns: 92px 1.55fr 0.8fr 0.65fr 0.65fr 0.8fr;
|
||||
gap: 0.58rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
.alias-head {
|
||||
padding: 0 0.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.unit-field,
|
||||
.color-field {
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
}
|
||||
|
||||
.color-field {
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.color-field input[type="color"] {
|
||||
padding: 0.12rem;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.error-box,
|
||||
.success-box {
|
||||
padding: 0.55rem 0.65rem;
|
||||
padding: 0.5rem 0.62rem;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -471,6 +775,66 @@ label {
|
||||
|
||||
.checkbox-field input {
|
||||
width: auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.file-button {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-button input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-section {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.admin-subnav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.admin-subnav .nav-button {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.protocol-panel,
|
||||
.protocol-block,
|
||||
.datapoint-preview {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.protocol-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.52rem;
|
||||
}
|
||||
|
||||
.protocol-block {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0.56rem;
|
||||
background: color-mix(in srgb, var(--surface-strong) 88%, transparent);
|
||||
}
|
||||
|
||||
.inline-fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 88px auto;
|
||||
gap: 0.36rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inline-fields.multi-line {
|
||||
grid-template-columns: minmax(0, 1fr) 88px auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
@@ -478,14 +842,34 @@ label {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.slim-empty {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell {
|
||||
.app-shell,
|
||||
.app-shell.trend-focus {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.sidebar.collapsed {
|
||||
position: static;
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.grid.two,
|
||||
.form-grid,
|
||||
.alias-row {
|
||||
.alias-row,
|
||||
.protocol-grid,
|
||||
.inline-fields,
|
||||
.inline-fields.multi-line {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user