diff --git a/electron/package-lock.json b/electron/package-lock.json index 72a26681e..6801093fc 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -9052,6 +9052,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", @@ -10585,11 +10597,32 @@ "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.11.tgz", "integrity": "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q==", "license": "MIT", - "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", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" } }, "node_modules/sonner": { @@ -11430,18 +11463,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", diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts deleted file mode 100644 index 493975862..000000000 --- a/electron/src/components/graph/excelExport.ts +++ /dev/null @@ -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 GraphExportData | null>, - groupId: string, -): void { - try { - // Filter out invalid series IDs (those without "-series-") - const filteredMap = new Map 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(); // 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 { - 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; -} diff --git a/electron/src/components/graph/excelExport/excelAnalysisSheetBuilder.ts b/electron/src/components/graph/excelExport/excelAnalysisSheetBuilder.ts new file mode 100644 index 000000000..f15cc4085 --- /dev/null +++ b/electron/src/components/graph/excelExport/excelAnalysisSheetBuilder.ts @@ -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 { + // 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(); + this.allSheetData.forEach((data) => { + data.timestamps.forEach((ts) => allTimestamps.add(ts)); + }); + return Array.from(allTimestamps).sort((a, b) => a - b); + } + + private buildDataByTimestampMap(): Map> { + const dataByTimestamp = new Map>(); + + 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>, + 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 { + 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; + } +} diff --git a/electron/src/components/graph/excelExport/excelCommentManager.ts b/electron/src/components/graph/excelExport/excelCommentManager.ts new file mode 100644 index 000000000..eda0f6c81 --- /dev/null +++ b/electron/src/components/graph/excelExport/excelCommentManager.ts @@ -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, + ); + } +} diff --git a/electron/src/components/graph/excelExport/excelDataSheetBuilder.ts b/electron/src/components/graph/excelExport/excelDataSheetBuilder.ts new file mode 100644 index 000000000..59ad66247 --- /dev/null +++ b/electron/src/components/graph/excelExport/excelDataSheetBuilder.ts @@ -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); + } +} diff --git a/electron/src/components/graph/excelExport/excelDateFormatter.ts b/electron/src/components/graph/excelExport/excelDateFormatter.ts new file mode 100644 index 000000000..df9f1c3c9 --- /dev/null +++ b/electron/src/components/graph/excelExport/excelDateFormatter.ts @@ -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); + } +} diff --git a/electron/src/components/graph/excelExport/excelExport.ts b/electron/src/components/graph/excelExport/excelExport.ts new file mode 100644 index 000000000..a36c44848 --- /dev/null +++ b/electron/src/components/graph/excelExport/excelExport.ts @@ -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 GraphExportData | null>, + groupId: string, + logs: LogEntry[] = [], + pidData?: PidData, + ): Promise { + 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 GraphExportData | null>, + ): Map GraphExportData | null> { + const filteredMap = new Map 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 GraphExportData | null>, + groupId: string, + logs: LogEntry[] = [], + pidData?: PidData, +): Promise { + const exporter = new ExcelExporter(); + await exporter.export(graphDataMap, groupId, logs, pidData); +} diff --git a/electron/src/components/graph/excelExport/excelExportConfig.ts b/electron/src/components/graph/excelExport/excelExportConfig.ts new file mode 100644 index 000000000..32043d749 --- /dev/null +++ b/electron/src/components/graph/excelExport/excelExportConfig.ts @@ -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, + 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(); + } +} diff --git a/electron/src/components/graph/excelExport/excelExportTypes.ts b/electron/src/components/graph/excelExport/excelExportTypes.ts new file mode 100644 index 000000000..10df09c60 --- /dev/null +++ b/electron/src/components/graph/excelExport/excelExportTypes.ts @@ -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; // keyed by zone + pressure?: PidSettings; +}; + +export type CombinedSheetData = { + sheetName: string; + timestamps: number[]; + values: number[]; + unit: string; + seriesTitle: string; + graphTitle: string; + targetLines: GraphLine[]; + color?: string; +}; diff --git a/electron/src/components/graph/excelExport/excelFormatters.ts b/electron/src/components/graph/excelExport/excelFormatters.ts new file mode 100644 index 000000000..b535410b9 --- /dev/null +++ b/electron/src/components/graph/excelExport/excelFormatters.ts @@ -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)]; + } +} diff --git a/electron/src/components/graph/excelExport/excelMetadata.ts b/electron/src/components/graph/excelExport/excelMetadata.ts new file mode 100644 index 000000000..36369ca8a --- /dev/null +++ b/electron/src/components/graph/excelExport/excelMetadata.ts @@ -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, + ) { + 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; + 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; + } +} diff --git a/electron/src/components/graph/excelExport/excelMetadataBuilder.ts b/electron/src/components/graph/excelExport/excelMetadataBuilder.ts new file mode 100644 index 000000000..59432cebb --- /dev/null +++ b/electron/src/components/graph/excelExport/excelMetadataBuilder.ts @@ -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); + } +} diff --git a/electron/src/components/graph/excelExport/excelSheetNameManager.ts b/electron/src/components/graph/excelExport/excelSheetNameManager.ts new file mode 100644 index 000000000..889bb4489 --- /dev/null +++ b/electron/src/components/graph/excelExport/excelSheetNameManager.ts @@ -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(); + + 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; + } +} diff --git a/electron/src/components/graph/excelExport/excelStatisticsCalculator.ts b/electron/src/components/graph/excelExport/excelStatisticsCalculator.ts new file mode 100644 index 000000000..ac6757009 --- /dev/null +++ b/electron/src/components/graph/excelExport/excelStatisticsCalculator.ts @@ -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 }; + } +} diff --git a/electron/src/components/graph/excelExport/excelUtils.ts b/electron/src/components/graph/excelExport/excelUtils.ts new file mode 100644 index 000000000..85d9ae7d1 --- /dev/null +++ b/electron/src/components/graph/excelExport/excelUtils.ts @@ -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 { + 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; + 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; + 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; + 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; + pressure?: { kp: number; ki: number; kd: number }; + } | null, + ) {} + + async fetchPidSettings(): Promise<{ + temperature?: Record; + pressure?: { kp: number; ki: number; kd: number }; + } | null> { + return Promise.resolve(this.mockData); + } +} diff --git a/electron/src/components/graph/excelExport/excelVersionInfoRenderer.ts b/electron/src/components/graph/excelExport/excelVersionInfoRenderer.ts new file mode 100644 index 000000000..4423d3780 --- /dev/null +++ b/electron/src/components/graph/excelExport/excelVersionInfoRenderer.ts @@ -0,0 +1,2 @@ +// Version info rendering removed (YAGNI). +export {}; diff --git a/electron/src/components/graph/excelExport/index.ts b/electron/src/components/graph/excelExport/index.ts new file mode 100644 index 000000000..dae7941cc --- /dev/null +++ b/electron/src/components/graph/excelExport/index.ts @@ -0,0 +1,2 @@ +export { exportGraphsToExcel } from "./excelExport"; +export type { GraphExportData } from "./excelExport"; diff --git a/electron/src/components/graph/useGraphSync.ts b/electron/src/components/graph/useGraphSync.ts index a86ce7d39..de5b348d4 100644 --- a/electron/src/components/graph/useGraphSync.ts +++ b/electron/src/components/graph/useGraphSync.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useRef } from "react"; import { PropGraphSync } from "./types"; import { GraphExportData, exportGraphsToExcel } from "./excelExport"; +import { useLogsStore } from "@/stores/logsStore"; import { useGraphSettingsStore } from "@/stores/graphSettingsStore"; export function useGraphSync(exportGroupId?: string) { @@ -126,7 +127,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( diff --git a/nixos/packages/electron.nix b/nixos/packages/electron.nix index fb632284b..27c2f6bf6 100644 --- a/nixos/packages/electron.nix +++ b/nixos/packages/electron.nix @@ -10,7 +10,7 @@ buildNpmPackage rec { ELECTRON_SKIP_BINARY_DOWNLOAD = 1; makeCacheWritable = true; - npmDepsHash = "sha256-wzVjsgcZsViPOyQKlKTb5gJIeY416KyCSiheSXSRZoc="; + npmDepsHash = "sha256-aW8rlE4Dd9i0cBbPooMJpxOXm7OBYTD/xkEJKSbGkAU="; npmFlags = [ "--no-audit" "--no-fund" ]; installPhase = ''