Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6b5b24b17 | ||
|
|
cde715894e | ||
|
|
adaadeabff | ||
|
|
b66214678d |
No files matched your search
Generated
+7
-7
@@ -657,12 +657,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctrlc"
|
||||
version = "3.5.2"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
|
||||
checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"nix 0.31.2",
|
||||
"nix 0.30.1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -1485,9 +1485,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.182"
|
||||
version = "0.2.177"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@@ -1709,9 +1709,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.2"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
|
||||
+12
-52
@@ -1,8 +1,8 @@
|
||||
# REST API `/api/v2`
|
||||
|
||||
The panel behaves like any other NetworkManager-managed linux and is able to either receive an IP-Address over DHCP or a statically defined one. Where the static IP is most likely the easiest to work with.
|
||||
The Qitech Control Panel exposes a small HTTP interface for discovering machines and reading their current values. All examples below assume you are connected to the panel’s Ethernet subnet and talk directly to the panel at:
|
||||
|
||||
- **Base URL:** `https://IP_ADDRESS`
|
||||
- **Base URL:** `http://10.10.10.1:3001`
|
||||
|
||||
> **Schema note:** This documentation intentionally stays light on field details beyond what is shown in the examples. For the exact datatypes and complete payload shapes, refer to the corresponding Rust types (linked below).
|
||||
|
||||
@@ -10,57 +10,17 @@ The panel behaves like any other NetworkManager-managed linux and is able to eit
|
||||
|
||||
## Authentication
|
||||
|
||||
The panel performs Basic-Auth authentication.
|
||||
For EVERY request made over HTTPS, Basic Auth is required.
|
||||
The panel does **not** perform HTTP authentication. The expected security model is **network-level isolation**: anything that can send packets to the panel’s Ethernet interface is treated as trusted. This matches common security assumptions in EtherCAT-style control networks. It is therefore suggested to run the entire production line in isolated operation.
|
||||
|
||||
To set the password on the Panel for Basic Auth, do the following:
|
||||
1. Open a Terminal
|
||||
2. cd /home/qitech/control
|
||||
3. ./credentials.sh
|
||||
The panel is configured to administer its own subnet `10.10.10.0/24` via DHCP, while Wi-Fi can be used for upstream internet connectivity. To let the outside world communicate with the production line, a router should be placed at the network bondary bridging the production line's subnet and the rest of the network. The router can run in **client/bridge mode** on the line's subnet and expose the panel's API via **port forwading** or through a **reverse proxy** where one can add authentication, logging, rate limiting, etc.
|
||||
|
||||
The credentials.sh script sets and prints the username and password for the api ONCE in the console.
|
||||
Afterwards its accessible in machine_password in /tmp/ until the machine shuts down.
|
||||
If you ever forget your password you can simply run ./credentials.sh again, resetting the password used, however the username will always be machine
|
||||
The following graphic depicts the suggested network topology for a single production line. Many lines could potentially share the same router.
|
||||
|
||||
Example request with curl:
|
||||
```
|
||||
# username=machine
|
||||
# password=VoMZKZK4K4ZIvON+OU2ZcCXy
|
||||
# With -k you can ignore curl's self signed certificate error
|
||||
curl https://192.168.130.195/api/v2/machine -k -u machine:VoMZKZK4K4ZIvON+OU2ZcCXy
|
||||
```
|
||||
|
||||
|
||||
## Handling Self signed Certificate Warnings
|
||||
|
||||
For HTTPS we use self-signed certificates.
|
||||
These are untrusted by default in browsers,tools like curl and https libraries.
|
||||
|
||||
For most programs you can either ignore this warning or you can manually trust the certificate on your system.
|
||||
How these are installed depends on your system.
|
||||
|
||||
On the panel pc, the root Certificate is located at: /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt
|
||||
|
||||
You need to copy this file to an usb drive or copy it some other way and add it as a trusted root certificate in your OS.
|
||||
|
||||
Please refer to your OS' documentation or search the Internet about how to install a trusted SSL certificate.
|
||||
|
||||
|
||||
## Bypassing HTTPS and Authentication
|
||||
If you have an isolated network and dont wish to perform Authentication, then you can achieve this by:
|
||||
1. Forking our repository
|
||||
2. Changing the Open Ports in nixos/os/configuration.nix:
|
||||
```
|
||||
# All the way at the Bottom
|
||||
networking.firewall.allowedUDPPorts = [ 53 67 69 ];
|
||||
# Change this
|
||||
networking.firewall.allowedTCPPorts = [ 443 ];
|
||||
# To this
|
||||
networking.firewall.allowedTCPPorts = [ 3001 ];
|
||||
```
|
||||
|
||||
Again, you should only ever do this if you have your own reverse proxy with authentication or you have an isolated network.
|
||||
<p align="center">
|
||||
<img width="769" height="528" alt="Suggested network topology" src="./assets/subnet.png" />
|
||||
</p>
|
||||
|
||||
If DNS is available on the subnet, you may be able to resolve `qitech.control`; otherwise use the static address `10.10.10.1`.
|
||||
|
||||
---
|
||||
|
||||
@@ -78,7 +38,7 @@ Each machine also includes a `legacy_id` to support older **v1** workflows. If a
|
||||
### Example request
|
||||
|
||||
```bash
|
||||
curl -X GET "https://IP_ADDRESS/api/v2/machine" -u machine:YOUR_PASSWORD
|
||||
curl -X GET "http://10.10.10.1:3001/api/v2/machine"
|
||||
```
|
||||
|
||||
### Example response
|
||||
@@ -159,7 +119,7 @@ To receive continuous updates (via WebSockets), subscribe to the machine namespa
|
||||
### Example request (mock machine)
|
||||
|
||||
```bash
|
||||
curl -X GET "https://IP_ADDRESS/api/v2/machine/mock/57922" -u machine:YOUR_PASSWORD
|
||||
curl -X GET "http://10.10.10.1:3001/api/v2/machine/mock/57922"
|
||||
```
|
||||
|
||||
### Example response
|
||||
@@ -214,7 +174,7 @@ The API does **not** return the newly-applied state in the POST response. The pa
|
||||
curl -X POST \
|
||||
-d \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://IP_ADDRESS/api/v1/machine/mock/57922" -u machine:YOUR_PASSWORD
|
||||
"http://10.10.10.1:3001/api/v1/machine/mock/57922"
|
||||
```
|
||||
|
||||
### Example response
|
||||
|
||||
Generated
+82
-15
@@ -4698,8 +4698,15 @@
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
"deep-eql": "^5.0.1",
|
||||
"loupe": "^3.1.0",
|
||||
"pathval": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
@@ -4759,6 +4766,16 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/class-variance-authority": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||
@@ -5040,6 +5057,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -7943,6 +7970,13 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lowercase-keys": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
|
||||
@@ -8920,6 +8954,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -9299,6 +9345,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathval": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
@@ -10439,8 +10495,31 @@
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.0",
|
||||
"seroval": "~1.5.0",
|
||||
"seroval-plugins": "~1.5.0"
|
||||
"seroval": "~1.3.0",
|
||||
"seroval-plugins": "~1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/solid-js/node_modules/seroval": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
|
||||
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/solid-js/node_modules/seroval-plugins": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.3.tgz",
|
||||
"integrity": "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"seroval": "^1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
@@ -11241,18 +11320,6 @@
|
||||
"yaku": "^0.16.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unzip-crx-3/node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",
|
||||
|
||||
@@ -111,10 +111,26 @@ export function BigGraph({
|
||||
|
||||
// State for series visibility
|
||||
const normalizedSeries = normalizeDataSeries(newData);
|
||||
const latestExportContextRef = useRef({
|
||||
normalizedSeries,
|
||||
config,
|
||||
unit,
|
||||
renderValue,
|
||||
});
|
||||
const [visibleSeries, setVisibleSeries] = useState<boolean[]>(
|
||||
new Array(normalizedSeries.length).fill(true),
|
||||
);
|
||||
|
||||
// Keep latest export context in a ref so export callbacks always read fresh data.
|
||||
useEffect(() => {
|
||||
latestExportContextRef.current = {
|
||||
normalizedSeries,
|
||||
config,
|
||||
unit,
|
||||
renderValue,
|
||||
};
|
||||
}, [normalizedSeries, config, unit, renderValue]);
|
||||
|
||||
// Enhanced configuration with visible series lines
|
||||
const enhancedConfig = React.useMemo(() => {
|
||||
return mergeConfigWithVisibleLines(config, newData, visibleSeries);
|
||||
@@ -207,35 +223,27 @@ export function BigGraph({
|
||||
// Register export functionality for the graph
|
||||
useEffect(() => {
|
||||
if (onRegisterForExport) {
|
||||
// Unregister any previous registrations for this graph
|
||||
if (onUnregisterFromExport) {
|
||||
// Clear any existing registrations for this graph's series
|
||||
normalizedSeries.forEach((_, index) => {
|
||||
const seriesId = `${graphId}-series-${index}`;
|
||||
onUnregisterFromExport(seriesId);
|
||||
});
|
||||
}
|
||||
|
||||
// Register each series as a separate export entry
|
||||
normalizedSeries.forEach((series, index) => {
|
||||
if (!series.newData) return;
|
||||
|
||||
normalizedSeries.forEach((_, index) => {
|
||||
const seriesId = `${graphId}-series-${index}`;
|
||||
const seriesTitle = series.title || `Series ${index + 1}`;
|
||||
|
||||
const getSeriesExportData = (): GraphExportData | null => {
|
||||
const current = latestExportContextRef.current;
|
||||
const series = current.normalizedSeries[index];
|
||||
if (!series?.newData) return null;
|
||||
|
||||
// Create export data for this specific series only
|
||||
const seriesLines = series.lines || [];
|
||||
const exportConfig = {
|
||||
...config,
|
||||
title: config.title, // Keep original graph title
|
||||
lines: [...(config.lines || []), ...seriesLines],
|
||||
...current.config,
|
||||
title: current.config.title, // Keep original graph title
|
||||
lines: [...(current.config.lines || []), ...seriesLines],
|
||||
};
|
||||
|
||||
// Create a single-series data structure for this series
|
||||
const singleSeriesData: SeriesData = {
|
||||
newData: series.newData,
|
||||
title: seriesTitle,
|
||||
title: series.title || `Series ${index + 1}`,
|
||||
color: series.color,
|
||||
lines: series.lines,
|
||||
};
|
||||
@@ -243,8 +251,8 @@ export function BigGraph({
|
||||
return {
|
||||
config: exportConfig,
|
||||
data: singleSeriesData, // Return ONLY this specific series
|
||||
unit,
|
||||
renderValue,
|
||||
unit: current.unit,
|
||||
renderValue: current.renderValue,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -265,19 +273,7 @@ export function BigGraph({
|
||||
onRegisterForExport,
|
||||
onUnregisterFromExport,
|
||||
graphId,
|
||||
config,
|
||||
normalizedSeries.length,
|
||||
unit,
|
||||
renderValue,
|
||||
// Add dependencies for series data
|
||||
JSON.stringify(
|
||||
normalizedSeries.map((s) => ({
|
||||
title: s.title,
|
||||
color: s.color,
|
||||
hasData: !!s.newData,
|
||||
linesCount: s.lines?.length || 0,
|
||||
})),
|
||||
),
|
||||
]);
|
||||
|
||||
// Updates Y-axis scale dynamically based on visible data
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import { TimeSeries, seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { renderUnitSymbol, Unit } from "@/control/units";
|
||||
import { GraphConfig, SeriesData, GraphLine } from "./types";
|
||||
|
||||
export type GraphExportData = {
|
||||
config: GraphConfig;
|
||||
data: SeriesData; // Always a single series
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
};
|
||||
|
||||
export function exportGraphsToExcel(
|
||||
graphDataMap: Map<string, () => GraphExportData | null>,
|
||||
groupId: string,
|
||||
): void {
|
||||
try {
|
||||
// Filter out invalid series IDs (those without "-series-")
|
||||
const filteredMap = new Map<string, () => GraphExportData | null>();
|
||||
graphDataMap.forEach((getDataFn, seriesId) => {
|
||||
if (seriesId.includes("-series-")) {
|
||||
filteredMap.set(seriesId, getDataFn);
|
||||
}
|
||||
});
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const exportTimestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-")
|
||||
.slice(0, 19);
|
||||
|
||||
const usedSheetNames = new Set<string>(); // Track unique sheet names
|
||||
let processedCount = 0;
|
||||
|
||||
// Process each valid series
|
||||
filteredMap.forEach((getDataFn, seriesId) => {
|
||||
const exportData = getDataFn();
|
||||
if (!exportData?.data?.newData) {
|
||||
console.warn(`No data for series: ${seriesId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const series = exportData.data;
|
||||
const seriesTitle = series.title || `Series ${processedCount + 1}`;
|
||||
|
||||
if (!series.newData) {
|
||||
console.warn(`Series ${seriesTitle} has null data`);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetLines: GraphLine[] = [
|
||||
...(exportData.config.lines || []),
|
||||
...(series.lines || []),
|
||||
];
|
||||
|
||||
const graphLineData = {
|
||||
graphTitle: exportData.config.title,
|
||||
lineTitle: seriesTitle,
|
||||
series: series.newData,
|
||||
color: series.color,
|
||||
unit: exportData.unit,
|
||||
renderValue: exportData.renderValue,
|
||||
config: exportData.config,
|
||||
targetLines: targetLines,
|
||||
};
|
||||
|
||||
// Create and append statistics sheet
|
||||
const statsData = createGraphLineStatsSheet(graphLineData);
|
||||
const statsWorksheet = XLSX.utils.aoa_to_sheet(statsData);
|
||||
const statsSheetName = generateUniqueSheetName(
|
||||
`${seriesTitle} Stats`,
|
||||
usedSheetNames,
|
||||
);
|
||||
XLSX.utils.book_append_sheet(workbook, statsWorksheet, statsSheetName);
|
||||
|
||||
// Create and append data sheet
|
||||
const dataRows = createGraphLineDataSheet(graphLineData);
|
||||
if (dataRows.length > 0) {
|
||||
const dataWorksheet = XLSX.utils.json_to_sheet(dataRows);
|
||||
const dataSheetName = generateUniqueSheetName(
|
||||
`${seriesTitle} Data`,
|
||||
usedSheetNames,
|
||||
);
|
||||
XLSX.utils.book_append_sheet(workbook, dataWorksheet, dataSheetName);
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
});
|
||||
|
||||
if (processedCount === 0) {
|
||||
alert("No data available to export from any graphs in this group");
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = `${groupId.toLowerCase().replace(/\s+/g, "_")}_export_${exportTimestamp}.xlsx`;
|
||||
XLSX.writeFile(workbook, filename);
|
||||
} catch (error) {
|
||||
alert(
|
||||
`Error exporting data to Excel: ${error instanceof Error ? error.message : "Unknown error"}. Please try again.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate statistics sheet for a graph line
|
||||
function createGraphLineStatsSheet(graphLine: {
|
||||
graphTitle: string;
|
||||
lineTitle: string;
|
||||
series: TimeSeries;
|
||||
color?: string;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
config: GraphConfig;
|
||||
targetLines: GraphLine[];
|
||||
}): any[][] {
|
||||
const [timestamps, values] = seriesToUPlotData(graphLine.series.long);
|
||||
const unitSymbol = renderUnitSymbol(graphLine.unit) || "";
|
||||
|
||||
const statsData = [
|
||||
[`Graph Line Statistics: ${graphLine.lineTitle}`, ""],
|
||||
["Graph", graphLine.graphTitle],
|
||||
["Line Name", graphLine.lineTitle],
|
||||
["Line Color", graphLine.color || "Default"],
|
||||
["Generated", new Date()],
|
||||
["", ""],
|
||||
["Data Points Information", ""],
|
||||
["Total Data Points", timestamps.length.toString()],
|
||||
];
|
||||
|
||||
if (timestamps.length > 0) {
|
||||
statsData.push(["Time Range Start", new Date(timestamps[0])]);
|
||||
statsData.push([
|
||||
"Time Range End",
|
||||
new Date(timestamps[timestamps.length - 1]),
|
||||
]);
|
||||
|
||||
const duration = timestamps[timestamps.length - 1] - timestamps[0];
|
||||
const durationHours = (duration / (1000 * 60 * 60)).toFixed(2);
|
||||
statsData.push(["Duration (hours)", durationHours]);
|
||||
|
||||
if (values.length > 0) {
|
||||
const minValue = Math.min(...values);
|
||||
const maxValue = Math.max(...values);
|
||||
const avgValue = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
const stdDev = Math.sqrt(
|
||||
values.reduce((sum, val) => sum + Math.pow(val - avgValue, 2), 0) /
|
||||
values.length,
|
||||
);
|
||||
|
||||
statsData.push(["", ""], ["Value Statistics", ""]);
|
||||
statsData.push([
|
||||
`Minimum Value (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(minValue)
|
||||
: minValue.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Maximum Value (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(maxValue)
|
||||
: maxValue.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Average Value (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(avgValue)
|
||||
: avgValue.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Standard Deviation (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(stdDev)
|
||||
: stdDev.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Range (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(maxValue - minValue)
|
||||
: (maxValue - minValue).toFixed(3),
|
||||
]);
|
||||
|
||||
// Percentiles
|
||||
const sortedValues = [...values].sort((a, b) => a - b);
|
||||
const p25 = sortedValues[Math.floor(sortedValues.length * 0.25)];
|
||||
const p50 = sortedValues[Math.floor(sortedValues.length * 0.5)];
|
||||
const p75 = sortedValues[Math.floor(sortedValues.length * 0.75)];
|
||||
|
||||
statsData.push(["", ""], ["Percentiles", ""]);
|
||||
statsData.push([
|
||||
`25th Percentile (${unitSymbol})`,
|
||||
graphLine.renderValue ? graphLine.renderValue(p25) : p25.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`50th Percentile/Median (${unitSymbol})`,
|
||||
graphLine.renderValue ? graphLine.renderValue(p50) : p50.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`75th Percentile (${unitSymbol})`,
|
||||
graphLine.renderValue ? graphLine.renderValue(p75) : p75.toFixed(3),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add target line information
|
||||
if (graphLine.targetLines.length > 0) {
|
||||
statsData.push(["", ""], ["Target Lines", ""]);
|
||||
graphLine.targetLines.forEach((line, index) => {
|
||||
statsData.push([
|
||||
`Target Line ${index + 1}`,
|
||||
line.label || `Line ${line.value}`,
|
||||
]);
|
||||
statsData.push([
|
||||
` Value (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(line.value)
|
||||
: line.value.toFixed(3),
|
||||
]);
|
||||
statsData.push([` Type`, line.type || "reference"]);
|
||||
statsData.push([` Color`, line.color || "default"]);
|
||||
statsData.push([` Show`, line.show !== false ? "Yes" : "No"]);
|
||||
|
||||
if (line.type === "threshold" && values.length > 0) {
|
||||
const withinThreshold = values.filter(
|
||||
(val) => Math.abs(val - line.value) <= line.value * 0.05,
|
||||
).length;
|
||||
const percentageWithin = (
|
||||
(withinThreshold / values.length) *
|
||||
100
|
||||
).toFixed(1);
|
||||
|
||||
statsData.push([
|
||||
` Points Within Threshold (5%)`,
|
||||
`${withinThreshold} (${percentageWithin}%)`,
|
||||
]);
|
||||
|
||||
const differences = values.map((val) => Math.abs(val - line.value));
|
||||
const minDifference = Math.min(...differences);
|
||||
const maxDifference = Math.max(...differences);
|
||||
|
||||
statsData.push([
|
||||
` Closest Approach (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(minDifference)
|
||||
: minDifference.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
` Furthest Distance (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(maxDifference)
|
||||
: maxDifference.toFixed(3),
|
||||
]);
|
||||
}
|
||||
|
||||
if (index < graphLine.targetLines.length - 1) {
|
||||
statsData.push([""]);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
statsData.push(["", ""], ["Target Lines", ""]);
|
||||
statsData.push(["No target lines defined", ""]);
|
||||
}
|
||||
|
||||
return statsData;
|
||||
}
|
||||
|
||||
// Generate data sheet for a graph line
|
||||
function createGraphLineDataSheet(graphLine: {
|
||||
graphTitle: string;
|
||||
lineTitle: string;
|
||||
series: TimeSeries;
|
||||
color?: string;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
config: GraphConfig;
|
||||
targetLines: GraphLine[];
|
||||
}): any[] {
|
||||
const [timestamps, values] = seriesToUPlotData(graphLine.series.long);
|
||||
|
||||
if (timestamps.length === 0) return [];
|
||||
|
||||
const unitSymbol = renderUnitSymbol(graphLine.unit) || "";
|
||||
|
||||
return timestamps.map((timestamp, index) => {
|
||||
const value = values[index];
|
||||
return {
|
||||
Timestamp: new Date(timestamp),
|
||||
[`Value (${unitSymbol})`]: graphLine.renderValue
|
||||
? graphLine.renderValue(value)
|
||||
: value?.toFixed(3) || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure sheet names are unique and valid for Excel
|
||||
function generateUniqueSheetName(
|
||||
name: string,
|
||||
usedSheetNames: Set<string>,
|
||||
): string {
|
||||
let baseSheetName = name
|
||||
.replace(/[\\/?*$:[\]]/g, "_") // Remove invalid characters
|
||||
.substring(0, 31); // Excel sheet name limit
|
||||
|
||||
if (!baseSheetName || baseSheetName.trim().length === 0) {
|
||||
baseSheetName = "Sheet";
|
||||
}
|
||||
|
||||
let sheetName = baseSheetName;
|
||||
let counter = 1;
|
||||
|
||||
while (usedSheetNames.has(sheetName)) {
|
||||
const suffix = `_${counter}`;
|
||||
const maxBaseLength = 31 - suffix.length;
|
||||
sheetName = `${baseSheetName.substring(0, maxBaseLength)}${suffix}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
usedSheetNames.add(sheetName);
|
||||
return sheetName;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import { LogEntry } from "@/stores/logsStore";
|
||||
import { ExportConfig, IExportConfig } from "./excelExportConfig";
|
||||
import {
|
||||
IValueFormatter,
|
||||
ValueFormatter,
|
||||
TimestampConverter,
|
||||
ArrayUtils,
|
||||
} from "./excelFormatters";
|
||||
import { MetadataProviderFactory } from "./excelMetadata";
|
||||
import { ChartAxisCalculator, ExcelCellSanitizer } from "./excelUtils";
|
||||
import { CommentManager } from "./excelCommentManager";
|
||||
import { CombinedSheetData, PidData } from "./excelExportTypes";
|
||||
|
||||
/**
|
||||
* Creates the combined analysis sheet with all series data
|
||||
*/
|
||||
export class AnalysisSheetBuilder {
|
||||
private config: IExportConfig;
|
||||
private formatter: IValueFormatter;
|
||||
|
||||
constructor(
|
||||
private allSheetData: CombinedSheetData[],
|
||||
private groupId: string,
|
||||
private logs: LogEntry[],
|
||||
private pidData?: PidData,
|
||||
config?: IExportConfig,
|
||||
formatter?: IValueFormatter,
|
||||
) {
|
||||
this.config = config || new ExportConfig();
|
||||
this.formatter = formatter || new ValueFormatter();
|
||||
}
|
||||
|
||||
async build(): Promise<XLSX.WorkSheet> {
|
||||
// Get sorted timestamps
|
||||
const sortedTimestamps = this.getSortedTimestamps();
|
||||
const startTime = sortedTimestamps[0];
|
||||
const endTime = sortedTimestamps[sortedTimestamps.length - 1];
|
||||
|
||||
// Filter relevant comments
|
||||
const relevantComments = CommentManager.filterRelevant(
|
||||
this.logs,
|
||||
startTime,
|
||||
endTime,
|
||||
);
|
||||
|
||||
// Build data by timestamp map
|
||||
const dataByTimestamp = this.buildDataByTimestampMap();
|
||||
|
||||
// Build columns
|
||||
const columns = this.buildColumns();
|
||||
|
||||
// Create sheet data array
|
||||
const sheetData: any[][] = [];
|
||||
|
||||
// Add title row
|
||||
const timeRangeTitle = this.formatter.formatTimeRange(startTime, endTime);
|
||||
sheetData.push(
|
||||
ArrayUtils.createRow(
|
||||
[`${this.groupId} - ${timeRangeTitle}`],
|
||||
columns.length,
|
||||
),
|
||||
);
|
||||
sheetData.push(ArrayUtils.createEmptyArray(columns.length));
|
||||
|
||||
// Add target values row if applicable
|
||||
this.addTargetValuesRow(sheetData, columns.length);
|
||||
|
||||
// Add header row
|
||||
sheetData.push(columns);
|
||||
|
||||
// Add data rows
|
||||
const dataStartRow = sheetData.length;
|
||||
let maxSeconds = 0;
|
||||
|
||||
sortedTimestamps.forEach((timestamp) => {
|
||||
const row = this.buildDataRow(
|
||||
timestamp,
|
||||
startTime,
|
||||
dataByTimestamp,
|
||||
relevantComments,
|
||||
);
|
||||
|
||||
const secondsFromStart = TimestampConverter.toSecondsFromStart(
|
||||
timestamp,
|
||||
startTime,
|
||||
);
|
||||
maxSeconds = Math.max(maxSeconds, secondsFromStart);
|
||||
|
||||
sheetData.push(row);
|
||||
});
|
||||
|
||||
// Add metadata
|
||||
await this.addMetadata(sheetData, columns.length, relevantComments);
|
||||
|
||||
// Add chart instructions
|
||||
this.addChartInstructions(
|
||||
sheetData,
|
||||
columns.length,
|
||||
dataStartRow,
|
||||
maxSeconds,
|
||||
timeRangeTitle,
|
||||
);
|
||||
|
||||
const sanitizedSheetData = sheetData.map((row) =>
|
||||
ExcelCellSanitizer.sanitizeRow(row),
|
||||
);
|
||||
|
||||
// Convert to worksheet
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(sanitizedSheetData);
|
||||
|
||||
// Configure worksheet
|
||||
this.configureWorksheet(worksheet, columns.length);
|
||||
|
||||
return worksheet;
|
||||
}
|
||||
|
||||
private getSortedTimestamps(): number[] {
|
||||
const allTimestamps = new Set<number>();
|
||||
this.allSheetData.forEach((data) => {
|
||||
data.timestamps.forEach((ts) => allTimestamps.add(ts));
|
||||
});
|
||||
return Array.from(allTimestamps).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
private buildDataByTimestampMap(): Map<number, Map<string, number>> {
|
||||
const dataByTimestamp = new Map<number, Map<string, number>>();
|
||||
|
||||
this.allSheetData.forEach((sheetData) => {
|
||||
sheetData.timestamps.forEach((ts, idx) => {
|
||||
if (!dataByTimestamp.has(ts)) {
|
||||
dataByTimestamp.set(ts, new Map());
|
||||
}
|
||||
dataByTimestamp
|
||||
.get(ts)!
|
||||
.set(sheetData.sheetName, sheetData.values[idx]);
|
||||
});
|
||||
});
|
||||
|
||||
return dataByTimestamp;
|
||||
}
|
||||
|
||||
private buildColumns(): string[] {
|
||||
const columns: string[] = ["Timestamp"];
|
||||
const availableColumns = this.allSheetData.map((d) => d.sheetName);
|
||||
columns.push(...availableColumns);
|
||||
columns.push("User Comments");
|
||||
return columns;
|
||||
}
|
||||
|
||||
private addTargetValuesRow(sheetData: any[][], columnCount: number): void {
|
||||
const targetValues: any[] = ["Target Values"];
|
||||
let hasTargets = false;
|
||||
|
||||
this.allSheetData.forEach((sheetDataEntry) => {
|
||||
if (sheetDataEntry.targetLines.length > 0) {
|
||||
const targetLine = sheetDataEntry.targetLines.find(
|
||||
(line) => line.type === "target",
|
||||
);
|
||||
if (targetLine) {
|
||||
targetValues.push(this.formatter.formatNumber(targetLine.value));
|
||||
hasTargets = true;
|
||||
} else {
|
||||
targetValues.push("");
|
||||
}
|
||||
} else {
|
||||
targetValues.push("");
|
||||
}
|
||||
});
|
||||
|
||||
targetValues.push(""); // Empty for comments column
|
||||
|
||||
if (hasTargets) {
|
||||
sheetData.push(targetValues);
|
||||
sheetData.push(ArrayUtils.createEmptyArray(columnCount));
|
||||
}
|
||||
}
|
||||
|
||||
private buildDataRow(
|
||||
timestamp: number,
|
||||
startTime: number,
|
||||
dataByTimestamp: Map<number, Map<string, number>>,
|
||||
relevantComments: LogEntry[],
|
||||
): any[] {
|
||||
const row: any[] = [];
|
||||
|
||||
// Calculate seconds from start using TimestampConverter
|
||||
const secondsFromStart = TimestampConverter.toSecondsFromStart(
|
||||
timestamp,
|
||||
startTime,
|
||||
);
|
||||
row.push(secondsFromStart);
|
||||
|
||||
// Add data for each column
|
||||
this.allSheetData.forEach((sheetDataEntry) => {
|
||||
const tsData = dataByTimestamp.get(timestamp);
|
||||
if (tsData && tsData.has(sheetDataEntry.sheetName)) {
|
||||
row.push(
|
||||
this.sanitizeNumber(tsData.get(sheetDataEntry.sheetName) ?? NaN),
|
||||
);
|
||||
} else {
|
||||
row.push("");
|
||||
}
|
||||
});
|
||||
|
||||
// Check for comments at this timestamp
|
||||
const comment = CommentManager.findAtTimestamp(relevantComments, timestamp);
|
||||
row.push(comment ? comment.message : "");
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private sanitizeNumber(value: number): number | "" {
|
||||
return Number.isFinite(value) ? value : "";
|
||||
}
|
||||
|
||||
private async addMetadata(
|
||||
sheetData: any[][],
|
||||
columnCount: number,
|
||||
relevantComments: LogEntry[],
|
||||
): Promise<void> {
|
||||
sheetData.push(ArrayUtils.createEmptyArray(columnCount));
|
||||
sheetData.push(ArrayUtils.createEmptyArray(columnCount));
|
||||
|
||||
// Use MetadataProvider to build metadata
|
||||
const metadataProvider = MetadataProviderFactory.createForExport({
|
||||
softwareName: this.config.getSoftwareName(),
|
||||
exportDate: this.formatter.formatDate(new Date()),
|
||||
pidData: this.pidData,
|
||||
commentCount: relevantComments.length,
|
||||
});
|
||||
|
||||
sheetData.push(...metadataProvider.buildRows(columnCount));
|
||||
}
|
||||
|
||||
private addChartInstructions(
|
||||
sheetData: any[][],
|
||||
columnCount: number,
|
||||
dataStartRow: number,
|
||||
maxSeconds: number,
|
||||
timeRangeTitle: string,
|
||||
): void {
|
||||
// Calculate optimal Y-axis range from all data
|
||||
const allValues = this.allSheetData.flatMap((sheet) => sheet.values);
|
||||
const yAxisRange = ChartAxisCalculator.calculateOptimalRange(allValues);
|
||||
const yAxisInstruction = ChartAxisCalculator.formatRangeInstruction(
|
||||
yAxisRange.min,
|
||||
yAxisRange.max,
|
||||
);
|
||||
|
||||
sheetData.push(ArrayUtils.createEmptyArray(columnCount));
|
||||
sheetData.push(ArrayUtils.createEmptyArray(columnCount));
|
||||
sheetData.push(ArrayUtils.createRow(["Chart Instructions"], columnCount));
|
||||
sheetData.push(
|
||||
ArrayUtils.createRow(
|
||||
[`1. Select all data from row ${dataStartRow} to the last data row`],
|
||||
columnCount,
|
||||
),
|
||||
);
|
||||
sheetData.push(
|
||||
ArrayUtils.createRow(
|
||||
["2. Insert > Chart > Scatter Chart with Straight Lines and Markers"],
|
||||
columnCount,
|
||||
),
|
||||
);
|
||||
sheetData.push(
|
||||
ArrayUtils.createRow(
|
||||
["3. X-axis: Time (seconds), Y-axis: All measurement columns"],
|
||||
columnCount,
|
||||
),
|
||||
);
|
||||
sheetData.push(
|
||||
ArrayUtils.createRow(
|
||||
[`4. Set X-axis range: 0 to ${maxSeconds}`],
|
||||
columnCount,
|
||||
),
|
||||
);
|
||||
sheetData.push(ArrayUtils.createRow([yAxisInstruction], columnCount));
|
||||
sheetData.push(
|
||||
ArrayUtils.createRow(["6. Position legend at bottom"], columnCount),
|
||||
);
|
||||
sheetData.push(
|
||||
ArrayUtils.createRow(
|
||||
[`7. Chart Title: ${this.groupId} - ${timeRangeTitle}`],
|
||||
columnCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private configureWorksheet(
|
||||
worksheet: XLSX.WorkSheet,
|
||||
columnCount: number,
|
||||
): void {
|
||||
// Merge title cells
|
||||
if (!worksheet["!merges"]) worksheet["!merges"] = [];
|
||||
worksheet["!merges"].push({
|
||||
s: { r: 0, c: 0 },
|
||||
e: { r: 0, c: columnCount - 1 },
|
||||
});
|
||||
|
||||
// Set column widths
|
||||
const colWidths = [
|
||||
{ wch: 12 }, // Timestamp
|
||||
...this.allSheetData.map(() => ({ wch: 12 })),
|
||||
{ wch: 40 }, // Comments
|
||||
];
|
||||
worksheet["!cols"] = colWidths;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { LogEntry } from "@/stores/logsStore";
|
||||
|
||||
/**
|
||||
* Filters and manages log comments for export
|
||||
*/
|
||||
export class CommentManager {
|
||||
static filterRelevant(
|
||||
logs: LogEntry[],
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): LogEntry[] {
|
||||
return logs.filter(
|
||||
(log) =>
|
||||
log.timestamp.getTime() >= startTime &&
|
||||
log.timestamp.getTime() <= endTime &&
|
||||
log.level === "info" &&
|
||||
log.message.toLowerCase().includes("comment"),
|
||||
);
|
||||
}
|
||||
|
||||
static findAtTimestamp(
|
||||
comments: LogEntry[],
|
||||
timestamp: number,
|
||||
tolerance: number = 1000,
|
||||
): LogEntry | undefined {
|
||||
return comments.find(
|
||||
(log) => Math.abs(log.timestamp.getTime() - timestamp) < tolerance,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import { TimeSeries, seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { renderUnitSymbol, Unit } from "@/control/units";
|
||||
import { GraphConfig, GraphLine } from "../types";
|
||||
import { IValueFormatter, ValueFormatter } from "./excelFormatters";
|
||||
import { StatisticsCalculator } from "./excelStatisticsCalculator";
|
||||
import { ExcelCellSanitizer } from "./excelUtils";
|
||||
|
||||
/**
|
||||
* Creates individual data sheets for each series
|
||||
*/
|
||||
export class DataSheetBuilder {
|
||||
private formatter: IValueFormatter;
|
||||
|
||||
constructor(
|
||||
private graphLine: {
|
||||
graphTitle: string;
|
||||
lineTitle: string;
|
||||
series: TimeSeries;
|
||||
color?: string;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
config: GraphConfig;
|
||||
targetLines: GraphLine[];
|
||||
},
|
||||
private seriesTitle: string,
|
||||
private unit: Unit | undefined,
|
||||
formatter?: IValueFormatter,
|
||||
) {
|
||||
this.formatter = formatter || new ValueFormatter();
|
||||
}
|
||||
|
||||
build(): XLSX.WorkSheet {
|
||||
const [timestamps, values] = seriesToUPlotData(this.graphLine.series.long);
|
||||
const unitSymbol = renderUnitSymbol(this.unit) || "";
|
||||
|
||||
const sheetData: any[][] = [];
|
||||
|
||||
// Build header
|
||||
const col1Header = unitSymbol
|
||||
? `${unitSymbol} ${this.seriesTitle}`
|
||||
: this.seriesTitle;
|
||||
|
||||
sheetData.push(["Timestamp", col1Header, "", "", "Statistic", "Value"]);
|
||||
|
||||
// Build stats section
|
||||
const statsRows = this.buildStatsRows(timestamps, values, unitSymbol);
|
||||
|
||||
// Combine data and stats rows
|
||||
const maxRows = Math.max(timestamps.length, statsRows.length);
|
||||
for (let i = 0; i < maxRows; i++) {
|
||||
const row = this.buildDataRow(i, timestamps, values, statsRows);
|
||||
sheetData.push(row);
|
||||
}
|
||||
|
||||
const sanitizedSheetData = sheetData.map((row) =>
|
||||
ExcelCellSanitizer.sanitizeRow(row),
|
||||
);
|
||||
|
||||
// Convert to worksheet
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(sanitizedSheetData);
|
||||
worksheet["!cols"] = [
|
||||
{ wch: 20 }, // Timestamp
|
||||
{ wch: 15 }, // Value
|
||||
{ wch: 5 }, // Empty
|
||||
{ wch: 5 }, // Empty
|
||||
{ wch: 30 }, // Statistic name
|
||||
{ wch: 20 }, // Statistic value
|
||||
];
|
||||
|
||||
return worksheet;
|
||||
}
|
||||
|
||||
private buildStatsRows(
|
||||
timestamps: number[],
|
||||
values: number[],
|
||||
unitSymbol: string,
|
||||
): string[][] {
|
||||
const statsRows: string[][] = [];
|
||||
|
||||
statsRows.push(["Graph", this.graphLine.graphTitle]);
|
||||
statsRows.push(["Line Name", this.graphLine.lineTitle]);
|
||||
statsRows.push(["Line Color", this.graphLine.color || "Default"]);
|
||||
statsRows.push(["Generated", this.formatter.formatDate(new Date())]);
|
||||
statsRows.push(["", ""]);
|
||||
statsRows.push(["Total Data Points", timestamps.length.toString()]);
|
||||
|
||||
if (timestamps.length > 0) {
|
||||
const firstDate = new Date(timestamps[0]);
|
||||
const lastDate = new Date(timestamps[timestamps.length - 1]);
|
||||
|
||||
statsRows.push([
|
||||
"Time Range Start",
|
||||
this.formatter.formatDate(firstDate),
|
||||
]);
|
||||
statsRows.push(["Time Range End", this.formatter.formatDate(lastDate)]);
|
||||
|
||||
const duration = timestamps[timestamps.length - 1] - timestamps[0];
|
||||
const durationHours = this.formatter.formatDuration(duration);
|
||||
statsRows.push(["Duration (hours)", durationHours]);
|
||||
|
||||
if (values.length > 0) {
|
||||
const stats = StatisticsCalculator.calculate(values);
|
||||
|
||||
statsRows.push(["", ""]);
|
||||
statsRows.push([
|
||||
`Minimum Value (${unitSymbol})`,
|
||||
this.formatValue(stats.min),
|
||||
]);
|
||||
statsRows.push([
|
||||
`Maximum Value (${unitSymbol})`,
|
||||
this.formatValue(stats.max),
|
||||
]);
|
||||
statsRows.push([
|
||||
`Average Value (${unitSymbol})`,
|
||||
this.formatValue(stats.avg),
|
||||
]);
|
||||
statsRows.push([
|
||||
`Standard Deviation (${unitSymbol})`,
|
||||
this.formatValue(stats.stdDev),
|
||||
]);
|
||||
statsRows.push([
|
||||
`Range (${unitSymbol})`,
|
||||
this.formatValue(stats.range),
|
||||
]);
|
||||
|
||||
statsRows.push(["", ""]);
|
||||
statsRows.push([
|
||||
`25th Percentile (${unitSymbol})`,
|
||||
this.formatValue(stats.p25),
|
||||
]);
|
||||
statsRows.push([
|
||||
`50th Percentile (${unitSymbol})`,
|
||||
this.formatValue(stats.p50),
|
||||
]);
|
||||
statsRows.push([
|
||||
`75th Percentile (${unitSymbol})`,
|
||||
this.formatValue(stats.p75),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return statsRows;
|
||||
}
|
||||
|
||||
private buildDataRow(
|
||||
index: number,
|
||||
timestamps: number[],
|
||||
values: number[],
|
||||
statsRows: string[][],
|
||||
): any[] {
|
||||
const row: any[] = ["", "", "", ""];
|
||||
|
||||
// Add timestamp and value data
|
||||
if (index < timestamps.length) {
|
||||
const date = new Date(timestamps[index]);
|
||||
row[0] = this.formatter.formatDate(date);
|
||||
row[1] = this.formatValue(values[index]);
|
||||
}
|
||||
|
||||
// Add stats
|
||||
if (index < statsRows.length) {
|
||||
row[4] = statsRows[index][0];
|
||||
row[5] = statsRows[index][1];
|
||||
} else {
|
||||
row[4] = "";
|
||||
row[5] = "";
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private formatValue(value: number): string {
|
||||
return this.graphLine.renderValue
|
||||
? this.graphLine.renderValue(value)
|
||||
: this.formatter.formatNumber(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ValueFormatter } from "./excelFormatters";
|
||||
|
||||
/**
|
||||
* Utility class for date/time formatting and manipulation
|
||||
* Now uses ValueFormatter internally
|
||||
*/
|
||||
export class DateFormatter {
|
||||
private static formatter = new ValueFormatter();
|
||||
|
||||
static format(date: Date): string {
|
||||
return this.formatter.formatDate(date);
|
||||
}
|
||||
|
||||
static getExportTimestamp(): string {
|
||||
return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
}
|
||||
|
||||
static formatTimeRange(startTime: number, endTime: number): string {
|
||||
return this.formatter.formatTimeRange(startTime, endTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { renderUnitSymbol } from "@/control/units";
|
||||
import { GraphLine } from "../types";
|
||||
import { LogEntry } from "@/stores/logsStore";
|
||||
import { ExportConfig, IExportConfig } from "./excelExportConfig";
|
||||
import { IValueFormatter, ValueFormatter } from "./excelFormatters";
|
||||
import { ExcelCellSanitizer, IPidDataProvider } from "./excelUtils";
|
||||
import {
|
||||
CombinedSheetData,
|
||||
GraphExportData,
|
||||
PidData,
|
||||
} from "./excelExportTypes";
|
||||
import { DateFormatter } from "./excelDateFormatter";
|
||||
import { SheetNameManager } from "./excelSheetNameManager";
|
||||
import { DataSheetBuilder } from "./excelDataSheetBuilder";
|
||||
import { AnalysisSheetBuilder } from "./excelAnalysisSheetBuilder";
|
||||
|
||||
export type { GraphExportData, PidSettings, PidData } from "./excelExportTypes";
|
||||
|
||||
/**
|
||||
* Main orchestrator for Excel export functionality
|
||||
*/
|
||||
export class ExcelExporter {
|
||||
private config: IExportConfig;
|
||||
private formatter: IValueFormatter;
|
||||
private sheetNameManager: SheetNameManager;
|
||||
private pidDataProvider?: IPidDataProvider;
|
||||
|
||||
constructor(
|
||||
config?: IExportConfig,
|
||||
formatter?: IValueFormatter,
|
||||
pidDataProvider?: IPidDataProvider,
|
||||
) {
|
||||
this.config = config || new ExportConfig();
|
||||
this.formatter = formatter || new ValueFormatter();
|
||||
this.sheetNameManager = new SheetNameManager(this.config);
|
||||
this.pidDataProvider = pidDataProvider;
|
||||
}
|
||||
|
||||
async export(
|
||||
graphDataMap: Map<string, () => GraphExportData | null>,
|
||||
groupId: string,
|
||||
logs: LogEntry[] = [],
|
||||
pidData?: PidData,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// If PID data provider is available and no PID data provided, fetch it
|
||||
if (!pidData && this.pidDataProvider) {
|
||||
pidData = (await this.pidDataProvider.fetchPidSettings()) || undefined;
|
||||
}
|
||||
|
||||
const filteredMap = this.filterValidSeries(graphDataMap);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const exportTimestamp = DateFormatter.getExportTimestamp();
|
||||
|
||||
const allSheetData: CombinedSheetData[] = [];
|
||||
|
||||
// Process each series
|
||||
filteredMap.forEach((getDataFn) => {
|
||||
const exportData = getDataFn();
|
||||
if (!exportData?.data?.newData) return;
|
||||
|
||||
const series = exportData.data;
|
||||
const seriesTitle = series.title || "Series";
|
||||
|
||||
// Ensure newData is not null before proceeding
|
||||
if (!series.newData) return;
|
||||
|
||||
const sheetName = this.sheetNameManager.generate(
|
||||
exportData.config.title,
|
||||
seriesTitle,
|
||||
exportData.unit,
|
||||
);
|
||||
|
||||
const targetLines: GraphLine[] = [
|
||||
...(exportData.config.lines || []),
|
||||
...(series.lines || []),
|
||||
];
|
||||
|
||||
// Create data sheet
|
||||
const dataSheetBuilder = new DataSheetBuilder(
|
||||
{
|
||||
graphTitle: exportData.config.title,
|
||||
lineTitle: seriesTitle,
|
||||
series: series.newData,
|
||||
color: series.color,
|
||||
unit: exportData.unit,
|
||||
renderValue: exportData.renderValue,
|
||||
config: exportData.config,
|
||||
targetLines,
|
||||
},
|
||||
seriesTitle,
|
||||
exportData.unit,
|
||||
this.formatter,
|
||||
);
|
||||
|
||||
const worksheet = dataSheetBuilder.build();
|
||||
ExcelCellSanitizer.sanitizeWorksheet(worksheet);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
|
||||
// Collect data for analysis sheet
|
||||
const [timestamps, values] = seriesToUPlotData(series.newData.long);
|
||||
allSheetData.push({
|
||||
sheetName,
|
||||
timestamps,
|
||||
values,
|
||||
unit: renderUnitSymbol(exportData.unit) || "",
|
||||
seriesTitle,
|
||||
graphTitle: exportData.config.title,
|
||||
targetLines,
|
||||
color: series.color,
|
||||
});
|
||||
});
|
||||
|
||||
if (allSheetData.length === 0) {
|
||||
alert("No data available to export from any graphs in this group");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create analysis sheet
|
||||
const analysisSheetBuilder = new AnalysisSheetBuilder(
|
||||
allSheetData,
|
||||
groupId,
|
||||
logs,
|
||||
pidData,
|
||||
this.config,
|
||||
this.formatter,
|
||||
);
|
||||
const analysisSheet = await analysisSheetBuilder.build();
|
||||
ExcelCellSanitizer.sanitizeWorksheet(analysisSheet);
|
||||
XLSX.utils.book_append_sheet(workbook, analysisSheet, "Analysis");
|
||||
|
||||
const xlsxBuffer = XLSX.write(workbook, {
|
||||
type: "buffer",
|
||||
bookType: "xlsx",
|
||||
});
|
||||
|
||||
this.triggerDownload(
|
||||
xlsxBuffer,
|
||||
groupId,
|
||||
DateFormatter.getExportTimestamp(),
|
||||
);
|
||||
} catch (error) {
|
||||
alert(
|
||||
`Error exporting data to Excel: ${
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
}. Please try again.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private filterValidSeries(
|
||||
graphDataMap: Map<string, () => GraphExportData | null>,
|
||||
): Map<string, () => GraphExportData | null> {
|
||||
const filteredMap = new Map<string, () => GraphExportData | null>();
|
||||
graphDataMap.forEach((getDataFn, seriesId) => {
|
||||
if (seriesId.includes("-series-")) {
|
||||
filteredMap.set(seriesId, getDataFn);
|
||||
}
|
||||
});
|
||||
return filteredMap;
|
||||
}
|
||||
|
||||
private triggerDownload(
|
||||
buffer: ArrayBuffer,
|
||||
groupId: string,
|
||||
exportTimestamp: string,
|
||||
): void {
|
||||
const filename = `${groupId
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "_")}_export_${exportTimestamp}.xlsx`;
|
||||
|
||||
const blob = new Blob([buffer], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to maintain backward compatibility with existing code
|
||||
*/
|
||||
export async function exportGraphsToExcel(
|
||||
graphDataMap: Map<string, () => GraphExportData | null>,
|
||||
groupId: string,
|
||||
logs: LogEntry[] = [],
|
||||
pidData?: PidData,
|
||||
): Promise<void> {
|
||||
const exporter = new ExcelExporter();
|
||||
await exporter.export(graphDataMap, groupId, logs, pidData);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Configuration system for Excel Export
|
||||
* Centralizes all hardcoded values and provides type-safe access
|
||||
* Follows Dependency Inversion Principle - depends on abstractions
|
||||
*/
|
||||
|
||||
export interface IExportConfig {
|
||||
getSoftwareName(): string;
|
||||
getDefaultPrecision(): number;
|
||||
getUnitFriendlyName(unit: string): string | undefined;
|
||||
getDefaultChartColor(): string;
|
||||
getDateLocale(): string;
|
||||
getYAxisRange(): { min: number; max: number } | null; // null means auto-scale
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation that can be extended or replaced
|
||||
*/
|
||||
export class ExportConfig implements IExportConfig {
|
||||
private readonly config = {
|
||||
softwareName: "QiTech Control",
|
||||
defaultPrecision: 3,
|
||||
unitFriendlyNames: {
|
||||
"°C": "Temp",
|
||||
W: "Watt",
|
||||
A: "Ampere",
|
||||
bar: "Bar",
|
||||
rpm: "Rpm",
|
||||
"1/min": "Rpm",
|
||||
mm: "mm",
|
||||
"%": "Percent",
|
||||
} as Record<string, string>,
|
||||
defaultChartColor: "#9b59b6",
|
||||
dateLocale: "de-DE",
|
||||
// null means auto-scale based on data
|
||||
yAxisRange: null as { min: number; max: number } | null,
|
||||
};
|
||||
|
||||
getSoftwareName(): string {
|
||||
return this.config.softwareName;
|
||||
}
|
||||
|
||||
getDefaultPrecision(): number {
|
||||
return this.config.defaultPrecision;
|
||||
}
|
||||
|
||||
getUnitFriendlyName(unit: string): string | undefined {
|
||||
return this.config.unitFriendlyNames[unit];
|
||||
}
|
||||
|
||||
getDefaultChartColor(): string {
|
||||
return this.config.defaultChartColor;
|
||||
}
|
||||
|
||||
getDateLocale(): string {
|
||||
return this.config.dateLocale;
|
||||
}
|
||||
|
||||
getYAxisRange(): { min: number; max: number } | null {
|
||||
return this.config.yAxisRange;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow runtime configuration updates
|
||||
*/
|
||||
setYAxisRange(min: number, max: number): void {
|
||||
this.config.yAxisRange = { min, max };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new unit mapping at runtime
|
||||
*/
|
||||
addUnitFriendlyName(unit: string, friendlyName: string): void {
|
||||
this.config.unitFriendlyNames[unit] = friendlyName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Machine-aware configuration that fetches data from machine context
|
||||
* This can be extended to fetch PID settings, machine-specific units, etc.
|
||||
*/
|
||||
export class MachineAwareExportConfig extends ExportConfig {
|
||||
constructor(private machineContext?: any) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Override to get software name from machine context if available
|
||||
*/
|
||||
override getSoftwareName(): string {
|
||||
// Could fetch from machine context in future
|
||||
return this.machineContext?.softwareName ?? super.getSoftwareName();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating configuration instances
|
||||
* Follows Abstract Factory Pattern
|
||||
*/
|
||||
export class ExportConfigFactory {
|
||||
static create(machineContext?: any): IExportConfig {
|
||||
if (machineContext) {
|
||||
return new MachineAwareExportConfig(machineContext);
|
||||
}
|
||||
return new ExportConfig();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Unit } from "@/control/units";
|
||||
import { GraphConfig, SeriesData, GraphLine } from "../types";
|
||||
|
||||
/**
|
||||
* Type definitions for export data structures
|
||||
*/
|
||||
export type GraphExportData = {
|
||||
config: GraphConfig;
|
||||
data: SeriesData;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
};
|
||||
|
||||
export type PidSettings = {
|
||||
kp: number;
|
||||
ki: number;
|
||||
kd: number;
|
||||
zone?: string; // For temperature zones (front, middle, back, nozzle)
|
||||
};
|
||||
|
||||
export type PidData = {
|
||||
temperature?: Record<string, PidSettings>; // keyed by zone
|
||||
pressure?: PidSettings;
|
||||
};
|
||||
|
||||
export type CombinedSheetData = {
|
||||
sheetName: string;
|
||||
timestamps: number[];
|
||||
values: number[];
|
||||
unit: string;
|
||||
seriesTitle: string;
|
||||
graphTitle: string;
|
||||
targetLines: GraphLine[];
|
||||
color?: string;
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Value formatting utilities following Strategy Pattern
|
||||
* Centralizes all formatting logic to eliminate duplication
|
||||
*/
|
||||
|
||||
export interface IValueFormatter {
|
||||
formatNumber(value: number): string;
|
||||
formatDate(date: Date): string;
|
||||
formatTimeRange(startTime: number, endTime: number): string;
|
||||
formatDuration(milliseconds: number): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default formatter implementation
|
||||
*/
|
||||
export class ValueFormatter implements IValueFormatter {
|
||||
constructor(
|
||||
private precision: number = 3,
|
||||
private locale: string = "de-DE",
|
||||
) {}
|
||||
|
||||
formatNumber(value: number): string {
|
||||
if (value == null || isNaN(value)) {
|
||||
return "";
|
||||
}
|
||||
return value.toFixed(this.precision);
|
||||
}
|
||||
|
||||
formatDate(date: Date): string {
|
||||
return date.toLocaleString(this.locale, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
formatTimeRange(startTime: number, endTime: number): string {
|
||||
const startDate = new Date(startTime);
|
||||
const endDate = new Date(endTime);
|
||||
return `${this.formatDate(startDate)} bis ${this.formatDate(endDate)}`;
|
||||
}
|
||||
|
||||
formatDuration(milliseconds: number): string {
|
||||
const hours = (milliseconds / (1000 * 60 * 60)).toFixed(2);
|
||||
return hours;
|
||||
}
|
||||
|
||||
setPrecision(precision: number): void {
|
||||
this.precision = precision;
|
||||
}
|
||||
|
||||
setLocale(locale: string): void {
|
||||
this.locale = locale;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom formatter that can use a render function
|
||||
*/
|
||||
export class CustomValueFormatter extends ValueFormatter {
|
||||
constructor(
|
||||
private customRenderFn?: (value: number) => string,
|
||||
precision?: number,
|
||||
locale?: string,
|
||||
) {
|
||||
super(precision, locale);
|
||||
}
|
||||
|
||||
override formatNumber(value: number): string {
|
||||
if (this.customRenderFn) {
|
||||
return this.customRenderFn(value);
|
||||
}
|
||||
return super.formatNumber(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamp utilities to eliminate duplicate conversion logic
|
||||
*/
|
||||
export class TimestampConverter {
|
||||
/**
|
||||
* Convert timestamp to seconds from start
|
||||
*/
|
||||
static toSecondsFromStart(timestamp: number, startTime: number): number {
|
||||
return Math.floor((timestamp - startTime) / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert multiple timestamps to seconds from start
|
||||
*/
|
||||
static arrayToSecondsFromStart(
|
||||
timestamps: number[],
|
||||
startTime: number,
|
||||
): number[] {
|
||||
return timestamps.map((ts) => this.toSecondsFromStart(ts, startTime));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Array utilities to eliminate duplicate fill patterns
|
||||
*/
|
||||
export class ArrayUtils {
|
||||
/**
|
||||
* Create an array filled with empty strings
|
||||
*/
|
||||
static createEmptyArray(count: number): string[] {
|
||||
return Array(count).fill("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a row with initial values and fill rest with empty strings
|
||||
*/
|
||||
static createRow(initialValues: any[], totalColumns: number): any[] {
|
||||
const emptyCount = Math.max(0, totalColumns - initialValues.length);
|
||||
return [...initialValues, ...this.createEmptyArray(emptyCount)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Metadata provider system following Open/Closed Principle
|
||||
* New metadata sections can be added without modifying existing code
|
||||
*/
|
||||
|
||||
export interface IMetadataSection {
|
||||
getTitle(): string;
|
||||
getRows(): Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base metadata section implementation
|
||||
*/
|
||||
export abstract class MetadataSection implements IMetadataSection {
|
||||
abstract getTitle(): string;
|
||||
abstract getRows(): Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export information section
|
||||
*/
|
||||
export class ExportInfoSection extends MetadataSection {
|
||||
constructor(
|
||||
private softwareName: string,
|
||||
private exportDate: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
return "Export Information";
|
||||
}
|
||||
|
||||
getRows(): Array<{ key: string; value: string }> {
|
||||
return [
|
||||
{ key: "Software", value: this.softwareName },
|
||||
{ key: "Export Date", value: this.exportDate },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PID settings section for temperature controllers
|
||||
*/
|
||||
export class TemperaturePidSection extends MetadataSection {
|
||||
constructor(
|
||||
private pidSettings: Record<string, { kp: number; ki: number; kd: number }>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
return "Temperature Controllers";
|
||||
}
|
||||
|
||||
getRows(): Array<{ key: string; value: string }> {
|
||||
const rows: Array<{ key: string; value: string }> = [];
|
||||
|
||||
Object.entries(this.pidSettings).forEach(([zone, settings]) => {
|
||||
rows.push(
|
||||
{ key: ` ${zone} - Kp`, value: settings.kp.toFixed(3) },
|
||||
{ key: ` ${zone} - Ki`, value: settings.ki.toFixed(3) },
|
||||
{ key: ` ${zone} - Kd`, value: settings.kd.toFixed(3) },
|
||||
);
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PID settings section for pressure controller
|
||||
*/
|
||||
export class PressurePidSection extends MetadataSection {
|
||||
constructor(private pidSettings: { kp: number; ki: number; kd: number }) {
|
||||
super();
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
return "Pressure Controller";
|
||||
}
|
||||
|
||||
getRows(): Array<{ key: string; value: string }> {
|
||||
return [
|
||||
{ key: " Kp", value: this.pidSettings.kp.toFixed(3) },
|
||||
{ key: " Ki", value: this.pidSettings.ki.toFixed(3) },
|
||||
{ key: " Kd", value: this.pidSettings.kd.toFixed(3) },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment statistics section
|
||||
*/
|
||||
export class CommentStatsSection extends MetadataSection {
|
||||
constructor(private commentCount: number) {
|
||||
super();
|
||||
}
|
||||
|
||||
getTitle(): string {
|
||||
return "Comment Statistics";
|
||||
}
|
||||
|
||||
getRows(): Array<{ key: string; value: string }> {
|
||||
return [{ key: "Total Comments", value: this.commentCount.toString() }];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata provider that aggregates multiple sections
|
||||
* Follows Composite Pattern
|
||||
*/
|
||||
export class MetadataProvider {
|
||||
private sections: IMetadataSection[] = [];
|
||||
|
||||
addSection(section: IMetadataSection): this {
|
||||
this.sections.push(section);
|
||||
return this;
|
||||
}
|
||||
|
||||
getSections(): IMetadataSection[] {
|
||||
return this.sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build metadata rows for Excel sheet
|
||||
*/
|
||||
buildRows(columnCount: number): string[][] {
|
||||
const rows: string[][] = [];
|
||||
|
||||
this.sections.forEach((section, index) => {
|
||||
// Add empty row before each section except the first
|
||||
if (index > 0) {
|
||||
rows.push(Array(columnCount).fill(""));
|
||||
}
|
||||
|
||||
// Add section title
|
||||
rows.push([section.getTitle(), ...Array(columnCount - 1).fill("")]);
|
||||
|
||||
// Add section rows
|
||||
section.getRows().forEach((row) => {
|
||||
rows.push([row.key, row.value, ...Array(columnCount - 2).fill("")]);
|
||||
});
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating metadata providers
|
||||
*/
|
||||
export class MetadataProviderFactory {
|
||||
static createForExport(params: {
|
||||
softwareName: string;
|
||||
exportDate: string;
|
||||
pidData?: {
|
||||
temperature?: Record<string, { kp: number; ki: number; kd: number }>;
|
||||
pressure?: { kp: number; ki: number; kd: number };
|
||||
};
|
||||
commentCount?: number;
|
||||
}): MetadataProvider {
|
||||
const provider = new MetadataProvider();
|
||||
|
||||
// Always add export info
|
||||
provider.addSection(
|
||||
new ExportInfoSection(params.softwareName, params.exportDate),
|
||||
);
|
||||
|
||||
// Add PID sections if available
|
||||
if (params.pidData?.temperature) {
|
||||
provider.addSection(
|
||||
new TemperaturePidSection(params.pidData.temperature),
|
||||
);
|
||||
}
|
||||
|
||||
if (params.pidData?.pressure) {
|
||||
provider.addSection(new PressurePidSection(params.pidData.pressure));
|
||||
}
|
||||
|
||||
// Add comment stats if provided
|
||||
if (params.commentCount !== undefined) {
|
||||
provider.addSection(new CommentStatsSection(params.commentCount));
|
||||
}
|
||||
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { IExportConfig } from "./excelExportConfig";
|
||||
import { IValueFormatter } from "./excelFormatters";
|
||||
import { MetadataProvider, MetadataProviderFactory } from "./excelMetadata";
|
||||
import { PidData } from "./excelExportTypes";
|
||||
|
||||
/**
|
||||
* Builds metadata sections for Excel sheets
|
||||
* Now uses MetadataProvider for better separation of concerns
|
||||
* @deprecated Use MetadataProvider directly for new code
|
||||
*/
|
||||
export class MetadataBuilder {
|
||||
private metadataProvider: MetadataProvider;
|
||||
private config: IExportConfig;
|
||||
private formatter: IValueFormatter;
|
||||
|
||||
constructor(config: IExportConfig, formatter: IValueFormatter) {
|
||||
this.config = config;
|
||||
this.formatter = formatter;
|
||||
this.metadataProvider = new MetadataProvider();
|
||||
}
|
||||
|
||||
addExportInfo(columnCount: number): this {
|
||||
// This method is kept for backward compatibility
|
||||
// Actual data is added when building final rows
|
||||
return this;
|
||||
}
|
||||
|
||||
addPidSettings(pidData: PidData | undefined, columnCount: number): this {
|
||||
// This method is kept for backward compatibility
|
||||
// Actual data is added when building final rows
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build metadata rows using MetadataProvider
|
||||
*/
|
||||
getRows(
|
||||
columnCount: number,
|
||||
pidData?: PidData,
|
||||
commentCount?: number,
|
||||
): string[][] {
|
||||
// Create metadata provider with all sections
|
||||
const provider = MetadataProviderFactory.createForExport({
|
||||
softwareName: this.config.getSoftwareName(),
|
||||
exportDate: this.formatter.formatDate(new Date()),
|
||||
pidData: pidData,
|
||||
commentCount: commentCount,
|
||||
});
|
||||
|
||||
return provider.buildRows(columnCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { renderUnitSymbol, Unit } from "@/control/units";
|
||||
import { IExportConfig } from "./excelExportConfig";
|
||||
|
||||
/**
|
||||
* Manages unique sheet name generation for Excel workbooks
|
||||
* Now uses IExportConfig for unit mappings
|
||||
*/
|
||||
export class SheetNameManager {
|
||||
private usedNames = new Set<string>();
|
||||
|
||||
constructor(private config: IExportConfig) {}
|
||||
|
||||
generate(
|
||||
graphTitle: string,
|
||||
seriesTitle: string,
|
||||
unit: Unit | undefined,
|
||||
): string {
|
||||
const unitSymbol = renderUnitSymbol(unit) || "";
|
||||
let sheetName = "";
|
||||
|
||||
// Use unit-based name for generic series, otherwise use series title
|
||||
if (/^Series \d+$/i.test(seriesTitle)) {
|
||||
const friendlyUnitName = this.config.getUnitFriendlyName(unitSymbol);
|
||||
sheetName = friendlyUnitName || seriesTitle;
|
||||
} else {
|
||||
const friendlyUnitName = this.config.getUnitFriendlyName(unitSymbol);
|
||||
if (
|
||||
friendlyUnitName &&
|
||||
!seriesTitle.toLowerCase().includes(friendlyUnitName.toLowerCase())
|
||||
) {
|
||||
sheetName = `${seriesTitle} ${friendlyUnitName}`;
|
||||
} else {
|
||||
sheetName = seriesTitle;
|
||||
}
|
||||
}
|
||||
|
||||
return this.makeUnique(this.sanitize(sheetName));
|
||||
}
|
||||
|
||||
private sanitize(name: string): string {
|
||||
return (
|
||||
name
|
||||
.replace(/[\\/?*$:[\]]/g, "_")
|
||||
.substring(0, 31)
|
||||
.trim() || "Sheet"
|
||||
);
|
||||
}
|
||||
|
||||
private makeUnique(name: string): string {
|
||||
let finalName = name;
|
||||
let counter = 1;
|
||||
|
||||
while (this.usedNames.has(finalName)) {
|
||||
const suffix = `_${counter}`;
|
||||
const maxBaseLength = 31 - suffix.length;
|
||||
finalName = `${name.substring(0, maxBaseLength)}${suffix}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
this.usedNames.add(finalName);
|
||||
return finalName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Handles statistical calculations for time series data
|
||||
*/
|
||||
export class StatisticsCalculator {
|
||||
static calculate(values: number[]): {
|
||||
min: number;
|
||||
max: number;
|
||||
avg: number;
|
||||
stdDev: number;
|
||||
range: number;
|
||||
p25: number;
|
||||
p50: number;
|
||||
p75: number;
|
||||
} {
|
||||
if (values.length === 0) {
|
||||
throw new Error("Cannot calculate statistics for empty array");
|
||||
}
|
||||
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const avg = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
const stdDev = Math.sqrt(
|
||||
values.reduce((sum, val) => sum + Math.pow(val - avg, 2), 0) /
|
||||
values.length,
|
||||
);
|
||||
const range = max - min;
|
||||
|
||||
const sortedValues = [...values].sort((a, b) => a - b);
|
||||
const p25 = sortedValues[Math.floor(sortedValues.length * 0.25)];
|
||||
const p50 = sortedValues[Math.floor(sortedValues.length * 0.5)];
|
||||
const p75 = sortedValues[Math.floor(sortedValues.length * 0.75)];
|
||||
|
||||
return { min, max, avg, stdDev, range, p25, p50, p75 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Additional utility classes for Excel export
|
||||
* Following SOLID principles and DRY
|
||||
*/
|
||||
|
||||
/**
|
||||
* Utility for calculating optimal Y-axis range based on data
|
||||
* Can be extended for more sophisticated scaling algorithms
|
||||
*/
|
||||
export class ChartAxisCalculator {
|
||||
/**
|
||||
* Calculate optimal Y-axis range with padding
|
||||
*/
|
||||
static calculateOptimalRange(
|
||||
values: number[],
|
||||
paddingPercent: number = 10,
|
||||
): { min: number; max: number } {
|
||||
if (values.length === 0) {
|
||||
return { min: 0, max: 1000 }; // fallback
|
||||
}
|
||||
|
||||
const dataMin = Math.min(...values);
|
||||
const dataMax = Math.max(...values);
|
||||
const range = dataMax - dataMin;
|
||||
|
||||
// Add padding
|
||||
const padding = range * (paddingPercent / 100);
|
||||
|
||||
return {
|
||||
min: Math.floor(dataMin - padding),
|
||||
max: Math.ceil(dataMax + padding),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Y-axis range instruction for Excel
|
||||
*/
|
||||
static formatRangeInstruction(min: number, max: number): string {
|
||||
return `5. Set Y-axis range: ${min} to ${max}`;
|
||||
}
|
||||
}
|
||||
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
/**
|
||||
* Sanitizes cell values to avoid invalid XML in XLSX exports.
|
||||
*/
|
||||
export class ExcelCellSanitizer {
|
||||
/* eslint-disable no-control-regex */
|
||||
private static invalidXmlChars =
|
||||
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\uD800-\uDFFF\uFFFE\uFFFF]/g;
|
||||
/* eslint-enable no-control-regex */
|
||||
|
||||
static sanitizeCell(value: unknown): string | number {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : "";
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value.replace(this.invalidXmlChars, "");
|
||||
}
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return String(value).replace(this.invalidXmlChars, "");
|
||||
}
|
||||
|
||||
static sanitizeRow(row: unknown[]): Array<string | number> {
|
||||
return row.map((value) => this.sanitizeCell(value));
|
||||
}
|
||||
|
||||
static sanitizeWorksheet(worksheet: XLSX.WorkSheet): void {
|
||||
Object.keys(worksheet).forEach((key) => {
|
||||
if (key.startsWith("!")) return;
|
||||
const cell = worksheet[key] as XLSX.CellObject | undefined;
|
||||
if (!cell) return;
|
||||
|
||||
const sanitized = this.sanitizeCell(cell.v);
|
||||
if (sanitized === "") {
|
||||
delete worksheet[key];
|
||||
return;
|
||||
}
|
||||
|
||||
cell.v = sanitized as any;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for fetching machine PID settings
|
||||
*/
|
||||
export interface IPidDataProvider {
|
||||
fetchPidSettings(): Promise<{
|
||||
temperature?: Record<string, { kp: number; ki: number; kd: number }>;
|
||||
pressure?: { kp: number; ki: number; kd: number };
|
||||
} | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider that fetches PID settings from machine API
|
||||
* Implements Dependency Inversion Principle
|
||||
*/
|
||||
export class MachinePidDataProvider implements IPidDataProvider {
|
||||
constructor(
|
||||
private baseUrl: string = "http://10.10.10.1:3001",
|
||||
private machineSlug?: string,
|
||||
private machineSerial?: number,
|
||||
) {}
|
||||
|
||||
async fetchPidSettings(): Promise<{
|
||||
temperature?: Record<string, { kp: number; ki: number; kd: number }>;
|
||||
pressure?: { kp: number; ki: number; kd: number };
|
||||
} | null> {
|
||||
try {
|
||||
// If machine slug/serial provided, fetch from specific machine
|
||||
if (this.machineSlug && this.machineSerial !== undefined) {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/api/v2/machine/${this.machineSlug}/${this.machineSerial}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn("Failed to fetch machine data for PID settings");
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Extract PID settings from machine data
|
||||
// This is a placeholder - actual implementation depends on machine API structure
|
||||
return this.extractPidFromMachineData(data);
|
||||
}
|
||||
|
||||
// Otherwise, return null (PID data should be passed in)
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error fetching PID settings from machine:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private extractPidFromMachineData(machineData: any): {
|
||||
temperature?: Record<string, { kp: number; ki: number; kd: number }>;
|
||||
pressure?: { kp: number; ki: number; kd: number };
|
||||
} | null {
|
||||
// This is a placeholder implementation
|
||||
// Actual extraction depends on machine API structure
|
||||
// TODO: Implement based on actual machine data structure
|
||||
|
||||
const pidSettings: any = {};
|
||||
|
||||
// Example: Look for PID-related fields in machine data
|
||||
if (machineData.temperature_controllers) {
|
||||
pidSettings.temperature = machineData.temperature_controllers;
|
||||
}
|
||||
|
||||
if (machineData.pressure_controller) {
|
||||
pidSettings.pressure = machineData.pressure_controller;
|
||||
}
|
||||
|
||||
return Object.keys(pidSettings).length > 0 ? pidSettings : null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock provider for testing
|
||||
*/
|
||||
export class MockPidDataProvider implements IPidDataProvider {
|
||||
constructor(
|
||||
private mockData: {
|
||||
temperature?: Record<string, { kp: number; ki: number; kd: number }>;
|
||||
pressure?: { kp: number; ki: number; kd: number };
|
||||
} | null,
|
||||
) {}
|
||||
|
||||
async fetchPidSettings(): Promise<{
|
||||
temperature?: Record<string, { kp: number; ki: number; kd: number }>;
|
||||
pressure?: { kp: number; ki: number; kd: number };
|
||||
} | null> {
|
||||
return Promise.resolve(this.mockData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Version info rendering removed (YAGNI).
|
||||
export {};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { exportGraphsToExcel } from "./excelExport";
|
||||
export type { GraphExportData } from "./excelExport";
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { PropGraphSync, SwitchOrigin } from "./types";
|
||||
import { GraphExportData, exportGraphsToExcel } from "./excelExport";
|
||||
import { useLogsStore } from "@/stores/logsStore";
|
||||
import { useGraphSettingsStore } from "@/stores/graphSettingsStore";
|
||||
|
||||
export function useGraphSync(exportGroupId?: string) {
|
||||
@@ -132,7 +133,14 @@ export function useGraphSync(exportGroupId?: string) {
|
||||
console.warn("No graphs registered for export");
|
||||
return;
|
||||
}
|
||||
exportGraphsToExcel(graphDataRef.current, exportGroupId || "synced-graphs");
|
||||
const logs = useLogsStore.getState().entries;
|
||||
exportGraphsToExcel(
|
||||
graphDataRef.current,
|
||||
exportGroupId || "synced-graphs",
|
||||
logs,
|
||||
).catch((error) => {
|
||||
console.error("Failed to export graphs:", error);
|
||||
});
|
||||
}, [exportGroupId]);
|
||||
|
||||
const handleTimeWindowChange = useCallback(
|
||||
|
||||
@@ -132,7 +132,7 @@ export function usePresets<T>({
|
||||
if (latestPresetId === undefined) {
|
||||
const preset = store.insert({
|
||||
id: undefined,
|
||||
name: "Latest Machine Stettings",
|
||||
name: "Latest Machine Settings",
|
||||
machineIdentification: machine_identification,
|
||||
lastModified: new Date(),
|
||||
schemaVersion,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SectionTitle } from "@/components/SectionTitle";
|
||||
import { Terminal } from "@/components/Terminal";
|
||||
import { TouchButton } from "@/components/touch/TouchButton";
|
||||
import { UpdateProgressBar } from "@/components/UpdateProgressBar";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { updateExecute, updateCancelWithStore } from "@/helpers/update_helpers";
|
||||
import { useUpdateStore } from "@/stores/updateStore";
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
@@ -89,7 +88,7 @@ export function UpdateExecutePage() {
|
||||
<Page>
|
||||
<SectionTitle title="Apply Update" />
|
||||
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="flex flex-row gap-4">
|
||||
<TouchButton
|
||||
className="w-max"
|
||||
icon="lu:CircleFadingArrowUp"
|
||||
@@ -109,14 +108,6 @@ export function UpdateExecutePage() {
|
||||
Cancel Update
|
||||
</TouchButton>
|
||||
)}
|
||||
{isUpdating && (
|
||||
<div className="ml-auto flex items-center gap-2 rounded-xl border border-blue-400 bg-blue-600 px-4 py-2.5 text-white shadow-xl backdrop-blur-sm transition-all duration-300">
|
||||
<Icon name="lu:Info" className="h-5 w-5 text-blue-100" />
|
||||
<span className="text-base leading-snug text-blue-50">
|
||||
Updates typically take approximately <strong>5 minutes</strong>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{currentUpdateInfo && (
|
||||
<Alert title="Update Information" variant="info">
|
||||
@@ -145,7 +136,8 @@ export function UpdateExecutePage() {
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
Please stay connected to the internet and leave the power on. The
|
||||
machine will reboot after the update completes.
|
||||
update procedure takes a couple of minutes and reboots the machine
|
||||
afterwards.
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
+13
-2
@@ -3,8 +3,19 @@
|
||||
|
||||
home.packages = with pkgs; [ pkgs.qitechPackages.electron ];
|
||||
|
||||
xdg.configFile."autostart/de.qitech.control-electron.desktop".source =
|
||||
"${pkgs.qitechPackages.electron}/share/applications/de.qitech.control-electron.desktop";
|
||||
# Add autostart entry for the QiTech electron app
|
||||
xdg.configFile."autostart/de.qitech.control-electron.desktop".text = ''
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=QiTech Control
|
||||
Comment=QiTech Control
|
||||
Exec=qitech-control-electron
|
||||
Icon=de.qitech.control-electron
|
||||
Terminal=false
|
||||
StartupWMClass=QiTech Control
|
||||
X-GNOME-Autostart-enabled=true
|
||||
X-GNOME-Autostart-Phase=Applications
|
||||
'';
|
||||
|
||||
dconf.settings = {
|
||||
# Set GNOME wallpaper
|
||||
|
||||
@@ -10,7 +10,7 @@ buildNpmPackage rec {
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
|
||||
|
||||
makeCacheWritable = true;
|
||||
npmDepsHash = "sha256-jW9nogboiuYBzjLNM5rlYsdfp+58OlDf+kxjgNlST70=";
|
||||
npmDepsHash = "sha256-Asa8lfMXGUvGkudj0DXQk9VRQ8ugmmP969t7vcaTE0E=";
|
||||
npmFlags = [ "--no-audit" "--no-fund" ];
|
||||
|
||||
installPhase = ''
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ tracing-subscriber = { version = "0.3.22", features = [
|
||||
utils = { version = "0.1.0", path="../utils" }
|
||||
spin_sleep = "1.3.3"
|
||||
dhat = { version = "0.3.3", optional = true }
|
||||
ctrlc = { version = "3.5", optional = true }
|
||||
ctrlc = { version = "3.3", optional = true }
|
||||
ta = "0.5.0"
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -33,6 +33,7 @@ use rest::init::start_api_thread;
|
||||
use serialport::UsbPortInfo;
|
||||
use smol::{
|
||||
channel::{Receiver, Sender},
|
||||
future,
|
||||
lock::RwLock,
|
||||
};
|
||||
use socketioxide::extract::SocketRef;
|
||||
@@ -412,7 +413,7 @@ fn main() {
|
||||
app_state.clone(),
|
||||
));
|
||||
}
|
||||
smol::Timer::after(Duration::from_micros(150)).await;
|
||||
future::yield_now().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user