From 70d4af9ca2288d0b62e933a2ad6698b79d4c2974 Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Sat, 17 Jan 2026 03:11:15 +0100 Subject: [PATCH 01/26] fixed names --- electron/src/components/graph/excelExport.ts | 334 ++++++++++++++++++- 1 file changed, 316 insertions(+), 18 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index 493975862..fb18d1fe3 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -64,25 +64,17 @@ export function exportGraphsToExcel( targetLines: targetLines, }; - // Create and append statistics sheet - const statsData = createGraphLineStatsSheet(graphLineData); - const statsWorksheet = XLSX.utils.aoa_to_sheet(statsData); - const statsSheetName = generateUniqueSheetName( - `${seriesTitle} Stats`, + // Generate better sheet name based on graph title, series title, and unit + const sheetName = generateSheetName( + exportData.config.title, + seriesTitle, + exportData.unit, 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); - } + + // Create combined sheet with data and stats + const combinedWorksheet = createCombinedSheet(graphLineData, sheetName); + XLSX.utils.book_append_sheet(workbook, combinedWorksheet, sheetName); processedCount++; }); @@ -101,7 +93,313 @@ export function exportGraphsToExcel( } } -// Generate statistics sheet for a graph line +// Generate better sheet names based on graph title, series title, and unit +function generateSheetName( + graphTitle: string, + seriesTitle: string, + unit: Unit | undefined, + usedSheetNames: Set, +): string { + // Determine the type of data based on unit + const unitSymbol = renderUnitSymbol(unit) || ""; + + // Create descriptive sheet name based on series and graph context + let sheetName = ""; + + // For temperature/power graphs (Nozzle, Front, Middle, Back, Total, Motor) + if (["Nozzle", "Front", "Middle", "Back"].includes(seriesTitle)) { + if (unitSymbol === "°C") { + sheetName = `${seriesTitle} Temp`; + } else if (unitSymbol === "W") { + sheetName = `${seriesTitle} Watt`; + } else { + sheetName = `${seriesTitle}`; + } + } else if (seriesTitle === "Total") { + if (unitSymbol === "W") { + sheetName = "Total Watt"; + } else { + sheetName = "Total"; + } + } else if (seriesTitle === "Motor") { + sheetName = "Motor"; + } + // For current/pressure/speed graphs (Series 1, 2, 3 fallback) + else if (seriesTitle === "Series 1") { + if (unitSymbol === "A") { + sheetName = "Ampere"; + } else if (unitSymbol === "bar") { + sheetName = "Bar"; + } else if (unitSymbol === "rpm" || unitSymbol === "1/min") { + sheetName = "Rpm"; + } else { + sheetName = "Series 1"; + } + } else if (seriesTitle === "Series 2") { + if (unitSymbol === "bar") { + sheetName = "Bar"; + } else if (unitSymbol === "rpm" || unitSymbol === "1/min") { + sheetName = "Rpm"; + } else { + sheetName = "Series 2"; + } + } else if (seriesTitle === "Series 3") { + if (unitSymbol === "rpm" || unitSymbol === "1/min") { + sheetName = "Rpm"; + } else { + sheetName = "Series 3"; + } + } + // For laser measurement graphs + else if (["Diameter", "X-Diameter", "Y-Diameter", "Roundness"].includes(seriesTitle)) { + sheetName = seriesTitle; + } + // Default fallback + else { + sheetName = seriesTitle; + } + + // Sanitize and limit length + sheetName = sheetName + .replace(/[\\/?*$:[\]]/g, "_") + .substring(0, 31); + + if (!sheetName || sheetName.trim().length === 0) { + sheetName = "Sheet"; + } + + // Make unique if needed + let finalName = sheetName; + let counter = 1; + + while (usedSheetNames.has(finalName)) { + const suffix = `_${counter}`; + const maxBaseLength = 31 - suffix.length; + finalName = `${sheetName.substring(0, maxBaseLength)}${suffix}`; + counter++; + } + + usedSheetNames.add(finalName); + return finalName; +} + +// Create combined sheet with data (columns A-C) and stats (columns E-F) +function createCombinedSheet( + graphLine: { + graphTitle: string; + lineTitle: string; + series: TimeSeries; + color?: string; + unit?: Unit; + renderValue?: (value: number) => string; + config: GraphConfig; + targetLines: GraphLine[]; + }, + sheetName: string, +): XLSX.WorkSheet { + const [timestamps, values] = seriesToUPlotData(graphLine.series.long); + const unitSymbol = renderUnitSymbol(graphLine.unit) || ""; + + // Create a 2D array for the combined sheet + const sheetData: any[][] = []; + + // Determine column header based on sheet name and unit + // Extract base name from sheet name (e.g., "Nozzle Temp, Watt" -> "Nozzle") + let baseName = sheetName; + + if (sheetName.includes(",")) { + // For sheets with multiple units (e.g., "Nozzle Temp, Watt"), extract base name + const parts = sheetName.split(",").map((s) => s.trim()); + baseName = parts[0].replace(/\s+(Temp|Watt|Stats)$/i, ""); + } else { + // Remove common suffixes + baseName = sheetName.replace(/\s+(Stats|Temp|Watt)$/i, ""); + } + + // Create column header: "unit baseName" (e.g., "°C Nozzle", "W Nozzle") + const col1Header = unitSymbol ? `${unitSymbol} ${baseName}` : baseName; + + // Add header row - Column A: Timestamp, Column B: Value, Column C: (empty), Column D: (empty), Column E-F: Stats + sheetData.push([ + "Timestamp", + col1Header, + "", + "", + "Statistic", + "Value", + ]); + + // Prepare stats data + const statsRows: string[][] = []; + + statsRows.push(["Graph", graphLine.graphTitle]); + statsRows.push(["Line Name", graphLine.lineTitle]); + statsRows.push(["Line Color", graphLine.color || "Default"]); + statsRows.push([ + "Generated", + new Date().toLocaleString("de-DE", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }), + ]); + 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", + firstDate.toLocaleString("de-DE", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }), + ]); + statsRows.push([ + "Time Range End", + lastDate.toLocaleString("de-DE", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }), + ]); + + const duration = timestamps[timestamps.length - 1] - timestamps[0]; + const durationHours = (duration / (1000 * 60 * 60)).toFixed(2); + statsRows.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, + ); + + statsRows.push(["", ""]); + statsRows.push([ + `Minimum Value (${unitSymbol})`, + graphLine.renderValue + ? graphLine.renderValue(minValue) + : minValue.toFixed(3), + ]); + statsRows.push([ + `Maximum Value (${unitSymbol})`, + graphLine.renderValue + ? graphLine.renderValue(maxValue) + : maxValue.toFixed(3), + ]); + statsRows.push([ + `Average Value (${unitSymbol})`, + graphLine.renderValue + ? graphLine.renderValue(avgValue) + : avgValue.toFixed(3), + ]); + statsRows.push([ + `Standard Deviation (${unitSymbol})`, + graphLine.renderValue + ? graphLine.renderValue(stdDev) + : stdDev.toFixed(3), + ]); + statsRows.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)]; + + statsRows.push(["", ""]); + statsRows.push([ + `25th Percentile (${unitSymbol})`, + graphLine.renderValue ? graphLine.renderValue(p25) : p25.toFixed(3), + ]); + statsRows.push([ + `50th Percentile (${unitSymbol})`, + graphLine.renderValue ? graphLine.renderValue(p50) : p50.toFixed(3), + ]); + statsRows.push([ + `75th Percentile (${unitSymbol})`, + graphLine.renderValue ? graphLine.renderValue(p75) : p75.toFixed(3), + ]); + } + } + + // Add data rows with stats in columns E-F + const maxRows = Math.max(timestamps.length, statsRows.length); + + for (let i = 0; i < maxRows; i++) { + const row: any[] = ["", "", "", ""]; + + // Add timestamp and value data (columns A-B) + if (i < timestamps.length) { + const timestamp = timestamps[i]; + const value = values[i]; + + // Format timestamp as dd.mm.yyyy hh:mm:ss + const date = new Date(timestamp); + const formattedDate = date.toLocaleString("de-DE", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + row[0] = formattedDate; + row[1] = graphLine.renderValue + ? graphLine.renderValue(value) + : value?.toFixed(3) || ""; + } + + // Add stats (columns E-F) + if (i < statsRows.length) { + row[4] = statsRows[i][0]; + row[5] = statsRows[i][1]; + } else { + row[4] = ""; + row[5] = ""; + } + + sheetData.push(row); + } + + // Convert to worksheet + const worksheet = XLSX.utils.aoa_to_sheet(sheetData); + + // Set column widths for better readability + worksheet["!cols"] = [ + { wch: 20 }, // Timestamp + { wch: 15 }, // Value + { wch: 5 }, // Empty column C + { wch: 5 }, // Empty column D + { wch: 30 }, // Statistic name + { wch: 20 }, // Statistic value + ]; + + return worksheet; +} + +// Generate statistics sheet for a graph line (kept for reference but not used) function createGraphLineStatsSheet(graphLine: { graphTitle: string; lineTitle: string; From cf263702214bc5ea1cc574d9580557fd370939bb Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Tue, 20 Jan 2026 15:11:20 +0100 Subject: [PATCH 02/26] merged the redundant sheets and created a new auswertung sheet with chart image generation using uPlot. Scatter chart with ExcelJs did not work because of limited support and xlsx-chart creates an entirely new excel file for chart generation --- electron/src/components/graph/excelExport.ts | 541 ++++++++++++++++++- 1 file changed, 538 insertions(+), 3 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index fb18d1fe3..cae06a4b8 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -1,7 +1,10 @@ import * as XLSX from "xlsx"; +import ExcelJS from "exceljs"; +import uPlot from "uplot"; import { TimeSeries, seriesToUPlotData } from "@/lib/timeseries"; import { renderUnitSymbol, Unit } from "@/control/units"; import { GraphConfig, SeriesData, GraphLine } from "./types"; +import { useLogsStore } from "@/stores/logsStore"; export type GraphExportData = { config: GraphConfig; @@ -10,10 +13,21 @@ export type GraphExportData = { renderValue?: (value: number) => string; }; -export function exportGraphsToExcel( +// Type for combined sheet data used in Auswertung +type CombinedSheetData = { + sheetName: string; + timestamps: number[]; + values: number[]; + unit: string; + seriesTitle: string; + graphTitle: string; + targetLines: GraphLine[]; +}; + +export async function exportGraphsToExcel( graphDataMap: Map GraphExportData | null>, groupId: string, -): void { +): Promise { try { // Filter out invalid series IDs (those without "-series-") const filteredMap = new Map GraphExportData | null>(); @@ -32,6 +46,9 @@ export function exportGraphsToExcel( const usedSheetNames = new Set(); // Track unique sheet names let processedCount = 0; + // Collect all sheet data for Auswertung sheet + const allSheetData: CombinedSheetData[] = []; + // Process each valid series filteredMap.forEach((getDataFn, seriesId) => { const exportData = getDataFn(); @@ -76,6 +93,18 @@ export function exportGraphsToExcel( const combinedWorksheet = createCombinedSheet(graphLineData, sheetName); XLSX.utils.book_append_sheet(workbook, combinedWorksheet, sheetName); + // Collect data for Auswertung sheet + const [timestamps, values] = seriesToUPlotData(series.newData.long); + allSheetData.push({ + sheetName, + timestamps, + values, + unit: renderUnitSymbol(exportData.unit) || "", + seriesTitle, + graphTitle: exportData.config.title, + targetLines, + }); + processedCount++; }); @@ -84,8 +113,84 @@ export function exportGraphsToExcel( return; } + // Create Auswertung (Analysis) sheet with combined data and chart + const auswertungSheet = await createAuswertungSheet( + allSheetData, + groupId + ); + XLSX.utils.book_append_sheet(workbook, auswertungSheet, "Auswertung"); + + // Write XLSX to buffer first + const xlsxBuffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }); + + // Convert to ExcelJS workbook to add chart image + const excelJSWorkbook = new ExcelJS.Workbook(); + await excelJSWorkbook.xlsx.load(xlsxBuffer); + + // Find the Auswertung sheet + const auswertungWorksheet = excelJSWorkbook.getWorksheet("Auswertung"); + + if (auswertungWorksheet) { + // Generate chart image + const sortedTimestamps = Array.from( + new Set(allSheetData.flatMap((d) => d.timestamps)) + ).sort((a, b) => a - b); + + const startTime = sortedTimestamps[0]; + const startDate = new Date(startTime); + const endDate = new Date(sortedTimestamps[sortedTimestamps.length - 1]); + + const formatDateTime = (date: Date) => + date.toLocaleString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + const timeRangeTitle = `${formatDateTime(startDate)} bis ${formatDateTime(endDate)}`; + + const chartImage = await generateChartImage( + allSheetData, + groupId, + timeRangeTitle, + sortedTimestamps, + startTime + ); + + if (chartImage) { + // Add image to worksheet + const imageId = excelJSWorkbook.addImage({ + base64: chartImage, + extension: "png", + }); + + // Find a good position for the image (after the data and metadata) + const lastRow = auswertungWorksheet.rowCount; + + auswertungWorksheet.addImage(imageId, { + tl: { col: 0, row: lastRow + 2 }, // top-left + ext: { width: 1200, height: 600 }, // size + }); + } + } + + // Write final file with ExcelJS const filename = `${groupId.toLowerCase().replace(/\s+/g, "_")}_export_${exportTimestamp}.xlsx`; - XLSX.writeFile(workbook, filename); + const buffer = await excelJSWorkbook.xlsx.writeBuffer(); + + // Create blob and download + 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); } catch (error) { alert( `Error exporting data to Excel: ${error instanceof Error ? error.message : "Unknown error"}. Please try again.`, @@ -93,6 +198,436 @@ export function exportGraphsToExcel( } } +// Generate chart image from data using uPlot +async function generateChartImage( + allSheetData: CombinedSheetData[], + groupId: string, + timeRangeTitle: string, + sortedTimestamps: number[], + startTime: number, +): Promise { + try { + // Create an off-screen div for the chart + const container = document.createElement("div"); + container.style.width = "1200px"; + container.style.height = "600px"; + container.style.position = "absolute"; + container.style.left = "-9999px"; + document.body.appendChild(container); + + // Prepare data for uPlot: [timestamps in seconds, ...value arrays] + const chartData: number[][] = [ + sortedTimestamps.map((ts) => (ts - startTime) / 1000), // X-axis: seconds from start + ]; + + const series: uPlot.Series[] = [ + { + label: "Time (s)", + }, + ]; + + // Add each data series + allSheetData.forEach((sheetData) => { + const values = new Array(sortedTimestamps.length).fill(null); + + // Map values to corresponding timestamps + sheetData.timestamps.forEach((ts, idx) => { + const timeIndex = sortedTimestamps.indexOf(ts); + if (timeIndex !== -1) { + values[timeIndex] = sheetData.values[idx]; + } + }); + + chartData.push(values); + + // Color mapping based on data type + const getSeriesColor = (name: string): string => { + if (name.includes("Temp")) return "#FF6B6B"; + if (name.includes("Watt") || name.includes("W ")) return "#4ECDC4"; + if (name === "Bar") return "#95E1D3"; + if (name === "Rpm") return "#F38181"; + return "#AA96DA"; + }; + + series.push({ + label: sheetData.sheetName, + stroke: getSeriesColor(sheetData.sheetName), + width: 2, + points: { show: false }, + }); + }); + + // Create uPlot instance + const opts: uPlot.Options = { + title: `${groupId} - ${timeRangeTitle}`, + width: 1200, + height: 600, + series, + scales: { + x: { + time: false, + }, + }, + axes: [ + { + label: "Time (seconds)", + stroke: "#333", + grid: { stroke: "#e0e0e0", width: 1 }, + }, + { + label: "Values", + stroke: "#333", + grid: { stroke: "#e0e0e0", width: 1 }, + }, + ], + legend: { + show: true, + }, + }; + + const plot = new uPlot(opts, chartData as uPlot.AlignedData, container); + + // Wait for render + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Get the canvas element + const canvas = container.querySelector("canvas"); + if (!canvas) { + document.body.removeChild(container); + return null; + } + + // Convert canvas to base64 PNG + const imageData = canvas.toDataURL("image/png"); + + // Clean up + plot.destroy(); + document.body.removeChild(container); + + // Return base64 data (remove the data:image/png;base64, prefix) + return imageData.split(",")[1]; + } catch (error) { + console.error("Error generating chart image:", error); + return null; + } +} + +// Create Auswertung (Analysis) sheet with combined data from all sheets +async function createAuswertungSheet( + allSheetData: CombinedSheetData[], + groupId: string, +): Promise { + // Find all unique timestamps across all series + const allTimestamps = new Set(); + allSheetData.forEach((data) => { + data.timestamps.forEach((ts) => allTimestamps.add(ts)); + }); + + const sortedTimestamps = Array.from(allTimestamps).sort((a, b) => a - b); + + // Calculate time range + const startTime = sortedTimestamps[0]; + const endTime = sortedTimestamps[sortedTimestamps.length - 1]; + const startDate = new Date(startTime); + const endDate = new Date(endTime); + + // Format time range for title + const formatDateTime = (date: Date) => + date.toLocaleString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + + const timeRangeTitle = `${formatDateTime(startDate)} bis ${formatDateTime(endDate)}`; + + // Get user comments/logs from store + const logs = useLogsStore.getState().entries; + const relevantComments = logs.filter( + (log) => + log.timestamp.getTime() >= startTime && + log.timestamp.getTime() <= endTime && + (log.level === "info" || log.message.toLowerCase().includes("comment")) + ); + + // Map data by timestamp for efficient lookup + const dataByTimestamp = new Map< + number, + Map + >(); + + 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]); + }); + }); + + // Build column headers based on available data + const columns: string[] = ["Timestamp"]; + + // Map sheet names to their proper column labels + const columnMapping: { [key: string]: string } = { + Bar: "Bar", + Rpm: "rpm", + "Front Temp": "Temp Front", + "Middle Temp": "Temp Middle", + "Back Temp": "Temp Back", + "Nozzle Temp": "Temp Nozzle", + "Total Watt": "Total W", + "Front Watt": "W Front", + "Middle Watt": "W Middle", + "Back Watt": "W Back", + "Nozzle Watt": "W Nozzle", + }; + + // Determine columns in desired order + const desiredOrder = [ + "Bar", + "Rpm", + "Temp Front", + "Temp Middle", + "Temp Back", + "Temp Nozzle", + "Total W", + "W Front", + "W Middle", + "W Back", + "W Nozzle", + ]; + + // Find which columns we actually have data for + const availableColumns: string[] = []; + desiredOrder.forEach((colName) => { + const sheetName = Object.entries(columnMapping).find( + ([, label]) => label === colName + )?.[0]; + if (sheetName && allSheetData.some((d) => d.sheetName === sheetName)) { + availableColumns.push(colName); + } + }); + + columns.push(...availableColumns); + + // Add comments column + columns.push("User Comments"); + + // Create sheet data array + const sheetData: any[][] = []; + + // Title row + const titleRow = [ + `${groupId} - ${timeRangeTitle}`, + ...Array(columns.length - 1).fill(""), + ]; + sheetData.push(titleRow); + + // Empty row + sheetData.push(Array(columns.length).fill("")); + + // Target values row (if any target lines exist) + const targetValues: any[] = ["Target Values"]; + let hasTargets = false; + + availableColumns.forEach((colName) => { + const sheetName = Object.entries(columnMapping).find( + ([, label]) => label === colName + )?.[0]; + const sheetDataEntry = allSheetData.find((d) => d.sheetName === sheetName); + + if (sheetDataEntry && sheetDataEntry.targetLines.length > 0) { + const targetLine = sheetDataEntry.targetLines.find( + (line) => line.type === "target" + ); + if (targetLine) { + targetValues.push(targetLine.value.toFixed(2)); + hasTargets = true; + } else { + targetValues.push(""); + } + } else { + targetValues.push(""); + } + }); + + targetValues.push(""); // Empty for comments column + + if (hasTargets) { + sheetData.push(targetValues); + sheetData.push(Array(columns.length).fill("")); // Empty row after targets + } + + // Header row + sheetData.push(columns); + + // Data rows with time in seconds from start + const dataStartRow = sheetData.length; + let maxSeconds = 0; + + sortedTimestamps.forEach((timestamp) => { + const row: any[] = []; + + // Calculate seconds from start + const secondsFromStart = Math.floor((timestamp - startTime) / 1000); + maxSeconds = Math.max(maxSeconds, secondsFromStart); + row.push(secondsFromStart); + + // Add data for each column + availableColumns.forEach((colName) => { + const sheetName = Object.entries(columnMapping).find( + ([, label]) => label === colName + )?.[0]; + + const tsData = dataByTimestamp.get(timestamp); + if (tsData && sheetName && tsData.has(sheetName)) { + row.push(Number(tsData.get(sheetName)!.toFixed(2))); + } else { + row.push(""); + } + }); + + // Check for comments at this timestamp (within 1 second tolerance) + const comment = relevantComments.find( + (log) => Math.abs(log.timestamp.getTime() - timestamp) < 1000 + ); + row.push(comment ? comment.message : ""); + + sheetData.push(row); + }); + + // Add metadata section after data + sheetData.push(Array(columns.length).fill("")); // Empty row + sheetData.push(Array(columns.length).fill("")); // Empty row + + // Software information + sheetData.push(["Software Information", "", "", "", "", "", ""]); + sheetData.push(["Software", "QiTech Control", "", "", "", "", ""]); + sheetData.push(["Version", "1.0.0", "", "", "", "", ""]); + sheetData.push([ + "Export Date", + new Date().toLocaleString("de-DE"), + "", + "", + "", + "", + "", + ]); + + // Comment statistics + sheetData.push(Array(columns.length).fill("")); // Empty row + sheetData.push(["Comment Statistics", "", "", "", "", "", ""]); + sheetData.push([ + "Total Comments", + relevantComments.length.toString(), + "", + "", + "", + "", + "", + ]); + + // Convert to worksheet + const worksheet = XLSX.utils.aoa_to_sheet(sheetData); + + // Merge title cells + if (!worksheet["!merges"]) worksheet["!merges"] = []; + worksheet["!merges"].push({ + s: { r: 0, c: 0 }, + e: { r: 0, c: columns.length - 1 }, + }); + + // Set column widths + const colWidths = [ + { wch: 12 }, // Timestamp (seconds) + ...availableColumns.map(() => ({ wch: 12 })), + { wch: 40 }, // Comments column + ]; + worksheet["!cols"] = colWidths; + + // Add chart creation instructions (kept as fallback) + sheetData.push(Array(columns.length).fill("")); // Empty row + sheetData.push(Array(columns.length).fill("")); // Empty row + sheetData.push([ + "Chart Instructions", + "", + "", + "", + "", + "", + "", + ]); + sheetData.push([ + "1. Select all data from row " + dataStartRow + " to the last data row", + "", + "", + "", + "", + "", + "", + ]); + sheetData.push([ + "2. Insert > Chart > Scatter Chart with Straight Lines and Markers", + "", + "", + "", + "", + "", + "", + ]); + sheetData.push([ + "3. X-axis: Time (seconds), Y-axis: All measurement columns", + "", + "", + "", + "", + "", + "", + ]); + sheetData.push([ + "4. Set X-axis range: 0 to " + maxSeconds, + "", + "", + "", + "", + "", + "", + ]); + sheetData.push([ + "5. Set Y-axis range: 0 to 1000", + "", + "", + "", + "", + "", + "", + ]); + sheetData.push([ + "6. Position legend at bottom", + "", + "", + "", + "", + "", + "", + ]); + sheetData.push([ + "7. Chart Title: " + groupId + " - " + timeRangeTitle, + "", + "", + "", + "", + "", + "", + ]); + + return worksheet; +} + // Generate better sheet names based on graph title, series title, and unit function generateSheetName( graphTitle: string, From 536f27b326d99b113b6e528baf578fa60d15ce47 Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Wed, 21 Jan 2026 17:42:37 +0100 Subject: [PATCH 03/26] fixed colours in the chart and added legends below the chart --- electron/src/components/graph/excelExport.ts | 124 ++++++++++++++++--- 1 file changed, 108 insertions(+), 16 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index cae06a4b8..a5f4c50dd 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -161,8 +161,8 @@ export async function exportGraphsToExcel( ); if (chartImage) { - // Add image to worksheet - const imageId = excelJSWorkbook.addImage({ + // Add chart image to worksheet + const chartImageId = excelJSWorkbook.addImage({ base64: chartImage, extension: "png", }); @@ -170,10 +170,25 @@ export async function exportGraphsToExcel( // Find a good position for the image (after the data and metadata) const lastRow = auswertungWorksheet.rowCount; - auswertungWorksheet.addImage(imageId, { + auswertungWorksheet.addImage(chartImageId, { tl: { col: 0, row: lastRow + 2 }, // top-left - ext: { width: 1200, height: 600 }, // size + ext: { width: 1200, height: 600 }, // chart size }); + + // Generate and add legend image below the chart + const legendImage = generateLegendImage(allSheetData); + if (legendImage) { + const legendImageId = excelJSWorkbook.addImage({ + base64: legendImage, + extension: "png", + }); + + // Position legend below the chart (approximately 32 rows for 600px chart at ~19px per row) + auswertungWorksheet.addImage(legendImageId, { + tl: { col: 0, row: lastRow + 2 + 32 }, // below chart + ext: { width: 1200, height: 50 }, // legend size + }); + } } } @@ -198,6 +213,83 @@ export async function exportGraphsToExcel( } } +// Color mapping based on data type to match reference chart +function getSeriesColor(name: string): string { + const lowerName = name.toLowerCase(); + + // Temperatures - different shades matching the reference image + if (lowerName.includes("front") && lowerName.includes("temp")) return "#e74c3c"; // Red + if (lowerName.includes("middle") && lowerName.includes("temp")) return "#c0392b"; // Dark red + if (lowerName.includes("back") && lowerName.includes("temp")) return "#16a085"; // Teal/green + if (lowerName.includes("nozzle") && lowerName.includes("temp")) return "#95a5a6"; // Gray + + // Power - various shades matching reference + if (lowerName.includes("total") && lowerName.includes("watt")) return "#2c3e50"; // Dark blue/black + if (lowerName.includes("front") && lowerName.includes("watt")) return "#e67e22"; // Orange + if (lowerName.includes("middle") && lowerName.includes("watt")) return "#d35400"; // Dark orange + if (lowerName.includes("back") && lowerName.includes("watt")) return "#f39c12"; // Yellow-orange + if (lowerName.includes("nozzle") && lowerName.includes("watt")) return "#27ae60"; // Green + + // Pressure and RPM - blue shades from reference + if (lowerName === "bar") return "#2980b9"; // Blue + if (lowerName === "rpm" || lowerName.includes("rpm")) return "#16a085"; // Teal + + return "#9b59b6"; // Purple fallback +} + +// Generate a separate legend image +function generateLegendImage(allSheetData: CombinedSheetData[]): string | null { + try { + const legendCanvas = document.createElement("canvas"); + legendCanvas.width = 1200; + legendCanvas.height = 50; + const ctx = legendCanvas.getContext("2d"); + + if (!ctx) return null; + + // Draw white background + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, legendCanvas.width, legendCanvas.height); + + // Draw legend items + ctx.font = "12px sans-serif"; + ctx.textAlign = "left"; + + let legendX = 20; + let legendY = 25; + const itemSpacing = 15; + + allSheetData.forEach((sheetData, index) => { + const color = getSeriesColor(sheetData.sheetName); + const label = sheetData.sheetName; + + // Draw color indicator (small rectangle) + ctx.fillStyle = color; + ctx.fillRect(legendX, legendY - 6, 12, 12); + + // Draw label text + ctx.fillStyle = "#333"; + ctx.fillText(label, legendX + 16, legendY + 4); + + // Move to next position + const textWidth = ctx.measureText(label).width; + legendX += 16 + textWidth + itemSpacing; + + // Wrap to next line if needed + if (legendX > 1100 && index < allSheetData.length - 1) { + legendX = 20; + legendY += 20; + } + }); + + const imageData = legendCanvas.toDataURL("image/png"); + return imageData.split(",")[1]; + } catch (error) { + console.error("Error generating legend image:", error); + return null; + } +} + // Generate chart image from data using uPlot async function generateChartImage( allSheetData: CombinedSheetData[], @@ -240,20 +332,17 @@ async function generateChartImage( chartData.push(values); - // Color mapping based on data type - const getSeriesColor = (name: string): string => { - if (name.includes("Temp")) return "#FF6B6B"; - if (name.includes("Watt") || name.includes("W ")) return "#4ECDC4"; - if (name === "Bar") return "#95E1D3"; - if (name === "Rpm") return "#F38181"; - return "#AA96DA"; - }; + const color = getSeriesColor(sheetData.sheetName); series.push({ label: sheetData.sheetName, - stroke: getSeriesColor(sheetData.sheetName), + stroke: color, width: 2, - points: { show: false }, + points: { + show: true, + size: 3, + width: 1, + }, }); }); @@ -281,7 +370,10 @@ async function generateChartImage( }, ], legend: { - show: true, + show: false, // Legend is a separate image + }, + cursor: { + show: false, }, }; @@ -297,7 +389,7 @@ async function generateChartImage( return null; } - // Convert canvas to base64 PNG + // Get the image data directly from uPlot's canvas const imageData = canvas.toDataURL("image/png"); // Clean up From 7847966ab87dfe2ad6d1c2fef15626b054b24553 Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Fri, 23 Jan 2026 22:05:09 +0100 Subject: [PATCH 04/26] colours now taken from the machine data and fixed some duplicate code --- electron/src/components/graph/excelExport.ts | 256 +++---------------- 1 file changed, 38 insertions(+), 218 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index a5f4c50dd..53465a38a 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -13,7 +13,7 @@ export type GraphExportData = { renderValue?: (value: number) => string; }; -// Type for combined sheet data used in Auswertung +// Type for combined sheet data used in Analysis type CombinedSheetData = { sheetName: string; timestamps: number[]; @@ -22,8 +22,21 @@ type CombinedSheetData = { seriesTitle: string; graphTitle: string; targetLines: GraphLine[]; + color?: string; }; +// Utility function to format date/time in German locale +function formatDateTime(date: Date): string { + return date.toLocaleString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + export async function exportGraphsToExcel( graphDataMap: Map GraphExportData | null>, groupId: string, @@ -46,7 +59,7 @@ export async function exportGraphsToExcel( const usedSheetNames = new Set(); // Track unique sheet names let processedCount = 0; - // Collect all sheet data for Auswertung sheet + // Collect all sheet data for Analysis sheet const allSheetData: CombinedSheetData[] = []; // Process each valid series @@ -93,7 +106,7 @@ export async function exportGraphsToExcel( const combinedWorksheet = createCombinedSheet(graphLineData, sheetName); XLSX.utils.book_append_sheet(workbook, combinedWorksheet, sheetName); - // Collect data for Auswertung sheet + // Collect data for Analysis sheet const [timestamps, values] = seriesToUPlotData(series.newData.long); allSheetData.push({ sheetName, @@ -103,6 +116,7 @@ export async function exportGraphsToExcel( seriesTitle, graphTitle: exportData.config.title, targetLines, + color: series.color, }); processedCount++; @@ -113,12 +127,12 @@ export async function exportGraphsToExcel( return; } - // Create Auswertung (Analysis) sheet with combined data and chart - const auswertungSheet = await createAuswertungSheet( + // Create Analysis sheet with combined data and chart + const analysisSheet = await createAnalysisSheet( allSheetData, groupId ); - XLSX.utils.book_append_sheet(workbook, auswertungSheet, "Auswertung"); + XLSX.utils.book_append_sheet(workbook, analysisSheet, "Analysis"); // Write XLSX to buffer first const xlsxBuffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }); @@ -127,10 +141,10 @@ export async function exportGraphsToExcel( const excelJSWorkbook = new ExcelJS.Workbook(); await excelJSWorkbook.xlsx.load(xlsxBuffer); - // Find the Auswertung sheet - const auswertungWorksheet = excelJSWorkbook.getWorksheet("Auswertung"); + // Find the Analysis sheet + const analysisWorksheet = excelJSWorkbook.getWorksheet("Analysis"); - if (auswertungWorksheet) { + if (analysisWorksheet) { // Generate chart image const sortedTimestamps = Array.from( new Set(allSheetData.flatMap((d) => d.timestamps)) @@ -140,16 +154,6 @@ export async function exportGraphsToExcel( const startDate = new Date(startTime); const endDate = new Date(sortedTimestamps[sortedTimestamps.length - 1]); - const formatDateTime = (date: Date) => - date.toLocaleString("de-DE", { - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - const timeRangeTitle = `${formatDateTime(startDate)} bis ${formatDateTime(endDate)}`; const chartImage = await generateChartImage( @@ -168,9 +172,9 @@ export async function exportGraphsToExcel( }); // Find a good position for the image (after the data and metadata) - const lastRow = auswertungWorksheet.rowCount; + const lastRow = analysisWorksheet.rowCount; - auswertungWorksheet.addImage(chartImageId, { + analysisWorksheet.addImage(chartImageId, { tl: { col: 0, row: lastRow + 2 }, // top-left ext: { width: 1200, height: 600 }, // chart size }); @@ -184,7 +188,7 @@ export async function exportGraphsToExcel( }); // Position legend below the chart (approximately 32 rows for 600px chart at ~19px per row) - auswertungWorksheet.addImage(legendImageId, { + analysisWorksheet.addImage(legendImageId, { tl: { col: 0, row: lastRow + 2 + 32 }, // below chart ext: { width: 1200, height: 50 }, // legend size }); @@ -213,27 +217,14 @@ export async function exportGraphsToExcel( } } -// Color mapping based on data type to match reference chart -function getSeriesColor(name: string): string { - const lowerName = name.toLowerCase(); - - // Temperatures - different shades matching the reference image - if (lowerName.includes("front") && lowerName.includes("temp")) return "#e74c3c"; // Red - if (lowerName.includes("middle") && lowerName.includes("temp")) return "#c0392b"; // Dark red - if (lowerName.includes("back") && lowerName.includes("temp")) return "#16a085"; // Teal/green - if (lowerName.includes("nozzle") && lowerName.includes("temp")) return "#95a5a6"; // Gray - - // Power - various shades matching reference - if (lowerName.includes("total") && lowerName.includes("watt")) return "#2c3e50"; // Dark blue/black - if (lowerName.includes("front") && lowerName.includes("watt")) return "#e67e22"; // Orange - if (lowerName.includes("middle") && lowerName.includes("watt")) return "#d35400"; // Dark orange - if (lowerName.includes("back") && lowerName.includes("watt")) return "#f39c12"; // Yellow-orange - if (lowerName.includes("nozzle") && lowerName.includes("watt")) return "#27ae60"; // Green - - // Pressure and RPM - blue shades from reference - if (lowerName === "bar") return "#2980b9"; // Blue - if (lowerName === "rpm" || lowerName.includes("rpm")) return "#16a085"; // Teal - +// Get series color from machine data or fallback to default +function getSeriesColor(color?: string): string { + // Use the color from the machine data if available + if (color) { + return color; + } + + // Fallback to default color return "#9b59b6"; // Purple fallback } @@ -260,7 +251,7 @@ function generateLegendImage(allSheetData: CombinedSheetData[]): string | null { const itemSpacing = 15; allSheetData.forEach((sheetData, index) => { - const color = getSeriesColor(sheetData.sheetName); + const color = getSeriesColor(sheetData.color); const label = sheetData.sheetName; // Draw color indicator (small rectangle) @@ -332,7 +323,7 @@ async function generateChartImage( chartData.push(values); - const color = getSeriesColor(sheetData.sheetName); + const color = getSeriesColor(sheetData.color); series.push({ label: sheetData.sheetName, @@ -404,8 +395,8 @@ async function generateChartImage( } } -// Create Auswertung (Analysis) sheet with combined data from all sheets -async function createAuswertungSheet( +// Create Analysis sheet with combined data from all sheets +async function createAnalysisSheet( allSheetData: CombinedSheetData[], groupId: string, ): Promise { @@ -424,16 +415,6 @@ async function createAuswertungSheet( const endDate = new Date(endTime); // Format time range for title - const formatDateTime = (date: Date) => - date.toLocaleString("de-DE", { - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - const timeRangeTitle = `${formatDateTime(startDate)} bis ${formatDateTime(endDate)}`; // Get user comments/logs from store @@ -1026,167 +1007,6 @@ function createCombinedSheet( return worksheet; } -// Generate statistics sheet for a graph line (kept for reference but not used) -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; From 7a9ce326f26d560ade0b7314f17ccaaff69bc4cb Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Fri, 23 Jan 2026 22:34:57 +0100 Subject: [PATCH 05/26] get version info from machine --- electron/src/components/graph/excelExport.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index 53465a38a..685841ccc 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -577,10 +577,28 @@ async function createAnalysisSheet( sheetData.push(Array(columns.length).fill("")); // Empty row sheetData.push(Array(columns.length).fill("")); // Empty row + // Get environment info for version details + let versionInfo = ""; + let commitInfo = ""; + try { + const envInfo = await window.environment.getInfo(); + if (envInfo.qitechOsGitAbbreviation) { + versionInfo = envInfo.qitechOsGitAbbreviation; + } + if (envInfo.qitechOsGitCommit) { + commitInfo = envInfo.qitechOsGitCommit.substring(0, 8); // First 8 chars of commit hash + } + } catch (error) { + console.warn("Failed to fetch environment info", error); + } + // Software information sheetData.push(["Software Information", "", "", "", "", "", ""]); sheetData.push(["Software", "QiTech Control", "", "", "", "", ""]); - sheetData.push(["Version", "1.0.0", "", "", "", "", ""]); + sheetData.push(["Version", versionInfo || "Unknown", "", "", "", "", ""]); + if (commitInfo) { + sheetData.push(["Git Commit", commitInfo, "", "", "", "", ""]); + } sheetData.push([ "Export Date", new Date().toLocaleString("de-DE"), From 2b6fddb341357b0b7bbf8e4a7473130161be9060 Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Fri, 23 Jan 2026 22:41:46 +0100 Subject: [PATCH 06/26] fixed generate sheet name. Uses data from extruder pages --- electron/src/components/graph/excelExport.ts | 78 +++++++------------- 1 file changed, 28 insertions(+), 50 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index 685841ccc..37522bb37 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -729,61 +729,39 @@ function generateSheetName( // Determine the type of data based on unit const unitSymbol = renderUnitSymbol(unit) || ""; - // Create descriptive sheet name based on series and graph context + // Map unit symbols to friendly names for sheet naming + const unitFriendlyNames: Record = { + "°C": "Temp", + "W": "Watt", + "A": "Ampere", + "bar": "Bar", + "rpm": "Rpm", + "1/min": "Rpm", + "mm": "mm", + "%": "Percent", + }; + + // Create descriptive sheet name let sheetName = ""; - // For temperature/power graphs (Nozzle, Front, Middle, Back, Total, Motor) - if (["Nozzle", "Front", "Middle", "Back"].includes(seriesTitle)) { - if (unitSymbol === "°C") { - sheetName = `${seriesTitle} Temp`; - } else if (unitSymbol === "W") { - sheetName = `${seriesTitle} Watt`; - } else { - sheetName = `${seriesTitle}`; - } - } else if (seriesTitle === "Total") { - if (unitSymbol === "W") { - sheetName = "Total Watt"; - } else { - sheetName = "Total"; - } - } else if (seriesTitle === "Motor") { - sheetName = "Motor"; - } - // For current/pressure/speed graphs (Series 1, 2, 3 fallback) - else if (seriesTitle === "Series 1") { - if (unitSymbol === "A") { - sheetName = "Ampere"; - } else if (unitSymbol === "bar") { - sheetName = "Bar"; - } else if (unitSymbol === "rpm" || unitSymbol === "1/min") { - sheetName = "Rpm"; - } else { - sheetName = "Series 1"; - } - } else if (seriesTitle === "Series 2") { - if (unitSymbol === "bar") { - sheetName = "Bar"; - } else if (unitSymbol === "rpm" || unitSymbol === "1/min") { - sheetName = "Rpm"; - } else { - sheetName = "Series 2"; - } - } else if (seriesTitle === "Series 3") { - if (unitSymbol === "rpm" || unitSymbol === "1/min") { - sheetName = "Rpm"; + // If the series title is generic (e.g., "Series 1", "Series 2"), use unit name + if (/^Series \d+$/i.test(seriesTitle)) { + // For generic series names, prefer the unit-based name if available + const friendlyUnitName = unitFriendlyNames[unitSymbol]; + sheetName = friendlyUnitName || seriesTitle; + } else { + // For specific series names (e.g., "Nozzle", "Front", "Diameter") + // Combine the series title with the unit if it adds clarity + const friendlyUnitName = unitFriendlyNames[unitSymbol]; + + // Only append unit name if it provides additional context + // Don't append for standalone measurements like "Diameter" or "Roundness" + if (friendlyUnitName && !seriesTitle.toLowerCase().includes(friendlyUnitName.toLowerCase())) { + sheetName = `${seriesTitle} ${friendlyUnitName}`; } else { - sheetName = "Series 3"; + sheetName = seriesTitle; } } - // For laser measurement graphs - else if (["Diameter", "X-Diameter", "Y-Diameter", "Roundness"].includes(seriesTitle)) { - sheetName = seriesTitle; - } - // Default fallback - else { - sheetName = seriesTitle; - } // Sanitize and limit length sheetName = sheetName From fa40ddca95e440a23dca07a0b4abf773273616ee Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Fri, 23 Jan 2026 22:46:41 +0100 Subject: [PATCH 07/26] fixed duplicate time --- electron/src/components/graph/excelExport.ts | 36 +++----------------- 1 file changed, 4 insertions(+), 32 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index 37522bb37..ae87d5966 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -841,14 +841,7 @@ function createCombinedSheet( statsRows.push(["Line Color", graphLine.color || "Default"]); statsRows.push([ "Generated", - new Date().toLocaleString("de-DE", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }), + formatDateTime(new Date()), ]); statsRows.push(["", ""]); statsRows.push(["Total Data Points", timestamps.length.toString()]); @@ -859,25 +852,11 @@ function createCombinedSheet( statsRows.push([ "Time Range Start", - firstDate.toLocaleString("de-DE", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }), + formatDateTime(firstDate), ]); statsRows.push([ "Time Range End", - lastDate.toLocaleString("de-DE", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }), + formatDateTime(lastDate), ]); const duration = timestamps[timestamps.length - 1] - timestamps[0]; @@ -960,14 +939,7 @@ function createCombinedSheet( // Format timestamp as dd.mm.yyyy hh:mm:ss const date = new Date(timestamp); - const formattedDate = date.toLocaleString("de-DE", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); + const formattedDate = formatDateTime(date); row[0] = formattedDate; row[1] = graphLine.renderValue From abc45d23302ddbde126974473ab9dab5015d1efa Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Fri, 23 Jan 2026 22:51:47 +0100 Subject: [PATCH 08/26] removed unnecessary column mappings used for sheet names --- electron/src/components/graph/excelExport.ts | 62 +++----------------- 1 file changed, 7 insertions(+), 55 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index ae87d5966..50ae9962f 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -444,47 +444,8 @@ async function createAnalysisSheet( // Build column headers based on available data const columns: string[] = ["Timestamp"]; - // Map sheet names to their proper column labels - const columnMapping: { [key: string]: string } = { - Bar: "Bar", - Rpm: "rpm", - "Front Temp": "Temp Front", - "Middle Temp": "Temp Middle", - "Back Temp": "Temp Back", - "Nozzle Temp": "Temp Nozzle", - "Total Watt": "Total W", - "Front Watt": "W Front", - "Middle Watt": "W Middle", - "Back Watt": "W Back", - "Nozzle Watt": "W Nozzle", - }; - - // Determine columns in desired order - const desiredOrder = [ - "Bar", - "Rpm", - "Temp Front", - "Temp Middle", - "Temp Back", - "Temp Nozzle", - "Total W", - "W Front", - "W Middle", - "W Back", - "W Nozzle", - ]; - - // Find which columns we actually have data for - const availableColumns: string[] = []; - desiredOrder.forEach((colName) => { - const sheetName = Object.entries(columnMapping).find( - ([, label]) => label === colName - )?.[0]; - if (sheetName && allSheetData.some((d) => d.sheetName === sheetName)) { - availableColumns.push(colName); - } - }); - + // Simply use sheet names from the data + const availableColumns = allSheetData.map(d => d.sheetName); columns.push(...availableColumns); // Add comments column @@ -507,13 +468,8 @@ async function createAnalysisSheet( const targetValues: any[] = ["Target Values"]; let hasTargets = false; - availableColumns.forEach((colName) => { - const sheetName = Object.entries(columnMapping).find( - ([, label]) => label === colName - )?.[0]; - const sheetDataEntry = allSheetData.find((d) => d.sheetName === sheetName); - - if (sheetDataEntry && sheetDataEntry.targetLines.length > 0) { + allSheetData.forEach((sheetDataEntry) => { + if (sheetDataEntry.targetLines.length > 0) { const targetLine = sheetDataEntry.targetLines.find( (line) => line.type === "target" ); @@ -551,14 +507,10 @@ async function createAnalysisSheet( row.push(secondsFromStart); // Add data for each column - availableColumns.forEach((colName) => { - const sheetName = Object.entries(columnMapping).find( - ([, label]) => label === colName - )?.[0]; - + allSheetData.forEach((sheetDataEntry) => { const tsData = dataByTimestamp.get(timestamp); - if (tsData && sheetName && tsData.has(sheetName)) { - row.push(Number(tsData.get(sheetName)!.toFixed(2))); + if (tsData && tsData.has(sheetDataEntry.sheetName)) { + row.push(Number(tsData.get(sheetDataEntry.sheetName)!.toFixed(2))); } else { row.push(""); } From 2342960747b6d59b19b48b4587879b76b8ae34fa Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Fri, 23 Jan 2026 23:19:30 +0100 Subject: [PATCH 09/26] fixed metadata empty strings --- electron/src/components/graph/excelExport.ts | 22 +++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index 50ae9962f..18b53dc21 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -545,33 +545,25 @@ async function createAnalysisSheet( } // Software information - sheetData.push(["Software Information", "", "", "", "", "", ""]); - sheetData.push(["Software", "QiTech Control", "", "", "", "", ""]); - sheetData.push(["Version", versionInfo || "Unknown", "", "", "", "", ""]); + sheetData.push(["Software Information", ...Array(columns.length - 1).fill("")]); + sheetData.push(["Software", "QiTech Control", ...Array(columns.length - 2).fill("")]); + sheetData.push(["Version", versionInfo || "Unknown", ...Array(columns.length - 2).fill("")]); if (commitInfo) { - sheetData.push(["Git Commit", commitInfo, "", "", "", "", ""]); + sheetData.push(["Git Commit", commitInfo, ...Array(columns.length - 2).fill("")]); } sheetData.push([ "Export Date", new Date().toLocaleString("de-DE"), - "", - "", - "", - "", - "", + ...Array(columns.length - 2).fill(""), ]); // Comment statistics sheetData.push(Array(columns.length).fill("")); // Empty row - sheetData.push(["Comment Statistics", "", "", "", "", "", ""]); + sheetData.push(["Comment Statistics", ...Array(columns.length - 1).fill("")]); sheetData.push([ "Total Comments", relevantComments.length.toString(), - "", - "", - "", - "", - "", + ...Array(columns.length - 2).fill(""), ]); // Convert to worksheet From cba70e2c414c286d23e5a565d8907ca63534a7d5 Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Fri, 23 Jan 2026 23:24:20 +0100 Subject: [PATCH 10/26] refactored generateLegendImage function --- electron/src/components/graph/excelExport.ts | 71 +++++++++++++++----- 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index 18b53dc21..fccdd6ba5 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -231,46 +231,85 @@ function getSeriesColor(color?: string): string { // Generate a separate legend image function generateLegendImage(allSheetData: CombinedSheetData[]): string | null { try { + const canvasWidth = 1200; + const itemSpacing = 15; + const rowHeight = 20; + const topPadding = 5; + const bottomPadding = 5; + const maxItemWidth = 1100; // Maximum X position before wrapping + const itemStartX = 20; + + // Create a temporary canvas to measure text + const tempCanvas = document.createElement("canvas"); + const tempCtx = tempCanvas.getContext("2d"); + if (!tempCtx) return null; + + tempCtx.font = "12px sans-serif"; + + // First pass: Calculate required height based on layout + let legendX = itemStartX; + let rowCount = 1; + + allSheetData.forEach((sheetData, index) => { + const label = sheetData.sheetName; + const textWidth = tempCtx.measureText(label).width; + const itemWidth = 16 + textWidth + itemSpacing; // color box (16px) + spacing + + // Check if item fits in current row + if (legendX + itemWidth > maxItemWidth && index < allSheetData.length - 1) { + // Wrap to next line + legendX = itemStartX; + rowCount++; + } + + legendX += itemWidth; + }); + + // Calculate required canvas height + const requiredHeight = topPadding + rowHeight + (rowCount - 1) * rowHeight + bottomPadding; + + // Create canvas with calculated height const legendCanvas = document.createElement("canvas"); - legendCanvas.width = 1200; - legendCanvas.height = 50; + legendCanvas.width = canvasWidth; + legendCanvas.height = requiredHeight; const ctx = legendCanvas.getContext("2d"); if (!ctx) return null; // Draw white background ctx.fillStyle = "#ffffff"; - ctx.fillRect(0, 0, legendCanvas.width, legendCanvas.height); + ctx.fillRect(0, 0, canvasWidth, requiredHeight); // Draw legend items ctx.font = "12px sans-serif"; ctx.textAlign = "left"; - let legendX = 20; - let legendY = 25; - const itemSpacing = 15; + let currentX = itemStartX; + let currentY = topPadding + 15; // Vertical center of first row allSheetData.forEach((sheetData, index) => { const color = getSeriesColor(sheetData.color); const label = sheetData.sheetName; + const textWidth = ctx.measureText(label).width; + const itemWidth = 16 + textWidth + itemSpacing; + + // Check if item fits in current row + if (currentX + itemWidth > maxItemWidth && index < allSheetData.length - 1) { + // Wrap to next line + currentX = itemStartX; + currentY += rowHeight; + } // Draw color indicator (small rectangle) ctx.fillStyle = color; - ctx.fillRect(legendX, legendY - 6, 12, 12); + ctx.fillRect(currentX, currentY - 6, 12, 12); // Draw label text ctx.fillStyle = "#333"; - ctx.fillText(label, legendX + 16, legendY + 4); + ctx.fillText(label, currentX + 16, currentY + 4); // Move to next position - const textWidth = ctx.measureText(label).width; - legendX += 16 + textWidth + itemSpacing; - - // Wrap to next line if needed - if (legendX > 1100 && index < allSheetData.length - 1) { - legendX = 20; - legendY += 20; - } + currentX += itemWidth; }); const imageData = legendCanvas.toDataURL("image/png"); From 3667a50aa6d1dc93e579f947c3b88cc43deb3956 Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Fri, 23 Jan 2026 23:35:34 +0100 Subject: [PATCH 11/26] refactored generateChartImage function --- electron/src/components/graph/excelExport.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index fccdd6ba5..ea08c3cfe 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -328,9 +328,12 @@ async function generateChartImage( sortedTimestamps: number[], startTime: number, ): Promise { + let container: HTMLDivElement | null = null; + let plot: uPlot | null = null; + try { // Create an off-screen div for the chart - const container = document.createElement("div"); + container = document.createElement("div"); container.style.width = "1200px"; container.style.height = "600px"; container.style.position = "absolute"; @@ -407,7 +410,7 @@ async function generateChartImage( }, }; - const plot = new uPlot(opts, chartData as uPlot.AlignedData, container); + plot = new uPlot(opts, chartData as uPlot.AlignedData, container); // Wait for render await new Promise((resolve) => setTimeout(resolve, 100)); @@ -415,22 +418,25 @@ async function generateChartImage( // Get the canvas element const canvas = container.querySelector("canvas"); if (!canvas) { - document.body.removeChild(container); return null; } // Get the image data directly from uPlot's canvas const imageData = canvas.toDataURL("image/png"); - // Clean up - plot.destroy(); - document.body.removeChild(container); - // Return base64 data (remove the data:image/png;base64, prefix) return imageData.split(",")[1]; } catch (error) { console.error("Error generating chart image:", error); return null; + } finally { + // Ensure cleanup happens regardless of success or failure + if (plot) { + plot.destroy(); + } + if (container && document.body.contains(container)) { + document.body.removeChild(container); + } } } From 6564a6a072ef577884b3bf2757d7c2904d3d8c09 Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Fri, 23 Jan 2026 23:51:15 +0100 Subject: [PATCH 12/26] refactored comment filtering logic --- electron/src/components/graph/excelExport.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index ea08c3cfe..502797302 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -463,12 +463,17 @@ async function createAnalysisSheet( const timeRangeTitle = `${formatDateTime(startDate)} bis ${formatDateTime(endDate)}`; // Get user comments/logs from store + // Filter for logs that are explicitly marked as user comments: + // - Must be within the time range + // - Must have level "info" (user annotations are logged as info) + // - Must explicitly contain the word "comment" to distinguish from other info logs const logs = useLogsStore.getState().entries; const relevantComments = logs.filter( (log) => log.timestamp.getTime() >= startTime && log.timestamp.getTime() <= endTime && - (log.level === "info" || log.message.toLowerCase().includes("comment")) + log.level === "info" && + log.message.toLowerCase().includes("comment") ); // Map data by timestamp for efficient lookup From 2b3d07f9ae5ba94b6aea9a30a7f6a9177dc54c6b Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Fri, 23 Jan 2026 23:57:11 +0100 Subject: [PATCH 13/26] refactored createCombinedSheetfunction --- electron/src/components/graph/excelExport.ts | 27 ++++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index 502797302..d4d3cdbf0 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -103,7 +103,8 @@ export async function exportGraphsToExcel( ); // Create combined sheet with data and stats - const combinedWorksheet = createCombinedSheet(graphLineData, sheetName); + // Pass seriesTitle and unit to avoid fragile reverse-engineering from sheet name + const combinedWorksheet = createCombinedSheet(graphLineData, sheetName, seriesTitle, exportData.unit); XLSX.utils.book_append_sheet(workbook, combinedWorksheet, sheetName); // Collect data for Analysis sheet @@ -794,28 +795,20 @@ function createCombinedSheet( targetLines: GraphLine[]; }, sheetName: string, + seriesTitle: string, + unit: Unit | undefined, ): XLSX.WorkSheet { const [timestamps, values] = seriesToUPlotData(graphLine.series.long); - const unitSymbol = renderUnitSymbol(graphLine.unit) || ""; + const unitSymbol = renderUnitSymbol(unit) || ""; // Create a 2D array for the combined sheet const sheetData: any[][] = []; - // Determine column header based on sheet name and unit - // Extract base name from sheet name (e.g., "Nozzle Temp, Watt" -> "Nozzle") - let baseName = sheetName; - - if (sheetName.includes(",")) { - // For sheets with multiple units (e.g., "Nozzle Temp, Watt"), extract base name - const parts = sheetName.split(",").map((s) => s.trim()); - baseName = parts[0].replace(/\s+(Temp|Watt|Stats)$/i, ""); - } else { - // Remove common suffixes - baseName = sheetName.replace(/\s+(Stats|Temp|Watt)$/i, ""); - } - - // Create column header: "unit baseName" (e.g., "°C Nozzle", "W Nozzle") - const col1Header = unitSymbol ? `${unitSymbol} ${baseName}` : baseName; + // Create column header using the original series title and unit passed as parameters. + // This approach is more robust than reverse-engineering from the sheet name, + // which may not follow predictable naming patterns depending on the generateSheetName logic. + // Format: "unit seriesTitle" (e.g., "°C Nozzle", "W Ampere") or just "seriesTitle" if no unit + const col1Header = unitSymbol ? `${unitSymbol} ${seriesTitle}` : seriesTitle; // Add header row - Column A: Timestamp, Column B: Value, Column C: (empty), Column D: (empty), Column E-F: Stats sheetData.push([ From ff755504400df79d18303aa19cea76a0709056ed Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Sat, 24 Jan 2026 00:02:13 +0100 Subject: [PATCH 14/26] added error handling for excelJS workbook creation --- electron/src/components/graph/excelExport.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index d4d3cdbf0..698e22fb0 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -139,8 +139,20 @@ export async function exportGraphsToExcel( const xlsxBuffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }); // Convert to ExcelJS workbook to add chart image - const excelJSWorkbook = new ExcelJS.Workbook(); - await excelJSWorkbook.xlsx.load(xlsxBuffer); + let excelJSWorkbook: ExcelJS.Workbook; + try { + excelJSWorkbook = new ExcelJS.Workbook(); + await excelJSWorkbook.xlsx.load(xlsxBuffer); + } catch (error) { + console.error( + "Failed to load generated XLSX buffer into ExcelJS workbook", + error + ); + alert( + "Export failed while preparing the Excel file. The generated workbook data was invalid or could not be processed." + ); + return; + } // Find the Analysis sheet const analysisWorksheet = excelJSWorkbook.getWorksheet("Analysis"); From d505df7916711a7fd02623cc04a2ec4cba082d3b Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Sat, 24 Jan 2026 00:07:01 +0100 Subject: [PATCH 15/26] refactored to use a map from sorted timestamps for O(1) lookup performance --- electron/src/components/graph/excelExport.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index 698e22fb0..182330eaf 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -364,14 +364,20 @@ async function generateChartImage( }, ]; + // Create timestamp index map for O(1) lookup performance + const timestampIndexMap = new Map(); + sortedTimestamps.forEach((ts, idx) => { + timestampIndexMap.set(ts, idx); + }); + // Add each data series allSheetData.forEach((sheetData) => { const values = new Array(sortedTimestamps.length).fill(null); - // Map values to corresponding timestamps + // Map values to corresponding timestamps using O(1) lookup sheetData.timestamps.forEach((ts, idx) => { - const timeIndex = sortedTimestamps.indexOf(ts); - if (timeIndex !== -1) { + const timeIndex = timestampIndexMap.get(ts); + if (timeIndex !== undefined) { values[timeIndex] = sheetData.values[idx]; } }); From 732219d03f20d8e87dfd8c7d7bc7b5ca0ab91356 Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Sat, 24 Jan 2026 20:20:11 +0100 Subject: [PATCH 16/26] added error handling for promise returned by exportGraphsToExcel function --- electron/src/components/graph/useGraphSync.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/electron/src/components/graph/useGraphSync.ts b/electron/src/components/graph/useGraphSync.ts index a86ce7d39..f7c3df770 100644 --- a/electron/src/components/graph/useGraphSync.ts +++ b/electron/src/components/graph/useGraphSync.ts @@ -126,7 +126,10 @@ export function useGraphSync(exportGroupId?: string) { console.warn("No graphs registered for export"); return; } - exportGraphsToExcel(graphDataRef.current, exportGroupId || "synced-graphs"); + exportGraphsToExcel(graphDataRef.current, exportGroupId || "synced-graphs") + .catch((error) => { + console.error("Failed to export graphs:", error); + }); }, [exportGroupId]); const handleTimeWindowChange = useCallback( From 290d11b59d78c6131040440768f49203ec244e72 Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Sat, 24 Jan 2026 20:26:23 +0100 Subject: [PATCH 17/26] fixed the brittle approach of waiting for the uPlot chart to render --- electron/src/components/graph/excelExport.ts | 22 ++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index 182330eaf..e98cdce3c 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -431,8 +431,26 @@ async function generateChartImage( plot = new uPlot(opts, chartData as uPlot.AlignedData, container); - // Wait for render - await new Promise((resolve) => setTimeout(resolve, 100)); + // Wait for chart to render using requestAnimationFrame for more reliable timing + await new Promise((resolve) => { + const checkCanvas = () => { + if (!container) { + resolve(); + return; + } + const canvas = container.querySelector("canvas"); + if (canvas && canvas.width > 0 && canvas.height > 0) { + // Use another frame to ensure rendering is complete + requestAnimationFrame(() => resolve()); + } else { + requestAnimationFrame(checkCanvas); + } + }; + checkCanvas(); + + // Fallback timeout to prevent infinite waiting + setTimeout(() => resolve(), 500); + }); // Get the canvas element const canvas = container.querySelector("canvas"); From 72eb26046e917e73174c27c9d4bd9e0256b153d3 Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Sat, 24 Jan 2026 20:32:21 +0100 Subject: [PATCH 18/26] refactored to use columns.length for consistent sizing --- electron/src/components/graph/excelExport.ts | 59 +++----------------- 1 file changed, 8 insertions(+), 51 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index e98cdce3c..927cad23d 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -674,77 +674,34 @@ async function createAnalysisSheet( // Add chart creation instructions (kept as fallback) sheetData.push(Array(columns.length).fill("")); // Empty row sheetData.push(Array(columns.length).fill("")); // Empty row - sheetData.push([ - "Chart Instructions", - "", - "", - "", - "", - "", - "", - ]); + sheetData.push(["Chart Instructions", ...Array(columns.length - 1).fill("")]); sheetData.push([ "1. Select all data from row " + dataStartRow + " to the last data row", - "", - "", - "", - "", - "", - "", + ...Array(columns.length - 1).fill(""), ]); sheetData.push([ "2. Insert > Chart > Scatter Chart with Straight Lines and Markers", - "", - "", - "", - "", - "", - "", + ...Array(columns.length - 1).fill(""), ]); sheetData.push([ "3. X-axis: Time (seconds), Y-axis: All measurement columns", - "", - "", - "", - "", - "", - "", + ...Array(columns.length - 1).fill(""), ]); sheetData.push([ "4. Set X-axis range: 0 to " + maxSeconds, - "", - "", - "", - "", - "", - "", + ...Array(columns.length - 1).fill(""), ]); sheetData.push([ "5. Set Y-axis range: 0 to 1000", - "", - "", - "", - "", - "", - "", + ...Array(columns.length - 1).fill(""), ]); sheetData.push([ "6. Position legend at bottom", - "", - "", - "", - "", - "", - "", + ...Array(columns.length - 1).fill(""), ]); sheetData.push([ "7. Chart Title: " + groupId + " - " + timeRangeTitle, - "", - "", - "", - "", - "", - "", + ...Array(columns.length - 1).fill(""), ]); return worksheet; From 74dc732136efce0c05cb2ccf138c2ae18056109b Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Sat, 24 Jan 2026 20:41:53 +0100 Subject: [PATCH 19/26] removed the tight coupling between the export utility and the logs store and the relevant logs are now passed as a parameter --- electron/src/components/graph/excelExport.ts | 9 +++++---- electron/src/components/graph/useGraphSync.ts | 4 +++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index 927cad23d..a36f6cfcd 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -4,7 +4,7 @@ import uPlot from "uplot"; import { TimeSeries, seriesToUPlotData } from "@/lib/timeseries"; import { renderUnitSymbol, Unit } from "@/control/units"; import { GraphConfig, SeriesData, GraphLine } from "./types"; -import { useLogsStore } from "@/stores/logsStore"; +import { LogEntry } from "@/stores/logsStore"; export type GraphExportData = { config: GraphConfig; @@ -40,6 +40,7 @@ function formatDateTime(date: Date): string { export async function exportGraphsToExcel( graphDataMap: Map GraphExportData | null>, groupId: string, + logs: LogEntry[] = [], ): Promise { try { // Filter out invalid series IDs (those without "-series-") @@ -131,7 +132,8 @@ export async function exportGraphsToExcel( // Create Analysis sheet with combined data and chart const analysisSheet = await createAnalysisSheet( allSheetData, - groupId + groupId, + logs ); XLSX.utils.book_append_sheet(workbook, analysisSheet, "Analysis"); @@ -481,6 +483,7 @@ async function generateChartImage( async function createAnalysisSheet( allSheetData: CombinedSheetData[], groupId: string, + logs: LogEntry[] = [], ): Promise { // Find all unique timestamps across all series const allTimestamps = new Set(); @@ -499,12 +502,10 @@ async function createAnalysisSheet( // Format time range for title const timeRangeTitle = `${formatDateTime(startDate)} bis ${formatDateTime(endDate)}`; - // Get user comments/logs from store // Filter for logs that are explicitly marked as user comments: // - Must be within the time range // - Must have level "info" (user annotations are logged as info) // - Must explicitly contain the word "comment" to distinguish from other info logs - const logs = useLogsStore.getState().entries; const relevantComments = logs.filter( (log) => log.timestamp.getTime() >= startTime && diff --git a/electron/src/components/graph/useGraphSync.ts b/electron/src/components/graph/useGraphSync.ts index f7c3df770..c583e64e9 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,8 @@ 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); }); From 12a379f932e4ee94b43a8527b6d8f91b98cfc608 Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Sun, 25 Jan 2026 10:17:36 +0100 Subject: [PATCH 20/26] added pid support and refactored code to follow an object oriented DRY approach --- electron/src/components/graph/excelExport.ts | 1813 ++++++++++-------- 1 file changed, 1013 insertions(+), 800 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index a36f6cfcd..bbf6cc7f4 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -1,4 +1,5 @@ import * as XLSX from "xlsx"; +// @ts-ignore - ExcelJS types not installed import ExcelJS from "exceljs"; import uPlot from "uplot"; import { TimeSeries, seriesToUPlotData } from "@/lib/timeseries"; @@ -6,14 +7,28 @@ import { renderUnitSymbol, Unit } from "@/control/units"; import { GraphConfig, SeriesData, GraphLine } from "./types"; import { LogEntry } from "@/stores/logsStore"; +/** + * Type definitions for export data structures + */ export type GraphExportData = { config: GraphConfig; - data: SeriesData; // Always a single series + data: SeriesData; unit?: Unit; renderValue?: (value: number) => string; }; -// Type for combined sheet data used in Analysis +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; +}; + type CombinedSheetData = { sheetName: string; timestamps: number[]; @@ -25,358 +40,763 @@ type CombinedSheetData = { color?: string; }; -// Utility function to format date/time in German locale -function formatDateTime(date: Date): string { - return date.toLocaleString("de-DE", { - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); -} - -export async function exportGraphsToExcel( - graphDataMap: Map GraphExportData | null>, - groupId: string, - logs: LogEntry[] = [], -): Promise { - 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); - } +/** + * Utility class for date/time formatting and manipulation + */ +class DateFormatter { + static readonly GERMAN_LOCALE = "de-DE"; + + static format(date: Date): string { + return date.toLocaleString(this.GERMAN_LOCALE, { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", }); + } - const workbook = XLSX.utils.book_new(); - const exportTimestamp = new Date() + static getExportTimestamp(): string { + return new Date() .toISOString() .replace(/[:.]/g, "-") .slice(0, 19); + } - const usedSheetNames = new Set(); // Track unique sheet names - let processedCount = 0; + static formatTimeRange(startTime: number, endTime: number): string { + const startDate = new Date(startTime); + const endDate = new Date(endTime); + return `${this.format(startDate)} bis ${this.format(endDate)}`; + } +} - // Collect all sheet data for Analysis sheet - const allSheetData: CombinedSheetData[] = []; +/** + * Manages unique sheet name generation for Excel workbooks + */ +class SheetNameManager { + private usedNames = new Set(); + + private readonly UNIT_FRIENDLY_NAMES: Record = { + "°C": "Temp", + "W": "Watt", + "A": "Ampere", + "bar": "Bar", + "rpm": "Rpm", + "1/min": "Rpm", + "mm": "mm", + "%": "Percent", + }; - // Process each valid series - filteredMap.forEach((getDataFn, seriesId) => { - const exportData = getDataFn(); - if (!exportData?.data?.newData) { - console.warn(`No data for series: ${seriesId}`); - return; + 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.UNIT_FRIENDLY_NAMES[unitSymbol]; + sheetName = friendlyUnitName || seriesTitle; + } else { + const friendlyUnitName = this.UNIT_FRIENDLY_NAMES[unitSymbol]; + if ( + friendlyUnitName && + !seriesTitle.toLowerCase().includes(friendlyUnitName.toLowerCase()) + ) { + sheetName = `${seriesTitle} ${friendlyUnitName}`; + } else { + sheetName = seriesTitle; } + } - const series = exportData.data; - const seriesTitle = series.title || `Series ${processedCount + 1}`; + return this.makeUnique(this.sanitize(sheetName)); + } - if (!series.newData) { - console.warn(`Series ${seriesTitle} has null data`); - return; - } + private sanitize(name: string): string { + return name + .replace(/[\\/?*$:[\]]/g, "_") + .substring(0, 31) + .trim() || "Sheet"; + } - 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, - }; + private makeUnique(name: string): string { + let finalName = name; + let counter = 1; - // Generate better sheet name based on graph title, series title, and unit - const sheetName = generateSheetName( - exportData.config.title, - seriesTitle, - exportData.unit, - usedSheetNames, - ); + while (this.usedNames.has(finalName)) { + const suffix = `_${counter}`; + const maxBaseLength = 31 - suffix.length; + finalName = `${name.substring(0, maxBaseLength)}${suffix}`; + counter++; + } - // Create combined sheet with data and stats - // Pass seriesTitle and unit to avoid fragile reverse-engineering from sheet name - const combinedWorksheet = createCombinedSheet(graphLineData, sheetName, seriesTitle, exportData.unit); - XLSX.utils.book_append_sheet(workbook, combinedWorksheet, sheetName); + this.usedNames.add(finalName); + return finalName; + } +} - // 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, - }); +/** + * Handles statistical calculations for time series data + */ +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"); + } - processedCount++; - }); + 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; - if (processedCount === 0) { - alert("No data available to export from any graphs in this group"); - return; - } + 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)]; - // Create Analysis sheet with combined data and chart - const analysisSheet = await createAnalysisSheet( - allSheetData, - groupId, - logs + return { min, max, avg, stdDev, range, p25, p50, p75 }; + } +} + +/** + * Filters and manages log comments for 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") ); - XLSX.utils.book_append_sheet(workbook, analysisSheet, "Analysis"); + } - // Write XLSX to buffer first - const xlsxBuffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }); + static findAtTimestamp( + comments: LogEntry[], + timestamp: number, + tolerance: number = 1000 + ): LogEntry | undefined { + return comments.find( + (log) => Math.abs(log.timestamp.getTime() - timestamp) < tolerance + ); + } +} + +/** + * Builds metadata sections for Excel sheets + */ +class MetadataBuilder { + private rows: string[][] = []; + + addSection(title: string, columnCount: number): this { + this.rows.push([title, ...Array(columnCount - 1).fill("")]); + return this; + } + + addRow(key: string, value: string, columnCount: number): this { + this.rows.push([key, value, ...Array(columnCount - 2).fill("")]); + return this; + } + + addEmptyRow(columnCount: number): this { + this.rows.push(Array(columnCount).fill("")); + return this; + } + + async addSoftwareInfo(columnCount: number): Promise { + let versionInfo = ""; + let commitInfo = ""; - // Convert to ExcelJS workbook to add chart image - let excelJSWorkbook: ExcelJS.Workbook; try { - excelJSWorkbook = new ExcelJS.Workbook(); - await excelJSWorkbook.xlsx.load(xlsxBuffer); + const envInfo = await window.environment.getInfo(); + if (envInfo.qitechOsGitAbbreviation) { + versionInfo = envInfo.qitechOsGitAbbreviation; + } + if (envInfo.qitechOsGitCommit) { + commitInfo = envInfo.qitechOsGitCommit.substring(0, 8); + } } catch (error) { - console.error( - "Failed to load generated XLSX buffer into ExcelJS workbook", - error - ); - alert( - "Export failed while preparing the Excel file. The generated workbook data was invalid or could not be processed." - ); - return; + console.warn("Failed to fetch environment info", error); } - // Find the Analysis sheet - const analysisWorksheet = excelJSWorkbook.getWorksheet("Analysis"); + this.addSection("Software Information", columnCount); + this.addRow("Software", "QiTech Control", columnCount); + this.addRow("Version", versionInfo || "Unknown", columnCount); + if (commitInfo) { + this.addRow("Git Commit", commitInfo, columnCount); + } + this.addRow( + "Export Date", + DateFormatter.format(new Date()), + columnCount + ); - if (analysisWorksheet) { - // Generate chart image - const sortedTimestamps = Array.from( - new Set(allSheetData.flatMap((d) => d.timestamps)) - ).sort((a, b) => a - b); + return this; + } - const startTime = sortedTimestamps[0]; - const startDate = new Date(startTime); - const endDate = new Date(sortedTimestamps[sortedTimestamps.length - 1]); + addPidSettings(pidData: PidData | undefined, columnCount: number): this { + if (!pidData) return this; - const timeRangeTitle = `${formatDateTime(startDate)} bis ${formatDateTime(endDate)}`; + this.addEmptyRow(columnCount); + this.addSection("PID Controller Settings", columnCount); - const chartImage = await generateChartImage( - allSheetData, - groupId, - timeRangeTitle, - sortedTimestamps, - startTime - ); + // Temperature PID settings + if (pidData.temperature) { + this.addEmptyRow(columnCount); + this.addRow("Temperature Controllers", "", columnCount); + + Object.entries(pidData.temperature).forEach(([zone, settings]) => { + this.addRow(` ${zone} - Kp`, settings.kp.toFixed(3), columnCount); + this.addRow(` ${zone} - Ki`, settings.ki.toFixed(3), columnCount); + this.addRow(` ${zone} - Kd`, settings.kd.toFixed(3), columnCount); + }); + } - if (chartImage) { - // Add chart image to worksheet - const chartImageId = excelJSWorkbook.addImage({ - base64: chartImage, - extension: "png", - }); + // Pressure PID settings + if (pidData.pressure) { + this.addEmptyRow(columnCount); + this.addRow("Pressure Controller", "", columnCount); + this.addRow(" Kp", pidData.pressure.kp.toFixed(3), columnCount); + this.addRow(" Ki", pidData.pressure.ki.toFixed(3), columnCount); + this.addRow(" Kd", pidData.pressure.kd.toFixed(3), columnCount); + } - // Find a good position for the image (after the data and metadata) - const lastRow = analysisWorksheet.rowCount; + return this; + } - analysisWorksheet.addImage(chartImageId, { - tl: { col: 0, row: lastRow + 2 }, // top-left - ext: { width: 1200, height: 600 }, // chart size - }); + getRows(): string[][] { + return this.rows; + } +} - // Generate and add legend image below the chart - const legendImage = generateLegendImage(allSheetData); - if (legendImage) { - const legendImageId = excelJSWorkbook.addImage({ - base64: legendImage, - extension: "png", - }); - - // Position legend below the chart (approximately 32 rows for 600px chart at ~19px per row) - analysisWorksheet.addImage(legendImageId, { - tl: { col: 0, row: lastRow + 2 + 32 }, // below chart - ext: { width: 1200, height: 50 }, // legend size - }); - } +/** + * Creates individual data sheets for each series + */ +class DataSheetBuilder { + 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 + ) {} + + 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); + } + + // Convert to worksheet + const worksheet = XLSX.utils.aoa_to_sheet(sheetData); + 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", DateFormatter.format(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", DateFormatter.format(firstDate)]); + statsRows.push(["Time Range End", DateFormatter.format(lastDate)]); + + const duration = timestamps[timestamps.length - 1] - timestamps[0]; + const durationHours = (duration / (1000 * 60 * 60)).toFixed(2); + 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), + ]); } } - // Write final file with ExcelJS - const filename = `${groupId.toLowerCase().replace(/\s+/g, "_")}_export_${exportTimestamp}.xlsx`; - const buffer = await excelJSWorkbook.xlsx.writeBuffer(); + return statsRows; + } - // Create blob and download - 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); - } catch (error) { - alert( - `Error exporting data to Excel: ${error instanceof Error ? error.message : "Unknown error"}. Please try again.`, - ); + 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] = DateFormatter.format(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; } -} -// Get series color from machine data or fallback to default -function getSeriesColor(color?: string): string { - // Use the color from the machine data if available - if (color) { - return color; + private formatValue(value: number): string { + return this.graphLine.renderValue + ? this.graphLine.renderValue(value) + : value?.toFixed(3) || ""; } - - // Fallback to default color - return "#9b59b6"; // Purple fallback } -// Generate a separate legend image -function generateLegendImage(allSheetData: CombinedSheetData[]): string | null { - try { - const canvasWidth = 1200; - const itemSpacing = 15; - const rowHeight = 20; - const topPadding = 5; - const bottomPadding = 5; - const maxItemWidth = 1100; // Maximum X position before wrapping - const itemStartX = 20; +/** + * Creates the combined analysis sheet with all series data + */ +class AnalysisSheetBuilder { + constructor( + private allSheetData: CombinedSheetData[], + private groupId: string, + private logs: LogEntry[], + private pidData?: PidData + ) {} + + 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 + ); - // Create a temporary canvas to measure text - const tempCanvas = document.createElement("canvas"); - const tempCtx = tempCanvas.getContext("2d"); - if (!tempCtx) return null; + // Build data by timestamp map + const dataByTimestamp = this.buildDataByTimestampMap(); - tempCtx.font = "12px sans-serif"; + // Build columns + const columns = this.buildColumns(); - // First pass: Calculate required height based on layout - let legendX = itemStartX; - let rowCount = 1; + // Create sheet data array + const sheetData: any[][] = []; - allSheetData.forEach((sheetData, index) => { - const label = sheetData.sheetName; - const textWidth = tempCtx.measureText(label).width; - const itemWidth = 16 + textWidth + itemSpacing; // color box (16px) + spacing + // Add title row + const timeRangeTitle = DateFormatter.formatTimeRange(startTime, endTime); + sheetData.push([ + `${this.groupId} - ${timeRangeTitle}`, + ...Array(columns.length - 1).fill(""), + ]); + sheetData.push(Array(columns.length).fill("")); - // Check if item fits in current row - if (legendX + itemWidth > maxItemWidth && index < allSheetData.length - 1) { - // Wrap to next line - legendX = itemStartX; - rowCount++; - } + // Add target values row if applicable + this.addTargetValuesRow(sheetData, columns.length); - legendX += itemWidth; + // 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 = Math.floor((timestamp - startTime) / 1000); + maxSeconds = Math.max(maxSeconds, secondsFromStart); + + sheetData.push(row); }); - // Calculate required canvas height - const requiredHeight = topPadding + rowHeight + (rowCount - 1) * rowHeight + bottomPadding; + // Add metadata + await this.addMetadata(sheetData, columns.length, relevantComments); - // Create canvas with calculated height - const legendCanvas = document.createElement("canvas"); - legendCanvas.width = canvasWidth; - legendCanvas.height = requiredHeight; - const ctx = legendCanvas.getContext("2d"); + // Add chart instructions + this.addChartInstructions( + sheetData, + columns.length, + dataStartRow, + maxSeconds, + timeRangeTitle + ); - if (!ctx) return null; + // Convert to worksheet + const worksheet = XLSX.utils.aoa_to_sheet(sheetData); - // Draw white background - ctx.fillStyle = "#ffffff"; - ctx.fillRect(0, 0, canvasWidth, requiredHeight); + // Configure worksheet + this.configureWorksheet(worksheet, columns.length); - // Draw legend items - ctx.font = "12px sans-serif"; - ctx.textAlign = "left"; + return worksheet; + } - let currentX = itemStartX; - let currentY = topPadding + 15; // Vertical center of first row + 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); + } - allSheetData.forEach((sheetData, index) => { - const color = getSeriesColor(sheetData.color); - const label = sheetData.sheetName; - const textWidth = ctx.measureText(label).width; - const itemWidth = 16 + textWidth + itemSpacing; + private buildDataByTimestampMap(): Map> { + const dataByTimestamp = new Map>(); - // Check if item fits in current row - if (currentX + itemWidth > maxItemWidth && index < allSheetData.length - 1) { - // Wrap to next line - currentX = itemStartX; - currentY += rowHeight; + 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(targetLine.value.toFixed(2)); + hasTargets = true; + } else { + targetValues.push(""); + } + } else { + targetValues.push(""); } + }); - // Draw color indicator (small rectangle) - ctx.fillStyle = color; - ctx.fillRect(currentX, currentY - 6, 12, 12); + targetValues.push(""); // Empty for comments column - // Draw label text - ctx.fillStyle = "#333"; - ctx.fillText(label, currentX + 16, currentY + 4); + if (hasTargets) { + sheetData.push(targetValues); + sheetData.push(Array(columnCount).fill("")); + } + } - // Move to next position - currentX += itemWidth; + private buildDataRow( + timestamp: number, + startTime: number, + dataByTimestamp: Map>, + relevantComments: LogEntry[] + ): any[] { + const row: any[] = []; + + // Calculate seconds from start + const secondsFromStart = Math.floor((timestamp - startTime) / 1000); + 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(Number(tsData.get(sheetDataEntry.sheetName)!.toFixed(2))); + } else { + row.push(""); + } }); - const imageData = legendCanvas.toDataURL("image/png"); - return imageData.split(",")[1]; - } catch (error) { - console.error("Error generating legend image:", error); - return null; + // Check for comments at this timestamp + const comment = CommentManager.findAtTimestamp(relevantComments, timestamp); + row.push(comment ? comment.message : ""); + + return row; + } + + private async addMetadata( + sheetData: any[][], + columnCount: number, + relevantComments: LogEntry[] + ): Promise { + sheetData.push(Array(columnCount).fill("")); + sheetData.push(Array(columnCount).fill("")); + + const metadataBuilder = new MetadataBuilder(); + await metadataBuilder.addSoftwareInfo(columnCount); + + // Add PID settings if available + metadataBuilder.addPidSettings(this.pidData, columnCount); + + // Add comment statistics + metadataBuilder.addEmptyRow(columnCount); + metadataBuilder.addSection("Comment Statistics", columnCount); + metadataBuilder.addRow( + "Total Comments", + relevantComments.length.toString(), + columnCount + ); + + sheetData.push(...metadataBuilder.getRows()); + } + + private addChartInstructions( + sheetData: any[][], + columnCount: number, + dataStartRow: number, + maxSeconds: number, + timeRangeTitle: string + ): void { + sheetData.push(Array(columnCount).fill("")); + sheetData.push(Array(columnCount).fill("")); + sheetData.push(["Chart Instructions", ...Array(columnCount - 1).fill("")]); + sheetData.push([ + `1. Select all data from row ${dataStartRow} to the last data row`, + ...Array(columnCount - 1).fill(""), + ]); + sheetData.push([ + "2. Insert > Chart > Scatter Chart with Straight Lines and Markers", + ...Array(columnCount - 1).fill(""), + ]); + sheetData.push([ + "3. X-axis: Time (seconds), Y-axis: All measurement columns", + ...Array(columnCount - 1).fill(""), + ]); + sheetData.push([ + `4. Set X-axis range: 0 to ${maxSeconds}`, + ...Array(columnCount - 1).fill(""), + ]); + sheetData.push([ + "5. Set Y-axis range: 0 to 1000", + ...Array(columnCount - 1).fill(""), + ]); + sheetData.push([ + "6. Position legend at bottom", + ...Array(columnCount - 1).fill(""), + ]); + sheetData.push([ + `7. Chart Title: ${this.groupId} - ${timeRangeTitle}`, + ...Array(columnCount - 1).fill(""), + ]); + } + + 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; } } -// Generate chart image from data using uPlot -async function generateChartImage( - allSheetData: CombinedSheetData[], - groupId: string, - timeRangeTitle: string, - sortedTimestamps: number[], - startTime: number, -): Promise { - let container: HTMLDivElement | null = null; - let plot: uPlot | null = null; - - try { - // Create an off-screen div for the chart - container = document.createElement("div"); +/** + * Generates chart images using uPlot + */ +class ChartImageGenerator { + static async generate( + allSheetData: CombinedSheetData[], + groupId: string, + timeRangeTitle: string, + sortedTimestamps: number[], + startTime: number + ): Promise { + let container: HTMLDivElement | null = null; + let plot: uPlot | null = null; + + try { + container = this.createOffScreenContainer(); + document.body.appendChild(container); + + const chartData = this.prepareChartData( + allSheetData, + sortedTimestamps, + startTime + ); + const series = this.buildSeriesConfig(allSheetData); + const opts = this.buildPlotOptions(groupId, timeRangeTitle, series); + + plot = new uPlot(opts, chartData as uPlot.AlignedData, container); + + await this.waitForRender(container); + + const canvas = container.querySelector("canvas"); + if (!canvas) return null; + + const imageData = canvas.toDataURL("image/png"); + return imageData.split(",")[1]; + } catch (error) { + console.error("Error generating chart image:", error); + return null; + } finally { + if (plot) plot.destroy(); + if (container && document.body.contains(container)) { + document.body.removeChild(container); + } + } + } + + private static createOffScreenContainer(): HTMLDivElement { + const container = document.createElement("div"); container.style.width = "1200px"; container.style.height = "600px"; container.style.position = "absolute"; container.style.left = "-9999px"; - document.body.appendChild(container); + return container; + } - // Prepare data for uPlot: [timestamps in seconds, ...value arrays] + private static prepareChartData( + allSheetData: CombinedSheetData[], + sortedTimestamps: number[], + startTime: number + ): number[][] { const chartData: number[][] = [ - sortedTimestamps.map((ts) => (ts - startTime) / 1000), // X-axis: seconds from start - ]; - - const series: uPlot.Series[] = [ - { - label: "Time (s)", - }, + sortedTimestamps.map((ts) => (ts - startTime) / 1000), ]; - // Create timestamp index map for O(1) lookup performance const timestampIndexMap = new Map(); sortedTimestamps.forEach((ts, idx) => { timestampIndexMap.set(ts, idx); }); - // Add each data series allSheetData.forEach((sheetData) => { const values = new Array(sortedTimestamps.length).fill(null); - // Map values to corresponding timestamps using O(1) lookup sheetData.timestamps.forEach((ts, idx) => { const timeIndex = timestampIndexMap.get(ts); if (timeIndex !== undefined) { @@ -385,9 +805,18 @@ async function generateChartImage( }); chartData.push(values); + }); + + return chartData; + } - const color = getSeriesColor(sheetData.color); + private static buildSeriesConfig( + allSheetData: CombinedSheetData[] + ): uPlot.Series[] { + const series: uPlot.Series[] = [{ label: "Time (s)" }]; + allSheetData.forEach((sheetData) => { + const color = sheetData.color || "#9b59b6"; series.push({ label: sheetData.sheetName, stroke: color, @@ -400,16 +829,21 @@ async function generateChartImage( }); }); - // Create uPlot instance - const opts: uPlot.Options = { + return series; + } + + private static buildPlotOptions( + groupId: string, + timeRangeTitle: string, + series: uPlot.Series[] + ): uPlot.Options { + return { title: `${groupId} - ${timeRangeTitle}`, width: 1200, height: 600, series, scales: { - x: { - time: false, - }, + x: { time: false }, }, axes: [ { @@ -423,590 +857,369 @@ async function generateChartImage( grid: { stroke: "#e0e0e0", width: 1 }, }, ], - legend: { - show: false, // Legend is a separate image - }, - cursor: { - show: false, - }, + legend: { show: false }, + cursor: { show: false }, }; + } - plot = new uPlot(opts, chartData as uPlot.AlignedData, container); - - // Wait for chart to render using requestAnimationFrame for more reliable timing - await new Promise((resolve) => { + private static async waitForRender(container: HTMLDivElement): Promise { + return new Promise((resolve) => { const checkCanvas = () => { - if (!container) { - resolve(); - return; - } const canvas = container.querySelector("canvas"); if (canvas && canvas.width > 0 && canvas.height > 0) { - // Use another frame to ensure rendering is complete requestAnimationFrame(() => resolve()); } else { requestAnimationFrame(checkCanvas); } }; checkCanvas(); - - // Fallback timeout to prevent infinite waiting + + // Fallback timeout setTimeout(() => resolve(), 500); }); - - // Get the canvas element - const canvas = container.querySelector("canvas"); - if (!canvas) { - return null; - } - - // Get the image data directly from uPlot's canvas - const imageData = canvas.toDataURL("image/png"); - - // Return base64 data (remove the data:image/png;base64, prefix) - return imageData.split(",")[1]; - } catch (error) { - console.error("Error generating chart image:", error); - return null; - } finally { - // Ensure cleanup happens regardless of success or failure - if (plot) { - plot.destroy(); - } - if (container && document.body.contains(container)) { - document.body.removeChild(container); - } } } -// Create Analysis sheet with combined data from all sheets -async function createAnalysisSheet( - allSheetData: CombinedSheetData[], - groupId: string, - logs: LogEntry[] = [], -): Promise { - // Find all unique timestamps across all series - const allTimestamps = new Set(); - allSheetData.forEach((data) => { - data.timestamps.forEach((ts) => allTimestamps.add(ts)); - }); - - const sortedTimestamps = Array.from(allTimestamps).sort((a, b) => a - b); - - // Calculate time range - const startTime = sortedTimestamps[0]; - const endTime = sortedTimestamps[sortedTimestamps.length - 1]; - const startDate = new Date(startTime); - const endDate = new Date(endTime); - - // Format time range for title - const timeRangeTitle = `${formatDateTime(startDate)} bis ${formatDateTime(endDate)}`; - - // Filter for logs that are explicitly marked as user comments: - // - Must be within the time range - // - Must have level "info" (user annotations are logged as info) - // - Must explicitly contain the word "comment" to distinguish from other info logs - const relevantComments = logs.filter( - (log) => - log.timestamp.getTime() >= startTime && - log.timestamp.getTime() <= endTime && - log.level === "info" && - log.message.toLowerCase().includes("comment") - ); - - // Map data by timestamp for efficient lookup - const dataByTimestamp = new Map< - number, - Map - >(); - - 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]); - }); - }); - - // Build column headers based on available data - const columns: string[] = ["Timestamp"]; - - // Simply use sheet names from the data - const availableColumns = allSheetData.map(d => d.sheetName); - columns.push(...availableColumns); - - // Add comments column - columns.push("User Comments"); - - // Create sheet data array - const sheetData: any[][] = []; - - // Title row - const titleRow = [ - `${groupId} - ${timeRangeTitle}`, - ...Array(columns.length - 1).fill(""), - ]; - sheetData.push(titleRow); +/** + * Generates legend images for charts + */ +class LegendImageGenerator { + static generate(allSheetData: CombinedSheetData[]): string | null { + try { + const dimensions = this.calculateDimensions(allSheetData); + const canvas = this.createCanvas(dimensions.width, dimensions.height); + const ctx = canvas.getContext("2d"); - // Empty row - sheetData.push(Array(columns.length).fill("")); + if (!ctx) return null; - // Target values row (if any target lines exist) - const targetValues: any[] = ["Target Values"]; - let hasTargets = false; + this.drawBackground(ctx, dimensions.width, dimensions.height); + this.drawLegendItems(ctx, allSheetData, dimensions); - allSheetData.forEach((sheetDataEntry) => { - if (sheetDataEntry.targetLines.length > 0) { - const targetLine = sheetDataEntry.targetLines.find( - (line) => line.type === "target" - ); - if (targetLine) { - targetValues.push(targetLine.value.toFixed(2)); - hasTargets = true; - } else { - targetValues.push(""); - } - } else { - targetValues.push(""); + const imageData = canvas.toDataURL("image/png"); + return imageData.split(",")[1]; + } catch (error) { + console.error("Error generating legend image:", error); + return null; } - }); - - targetValues.push(""); // Empty for comments column - - if (hasTargets) { - sheetData.push(targetValues); - sheetData.push(Array(columns.length).fill("")); // Empty row after targets } - // Header row - sheetData.push(columns); + private static calculateDimensions(allSheetData: CombinedSheetData[]): { + width: number; + height: number; + rows: number; + } { + const canvasWidth = 1200; + const itemSpacing = 15; + const rowHeight = 20; + const topPadding = 5; + const bottomPadding = 5; + const maxItemWidth = 1100; + const itemStartX = 20; - // Data rows with time in seconds from start - const dataStartRow = sheetData.length; - let maxSeconds = 0; + const tempCanvas = document.createElement("canvas"); + const tempCtx = tempCanvas.getContext("2d"); + if (!tempCtx) return { width: canvasWidth, height: 50, rows: 1 }; - sortedTimestamps.forEach((timestamp) => { - const row: any[] = []; + tempCtx.font = "12px sans-serif"; - // Calculate seconds from start - const secondsFromStart = Math.floor((timestamp - startTime) / 1000); - maxSeconds = Math.max(maxSeconds, secondsFromStart); - row.push(secondsFromStart); + let legendX = itemStartX; + let rowCount = 1; - // Add data for each column - allSheetData.forEach((sheetDataEntry) => { - const tsData = dataByTimestamp.get(timestamp); - if (tsData && tsData.has(sheetDataEntry.sheetName)) { - row.push(Number(tsData.get(sheetDataEntry.sheetName)!.toFixed(2))); - } else { - row.push(""); + allSheetData.forEach((sheetData, index) => { + const textWidth = tempCtx.measureText(sheetData.sheetName).width; + const itemWidth = 16 + textWidth + itemSpacing; + + if ( + legendX + itemWidth > maxItemWidth && + index < allSheetData.length - 1 + ) { + legendX = itemStartX; + rowCount++; } + + legendX += itemWidth; }); - // Check for comments at this timestamp (within 1 second tolerance) - const comment = relevantComments.find( - (log) => Math.abs(log.timestamp.getTime() - timestamp) < 1000 - ); - row.push(comment ? comment.message : ""); + const height = + topPadding + rowHeight + (rowCount - 1) * rowHeight + bottomPadding; - sheetData.push(row); - }); + return { width: canvasWidth, height, rows: rowCount }; + } - // Add metadata section after data - sheetData.push(Array(columns.length).fill("")); // Empty row - sheetData.push(Array(columns.length).fill("")); // Empty row + private static createCanvas(width: number, height: number): HTMLCanvasElement { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + return canvas; + } - // Get environment info for version details - let versionInfo = ""; - let commitInfo = ""; - try { - const envInfo = await window.environment.getInfo(); - if (envInfo.qitechOsGitAbbreviation) { - versionInfo = envInfo.qitechOsGitAbbreviation; - } - if (envInfo.qitechOsGitCommit) { - commitInfo = envInfo.qitechOsGitCommit.substring(0, 8); // First 8 chars of commit hash - } - } catch (error) { - console.warn("Failed to fetch environment info", error); - } - - // Software information - sheetData.push(["Software Information", ...Array(columns.length - 1).fill("")]); - sheetData.push(["Software", "QiTech Control", ...Array(columns.length - 2).fill("")]); - sheetData.push(["Version", versionInfo || "Unknown", ...Array(columns.length - 2).fill("")]); - if (commitInfo) { - sheetData.push(["Git Commit", commitInfo, ...Array(columns.length - 2).fill("")]); - } - sheetData.push([ - "Export Date", - new Date().toLocaleString("de-DE"), - ...Array(columns.length - 2).fill(""), - ]); - - // Comment statistics - sheetData.push(Array(columns.length).fill("")); // Empty row - sheetData.push(["Comment Statistics", ...Array(columns.length - 1).fill("")]); - sheetData.push([ - "Total Comments", - relevantComments.length.toString(), - ...Array(columns.length - 2).fill(""), - ]); - - // Convert to worksheet - const worksheet = XLSX.utils.aoa_to_sheet(sheetData); - - // Merge title cells - if (!worksheet["!merges"]) worksheet["!merges"] = []; - worksheet["!merges"].push({ - s: { r: 0, c: 0 }, - e: { r: 0, c: columns.length - 1 }, - }); - - // Set column widths - const colWidths = [ - { wch: 12 }, // Timestamp (seconds) - ...availableColumns.map(() => ({ wch: 12 })), - { wch: 40 }, // Comments column - ]; - worksheet["!cols"] = colWidths; - - // Add chart creation instructions (kept as fallback) - sheetData.push(Array(columns.length).fill("")); // Empty row - sheetData.push(Array(columns.length).fill("")); // Empty row - sheetData.push(["Chart Instructions", ...Array(columns.length - 1).fill("")]); - sheetData.push([ - "1. Select all data from row " + dataStartRow + " to the last data row", - ...Array(columns.length - 1).fill(""), - ]); - sheetData.push([ - "2. Insert > Chart > Scatter Chart with Straight Lines and Markers", - ...Array(columns.length - 1).fill(""), - ]); - sheetData.push([ - "3. X-axis: Time (seconds), Y-axis: All measurement columns", - ...Array(columns.length - 1).fill(""), - ]); - sheetData.push([ - "4. Set X-axis range: 0 to " + maxSeconds, - ...Array(columns.length - 1).fill(""), - ]); - sheetData.push([ - "5. Set Y-axis range: 0 to 1000", - ...Array(columns.length - 1).fill(""), - ]); - sheetData.push([ - "6. Position legend at bottom", - ...Array(columns.length - 1).fill(""), - ]); - sheetData.push([ - "7. Chart Title: " + groupId + " - " + timeRangeTitle, - ...Array(columns.length - 1).fill(""), - ]); - - return worksheet; -} + private static drawBackground( + ctx: CanvasRenderingContext2D, + width: number, + height: number + ): void { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, width, height); + } -// Generate better sheet names based on graph title, series title, and unit -function generateSheetName( - graphTitle: string, - seriesTitle: string, - unit: Unit | undefined, - usedSheetNames: Set, -): string { - // Determine the type of data based on unit - const unitSymbol = renderUnitSymbol(unit) || ""; - - // Map unit symbols to friendly names for sheet naming - const unitFriendlyNames: Record = { - "°C": "Temp", - "W": "Watt", - "A": "Ampere", - "bar": "Bar", - "rpm": "Rpm", - "1/min": "Rpm", - "mm": "mm", - "%": "Percent", - }; + private static drawLegendItems( + ctx: CanvasRenderingContext2D, + allSheetData: CombinedSheetData[], + dimensions: { width: number; height: number; rows: number } + ): void { + const itemSpacing = 15; + const rowHeight = 20; + const topPadding = 5; + const maxItemWidth = 1100; + const itemStartX = 20; - // Create descriptive sheet name - let sheetName = ""; - - // If the series title is generic (e.g., "Series 1", "Series 2"), use unit name - if (/^Series \d+$/i.test(seriesTitle)) { - // For generic series names, prefer the unit-based name if available - const friendlyUnitName = unitFriendlyNames[unitSymbol]; - sheetName = friendlyUnitName || seriesTitle; - } else { - // For specific series names (e.g., "Nozzle", "Front", "Diameter") - // Combine the series title with the unit if it adds clarity - const friendlyUnitName = unitFriendlyNames[unitSymbol]; - - // Only append unit name if it provides additional context - // Don't append for standalone measurements like "Diameter" or "Roundness" - if (friendlyUnitName && !seriesTitle.toLowerCase().includes(friendlyUnitName.toLowerCase())) { - sheetName = `${seriesTitle} ${friendlyUnitName}`; - } else { - sheetName = seriesTitle; - } - } + ctx.font = "12px sans-serif"; + ctx.textAlign = "left"; - // Sanitize and limit length - sheetName = sheetName - .replace(/[\\/?*$:[\]]/g, "_") - .substring(0, 31); + let currentX = itemStartX; + let currentY = topPadding + 15; - if (!sheetName || sheetName.trim().length === 0) { - sheetName = "Sheet"; - } + allSheetData.forEach((sheetData, index) => { + const color = sheetData.color || "#9b59b6"; + const label = sheetData.sheetName; + const textWidth = ctx.measureText(label).width; + const itemWidth = 16 + textWidth + itemSpacing; - // Make unique if needed - let finalName = sheetName; - let counter = 1; + if ( + currentX + itemWidth > maxItemWidth && + index < allSheetData.length - 1 + ) { + currentX = itemStartX; + currentY += rowHeight; + } - while (usedSheetNames.has(finalName)) { - const suffix = `_${counter}`; - const maxBaseLength = 31 - suffix.length; - finalName = `${sheetName.substring(0, maxBaseLength)}${suffix}`; - counter++; - } + // Draw color indicator + ctx.fillStyle = color; + ctx.fillRect(currentX, currentY - 6, 12, 12); - usedSheetNames.add(finalName); - return finalName; + // Draw label text + ctx.fillStyle = "#333"; + ctx.fillText(label, currentX + 16, currentY + 4); + + currentX += itemWidth; + }); + } } -// Create combined sheet with data (columns A-C) and stats (columns E-F) -function createCombinedSheet( - graphLine: { - graphTitle: string; - lineTitle: string; - series: TimeSeries; - color?: string; - unit?: Unit; - renderValue?: (value: number) => string; - config: GraphConfig; - targetLines: GraphLine[]; - }, - sheetName: string, - seriesTitle: string, - unit: Unit | undefined, -): XLSX.WorkSheet { - const [timestamps, values] = seriesToUPlotData(graphLine.series.long); - const unitSymbol = renderUnitSymbol(unit) || ""; - - // Create a 2D array for the combined sheet - const sheetData: any[][] = []; - - // Create column header using the original series title and unit passed as parameters. - // This approach is more robust than reverse-engineering from the sheet name, - // which may not follow predictable naming patterns depending on the generateSheetName logic. - // Format: "unit seriesTitle" (e.g., "°C Nozzle", "W Ampere") or just "seriesTitle" if no unit - const col1Header = unitSymbol ? `${unitSymbol} ${seriesTitle}` : seriesTitle; - - // Add header row - Column A: Timestamp, Column B: Value, Column C: (empty), Column D: (empty), Column E-F: Stats - sheetData.push([ - "Timestamp", - col1Header, - "", - "", - "Statistic", - "Value", - ]); - - // Prepare stats data - const statsRows: string[][] = []; - - statsRows.push(["Graph", graphLine.graphTitle]); - statsRows.push(["Line Name", graphLine.lineTitle]); - statsRows.push(["Line Color", graphLine.color || "Default"]); - statsRows.push([ - "Generated", - formatDateTime(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", - formatDateTime(firstDate), - ]); - statsRows.push([ - "Time Range End", - formatDateTime(lastDate), - ]); +/** + * Main orchestrator for Excel export functionality + */ +export class ExcelExporter { + private sheetNameManager = new SheetNameManager(); + + async export( + graphDataMap: Map GraphExportData | null>, + groupId: string, + logs: LogEntry[] = [], + pidData?: PidData + ): Promise { + try { + 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 + ); + + const worksheet = dataSheetBuilder.build(); + 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, + }); + }); - const duration = timestamps[timestamps.length - 1] - timestamps[0]; - const durationHours = (duration / (1000 * 60 * 60)).toFixed(2); - statsRows.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, + 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 ); + const analysisSheet = await analysisSheetBuilder.build(); + XLSX.utils.book_append_sheet(workbook, analysisSheet, "Analysis"); - statsRows.push(["", ""]); - statsRows.push([ - `Minimum Value (${unitSymbol})`, - graphLine.renderValue - ? graphLine.renderValue(minValue) - : minValue.toFixed(3), - ]); - statsRows.push([ - `Maximum Value (${unitSymbol})`, - graphLine.renderValue - ? graphLine.renderValue(maxValue) - : maxValue.toFixed(3), - ]); - statsRows.push([ - `Average Value (${unitSymbol})`, - graphLine.renderValue - ? graphLine.renderValue(avgValue) - : avgValue.toFixed(3), - ]); - statsRows.push([ - `Standard Deviation (${unitSymbol})`, - graphLine.renderValue - ? graphLine.renderValue(stdDev) - : stdDev.toFixed(3), - ]); - statsRows.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)]; - - statsRows.push(["", ""]); - statsRows.push([ - `25th Percentile (${unitSymbol})`, - graphLine.renderValue ? graphLine.renderValue(p25) : p25.toFixed(3), - ]); - statsRows.push([ - `50th Percentile (${unitSymbol})`, - graphLine.renderValue ? graphLine.renderValue(p50) : p50.toFixed(3), - ]); - statsRows.push([ - `75th Percentile (${unitSymbol})`, - graphLine.renderValue ? graphLine.renderValue(p75) : p75.toFixed(3), - ]); + // Convert to ExcelJS for image support + await this.addChartImages(workbook, allSheetData, groupId); + } catch (error) { + alert( + `Error exporting data to Excel: ${ + error instanceof Error ? error.message : "Unknown error" + }. Please try again.` + ); } } - // Add data rows with stats in columns E-F - const maxRows = Math.max(timestamps.length, statsRows.length); - - for (let i = 0; i < maxRows; i++) { - const row: any[] = ["", "", "", ""]; - - // Add timestamp and value data (columns A-B) - if (i < timestamps.length) { - const timestamp = timestamps[i]; - const value = values[i]; - - // Format timestamp as dd.mm.yyyy hh:mm:ss - const date = new Date(timestamp); - const formattedDate = formatDateTime(date); + 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; + } - row[0] = formattedDate; - row[1] = graphLine.renderValue - ? graphLine.renderValue(value) - : value?.toFixed(3) || ""; - } + private async addChartImages( + workbook: XLSX.WorkBook, + allSheetData: CombinedSheetData[], + groupId: string + ): Promise { + const xlsxBuffer = XLSX.write(workbook, { + type: "buffer", + bookType: "xlsx", + }); - // Add stats (columns E-F) - if (i < statsRows.length) { - row[4] = statsRows[i][0]; - row[5] = statsRows[i][1]; - } else { - row[4] = ""; - row[5] = ""; + let excelJSWorkbook: ExcelJS.Workbook; + try { + excelJSWorkbook = new ExcelJS.Workbook(); + await excelJSWorkbook.xlsx.load(xlsxBuffer); + } catch (error) { + console.error("Failed to load XLSX buffer into ExcelJS", error); + alert( + "Export failed while preparing the Excel file. The generated workbook data was invalid or could not be processed." + ); + return; } - sheetData.push(row); - } + const analysisWorksheet = excelJSWorkbook.getWorksheet("Analysis"); + if (!analysisWorksheet) return; - // Convert to worksheet - const worksheet = XLSX.utils.aoa_to_sheet(sheetData); + const sortedTimestamps = Array.from( + new Set(allSheetData.flatMap((d) => d.timestamps)) + ).sort((a, b) => a - b); - // Set column widths for better readability - worksheet["!cols"] = [ - { wch: 20 }, // Timestamp - { wch: 15 }, // Value - { wch: 5 }, // Empty column C - { wch: 5 }, // Empty column D - { wch: 30 }, // Statistic name - { wch: 20 }, // Statistic value - ]; + const startTime = sortedTimestamps[0]; + const endTime = sortedTimestamps[sortedTimestamps.length - 1]; + const timeRangeTitle = DateFormatter.formatTimeRange(startTime, endTime); - return worksheet; -} - -// 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); + const chartImage = await ChartImageGenerator.generate( + allSheetData, + groupId, + timeRangeTitle, + sortedTimestamps, + startTime + ); - if (timestamps.length === 0) return []; + if (chartImage) { + const chartImageId = excelJSWorkbook.addImage({ + base64: chartImage, + extension: "png", + }); - const unitSymbol = renderUnitSymbol(graphLine.unit) || ""; + const lastRow = analysisWorksheet.rowCount; + analysisWorksheet.addImage(chartImageId, { + tl: { col: 0, row: lastRow + 2 }, + ext: { width: 1200, height: 600 }, + }); - return timestamps.map((timestamp, index) => { - const value = values[index]; - return { - Timestamp: new Date(timestamp), - [`Value (${unitSymbol})`]: graphLine.renderValue - ? graphLine.renderValue(value) - : value?.toFixed(3) || "", - }; - }); -} + const legendImage = LegendImageGenerator.generate(allSheetData); + if (legendImage) { + const legendImageId = excelJSWorkbook.addImage({ + base64: legendImage, + extension: "png", + }); -// 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 + analysisWorksheet.addImage(legendImageId, { + tl: { col: 0, row: lastRow + 2 + 32 }, + ext: { width: 1200, height: 50 }, + }); + } + } - if (!baseSheetName || baseSheetName.trim().length === 0) { - baseSheetName = "Sheet"; + // Write final file with ExcelJS + const buffer = await excelJSWorkbook.xlsx.writeBuffer(); + this.triggerDownload(buffer, groupId, DateFormatter.getExportTimestamp()); } - let sheetName = baseSheetName; - let counter = 1; + private triggerDownload( + buffer: ArrayBuffer, + groupId: string, + exportTimestamp: string + ): void { + const filename = `${groupId + .toLowerCase() + .replace(/\s+/g, "_")}_export_${exportTimestamp}.xlsx`; - while (usedSheetNames.has(sheetName)) { - const suffix = `_${counter}`; - const maxBaseLength = 31 - suffix.length; - sheetName = `${baseSheetName.substring(0, maxBaseLength)}${suffix}`; - counter++; + 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); } +} - usedSheetNames.add(sheetName); - return sheetName; +/** + * 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); } From ceff0a6df8afa539380b063f78186dcdf0d27839 Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Sun, 25 Jan 2026 10:23:02 +0100 Subject: [PATCH 21/26] modified to have version info at the top of the chart image instead of at the analysis sheet --- electron/src/components/graph/excelExport.ts | 98 +++++++++++++++----- 1 file changed, 74 insertions(+), 24 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index bbf6cc7f4..7f6077084 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -224,28 +224,9 @@ class MetadataBuilder { return this; } - async addSoftwareInfo(columnCount: number): Promise { - let versionInfo = ""; - let commitInfo = ""; - - try { - const envInfo = await window.environment.getInfo(); - if (envInfo.qitechOsGitAbbreviation) { - versionInfo = envInfo.qitechOsGitAbbreviation; - } - if (envInfo.qitechOsGitCommit) { - commitInfo = envInfo.qitechOsGitCommit.substring(0, 8); - } - } catch (error) { - console.warn("Failed to fetch environment info", error); - } - - this.addSection("Software Information", columnCount); + addExportInfo(columnCount: number): this { + this.addSection("Export Information", columnCount); this.addRow("Software", "QiTech Control", columnCount); - this.addRow("Version", versionInfo || "Unknown", columnCount); - if (commitInfo) { - this.addRow("Git Commit", commitInfo, columnCount); - } this.addRow( "Export Date", DateFormatter.format(new Date()), @@ -650,7 +631,7 @@ class AnalysisSheetBuilder { sheetData.push(Array(columnCount).fill("")); const metadataBuilder = new MetadataBuilder(); - await metadataBuilder.addSoftwareInfo(columnCount); + metadataBuilder.addExportInfo(columnCount); // Add PID settings if available metadataBuilder.addPidSettings(this.pidData, columnCount); @@ -725,6 +706,61 @@ class AnalysisSheetBuilder { } } +/** + * Handles version information retrieval and rendering + * Follows Single Responsibility Principle - only manages version info + */ +class VersionInfoRenderer { + private versionInfo: string = ""; + private commitInfo: string = ""; + + async fetchVersionInfo(): Promise { + try { + const envInfo = await window.environment.getInfo(); + if (envInfo.qitechOsGitAbbreviation) { + this.versionInfo = envInfo.qitechOsGitAbbreviation; + } + if (envInfo.qitechOsGitCommit) { + this.commitInfo = envInfo.qitechOsGitCommit.substring(0, 8); + } + } catch (error) { + console.warn("Failed to fetch environment info", error); + } + } + + renderOnCanvas(ctx: CanvasRenderingContext2D, canvasWidth: number): void { + if (!this.versionInfo && !this.commitInfo) return; + + const versionText = this.formatVersionText(); + + ctx.save(); + ctx.font = "12px sans-serif"; + ctx.fillStyle = "#666"; + ctx.textAlign = "center"; + ctx.fillText(versionText, canvasWidth / 2, 20); + ctx.restore(); + } + + private formatVersionText(): string { + const parts: string[] = []; + if (this.versionInfo) { + parts.push(`Version: ${this.versionInfo}`); + } + if (this.commitInfo) { + parts.push(`Commit: ${this.commitInfo}`); + } + return parts.join(" | "); + } + + getVersionInfo(): string { + return this.versionInfo; + } + + getCommitInfo(): string { + return this.commitInfo; + } +} + /** * Generates chart images using uPlot */ @@ -734,7 +770,8 @@ class ChartImageGenerator { groupId: string, timeRangeTitle: string, sortedTimestamps: number[], - startTime: number + startTime: number, + versionRenderer?: VersionInfoRenderer ): Promise { let container: HTMLDivElement | null = null; let plot: uPlot | null = null; @@ -758,6 +795,14 @@ class ChartImageGenerator { const canvas = container.querySelector("canvas"); if (!canvas) return null; + // Render version info centered at top of chart + if (versionRenderer) { + const ctx = canvas.getContext("2d"); + if (ctx) { + versionRenderer.renderOnCanvas(ctx, canvas.width); + } + } + const imageData = canvas.toDataURL("image/png"); return imageData.split(",")[1]; } catch (error) { @@ -1151,12 +1196,17 @@ export class ExcelExporter { const endTime = sortedTimestamps[sortedTimestamps.length - 1]; const timeRangeTitle = DateFormatter.formatTimeRange(startTime, endTime); + // Fetch version info for chart rendering + const versionRenderer = new VersionInfoRenderer(); + await versionRenderer.fetchVersionInfo(); + const chartImage = await ChartImageGenerator.generate( allSheetData, groupId, timeRangeTitle, sortedTimestamps, - startTime + startTime, + versionRenderer ); if (chartImage) { From 4408febe27fd3ec0cd96e2cd9b76a885407d00fb Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Sun, 25 Jan 2026 22:55:55 +0100 Subject: [PATCH 22/26] refactored the code to divide the large functions in the excelExport into their own files for readability and maintainability. added support for pid export as stated in the issue and config export to follow dependency inversion principle and to centralize the required hardcoded values to provide type safe access --- electron/package-lock.json | 803 +++++++++++- electron/package.json | 1 + .../graph/excelAnalysisSheetBuilder.ts | 303 +++++ .../graph/excelChartImageGenerator.ts | 178 +++ .../components/graph/excelCommentManager.ts | 30 + .../components/graph/excelDataSheetBuilder.ts | 173 +++ .../components/graph/excelDateFormatter.ts | 21 + electron/src/components/graph/excelExport.ts | 1137 +---------------- .../src/components/graph/excelExportConfig.ts | 171 +++ .../src/components/graph/excelExportTypes.ts | 35 + .../src/components/graph/excelFormatters.ts | 120 ++ .../graph/excelLegendImageGenerator.ts | 143 +++ .../src/components/graph/excelMetadata.ts | 188 +++ .../components/graph/excelMetadataBuilder.ts | 52 + .../components/graph/excelSheetNameManager.ts | 63 + .../graph/excelStatisticsCalculator.ts | 35 + electron/src/components/graph/excelUtils.ts | 153 +++ .../graph/excelVersionInfoRenderer.ts | 55 + electron/src/components/graph/useGraphSync.ts | 11 +- 19 files changed, 2580 insertions(+), 1092 deletions(-) create mode 100644 electron/src/components/graph/excelAnalysisSheetBuilder.ts create mode 100644 electron/src/components/graph/excelChartImageGenerator.ts create mode 100644 electron/src/components/graph/excelCommentManager.ts create mode 100644 electron/src/components/graph/excelDataSheetBuilder.ts create mode 100644 electron/src/components/graph/excelDateFormatter.ts create mode 100644 electron/src/components/graph/excelExportConfig.ts create mode 100644 electron/src/components/graph/excelExportTypes.ts create mode 100644 electron/src/components/graph/excelFormatters.ts create mode 100644 electron/src/components/graph/excelLegendImageGenerator.ts create mode 100644 electron/src/components/graph/excelMetadata.ts create mode 100644 electron/src/components/graph/excelMetadataBuilder.ts create mode 100644 electron/src/components/graph/excelSheetNameManager.ts create mode 100644 electron/src/components/graph/excelStatisticsCalculator.ts create mode 100644 electron/src/components/graph/excelUtils.ts create mode 100644 electron/src/components/graph/excelVersionInfoRenderer.ts diff --git a/electron/package-lock.json b/electron/package-lock.json index c1b411380..ce6669c98 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -33,6 +33,7 @@ "clsx": "^2.1.1", "electron-devtools-installer": "^4.0.0", "electron-squirrel-startup": "^1.0.1", + "exceljs": "^4.4.0", "i18next": "^25.0.2", "immer": "^10.1.1", "lucide-react": "^0.511.0", @@ -1338,6 +1339,47 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -4312,6 +4354,81 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -4496,6 +4613,12 @@ "node": ">=12" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -4555,7 +4678,26 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT" }, "node_modules/baseline-browser-mapping": { @@ -4567,6 +4709,45 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -4580,7 +4761,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -4620,16 +4800,56 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" } }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4776,6 +4996,18 @@ "node": ">=18" } }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4916,11 +5148,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/convert-source-map": { @@ -4941,6 +5187,31 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -5058,6 +5329,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5289,6 +5566,51 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/electron": { "version": "37.10.3", "resolved": "https://registry.npmjs.org/electron/-/electron-37.10.3.tgz", @@ -5445,7 +5767,6 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -6128,6 +6449,38 @@ "node": ">=0.10.0" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -6165,6 +6518,19 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6270,11 +6636,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -6291,6 +6662,22 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6438,7 +6825,6 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -6569,7 +6955,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-bigints": { @@ -6939,6 +7324,26 @@ } } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -7007,7 +7412,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -7736,6 +8140,54 @@ "json-buffer": "3.0.1" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -8009,6 +8461,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -8025,6 +8483,73 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -8032,6 +8557,18 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -9020,7 +9557,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9038,6 +9574,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", @@ -9085,6 +9633,15 @@ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", @@ -9222,7 +9779,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -9376,7 +9932,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9941,6 +10496,50 @@ "node": ">=6" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -10110,6 +10709,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -10197,6 +10809,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -10664,6 +11296,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -10941,6 +11582,22 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -11062,6 +11719,15 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/tough-cookie": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", @@ -11088,6 +11754,15 @@ "node": ">=18" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -11434,16 +12109,58 @@ "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==", + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", "license": "MIT", "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" } }, "node_modules/update-browserslist-db": { @@ -11550,6 +12267,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -12075,7 +12801,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -12126,7 +12851,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, "license": "MIT" }, "node_modules/xmlhttprequest-ssl": { @@ -12183,6 +12907,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/zod": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", diff --git a/electron/package.json b/electron/package.json index 461565934..1a49421d7 100644 --- a/electron/package.json +++ b/electron/package.json @@ -81,6 +81,7 @@ "clsx": "^2.1.1", "electron-devtools-installer": "^4.0.0", "electron-squirrel-startup": "^1.0.1", + "exceljs": "^4.4.0", "i18next": "^25.0.2", "immer": "^10.1.1", "lucide-react": "^0.511.0", diff --git a/electron/src/components/graph/excelAnalysisSheetBuilder.ts b/electron/src/components/graph/excelAnalysisSheetBuilder.ts new file mode 100644 index 000000000..6062ff376 --- /dev/null +++ b/electron/src/components/graph/excelAnalysisSheetBuilder.ts @@ -0,0 +1,303 @@ +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 } 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, + ); + + // Convert to worksheet + const worksheet = XLSX.utils.aoa_to_sheet(sheetData); + + // 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( + Number( + this.formatter.formatNumber(tsData.get(sheetDataEntry.sheetName)!), + ), + ); + } else { + row.push(""); + } + }); + + // Check for comments at this timestamp + const comment = CommentManager.findAtTimestamp(relevantComments, timestamp); + row.push(comment ? comment.message : ""); + + return row; + } + + 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/excelChartImageGenerator.ts b/electron/src/components/graph/excelChartImageGenerator.ts new file mode 100644 index 000000000..570549b7d --- /dev/null +++ b/electron/src/components/graph/excelChartImageGenerator.ts @@ -0,0 +1,178 @@ +import uPlot from "uplot"; +import { ExportConfig, IExportConfig } from "./excelExportConfig"; +import { TimestampConverter } from "./excelFormatters"; +import { CanvasUtils } from "./excelUtils"; +import { CombinedSheetData } from "./excelExportTypes"; +import { VersionInfoRenderer } from "./excelVersionInfoRenderer"; + +/** + * Generates chart images using uPlot + */ +export class ChartImageGenerator { + static async generate( + allSheetData: CombinedSheetData[], + groupId: string, + timeRangeTitle: string, + sortedTimestamps: number[], + startTime: number, + versionRenderer?: VersionInfoRenderer, + config?: IExportConfig, + ): Promise { + const exportConfig = config || new ExportConfig(); + let container: HTMLDivElement | null = null; + let plot: uPlot | null = null; + + try { + const dimensions = exportConfig.getChartDimensions(); + container = CanvasUtils.createOffscreenContainer( + dimensions.width, + dimensions.height, + ); + document.body.appendChild(container); + + const chartData = this.prepareChartData( + allSheetData, + sortedTimestamps, + startTime, + ); + const series = this.buildSeriesConfig(allSheetData, exportConfig); + const opts = this.buildPlotOptions( + groupId, + timeRangeTitle, + series, + exportConfig, + ); + + plot = new uPlot(opts, chartData as uPlot.AlignedData, container); + + await this.waitForRender(container); + + const canvas = container.querySelector("canvas"); + if (!canvas) return null; + + // Render version info centered at top of chart + if (versionRenderer) { + const ctx = canvas.getContext("2d"); + if (ctx) { + versionRenderer.renderOnCanvas(ctx, canvas.width); + } + } + + const imageData = canvas.toDataURL("image/png"); + return imageData.split(",")[1]; + } catch (error) { + console.error("Error generating chart image:", error); + return null; + } finally { + if (plot) plot.destroy(); + if (container && document.body.contains(container)) { + document.body.removeChild(container); + } + } + } + + private static prepareChartData( + allSheetData: CombinedSheetData[], + sortedTimestamps: number[], + startTime: number, + ): number[][] { + const chartData: number[][] = [ + TimestampConverter.arrayToSecondsFromStart(sortedTimestamps, startTime), + ]; + + const timestampIndexMap = new Map(); + sortedTimestamps.forEach((ts, idx) => { + timestampIndexMap.set(ts, idx); + }); + + allSheetData.forEach((sheetData) => { + const values = new Array(sortedTimestamps.length).fill(null); + + sheetData.timestamps.forEach((ts, idx) => { + const timeIndex = timestampIndexMap.get(ts); + if (timeIndex !== undefined) { + values[timeIndex] = sheetData.values[idx]; + } + }); + + chartData.push(values); + }); + + return chartData; + } + + private static buildSeriesConfig( + allSheetData: CombinedSheetData[], + config?: IExportConfig, + ): uPlot.Series[] { + const exportConfig = config || new ExportConfig(); + const series: uPlot.Series[] = [{ label: "Time (s)" }]; + + allSheetData.forEach((sheetData) => { + const color = sheetData.color || exportConfig.getDefaultChartColor(); + series.push({ + label: sheetData.sheetName, + stroke: color, + width: 2, + points: { + show: true, + size: 3, + width: 1, + }, + }); + }); + + return series; + } + + private static buildPlotOptions( + groupId: string, + timeRangeTitle: string, + series: uPlot.Series[], + config?: IExportConfig, + ): uPlot.Options { + const exportConfig = config || new ExportConfig(); + const dimensions = exportConfig.getChartDimensions(); + + return { + title: `${groupId} - ${timeRangeTitle}`, + width: dimensions.width, + height: dimensions.height, + series, + scales: { + x: { time: false }, + }, + axes: [ + { + label: "Time (seconds)", + stroke: "#333", + grid: { stroke: "#e0e0e0", width: 1 }, + }, + { + label: "Values", + stroke: "#333", + grid: { stroke: "#e0e0e0", width: 1 }, + }, + ], + legend: { show: false }, + cursor: { show: false }, + }; + } + + private static async waitForRender(container: HTMLDivElement): Promise { + return new Promise((resolve) => { + const checkCanvas = () => { + const canvas = container.querySelector("canvas"); + if (canvas && canvas.width > 0 && canvas.height > 0) { + requestAnimationFrame(() => resolve()); + } else { + requestAnimationFrame(checkCanvas); + } + }; + checkCanvas(); + + // Fallback timeout + setTimeout(() => resolve(), 500); + }); + } +} diff --git a/electron/src/components/graph/excelCommentManager.ts b/electron/src/components/graph/excelCommentManager.ts new file mode 100644 index 000000000..eda0f6c81 --- /dev/null +++ b/electron/src/components/graph/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/excelDataSheetBuilder.ts b/electron/src/components/graph/excelDataSheetBuilder.ts new file mode 100644 index 000000000..eee13a744 --- /dev/null +++ b/electron/src/components/graph/excelDataSheetBuilder.ts @@ -0,0 +1,173 @@ +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"; + +/** + * 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); + } + + // Convert to worksheet + const worksheet = XLSX.utils.aoa_to_sheet(sheetData); + 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/excelDateFormatter.ts b/electron/src/components/graph/excelDateFormatter.ts new file mode 100644 index 000000000..df9f1c3c9 --- /dev/null +++ b/electron/src/components/graph/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.ts b/electron/src/components/graph/excelExport.ts index 7f6077084..3cb46c352 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -1,1069 +1,64 @@ import * as XLSX from "xlsx"; // @ts-ignore - ExcelJS types not installed import ExcelJS from "exceljs"; -import uPlot from "uplot"; -import { TimeSeries, seriesToUPlotData } from "@/lib/timeseries"; -import { renderUnitSymbol, Unit } from "@/control/units"; -import { GraphConfig, SeriesData, GraphLine } from "./types"; +import { seriesToUPlotData } from "@/lib/timeseries"; +import { renderUnitSymbol } from "@/control/units"; +import { GraphLine } from "./types"; import { LogEntry } from "@/stores/logsStore"; +import { + ExportConfig, + IExportConfig, + WindowEnvironmentProvider, +} from "./excelExportConfig"; +import { IValueFormatter, ValueFormatter } from "./excelFormatters"; +import { 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"; +import { VersionInfoRenderer } from "./excelVersionInfoRenderer"; +import { ChartImageGenerator } from "./excelChartImageGenerator"; +import { LegendImageGenerator } from "./excelLegendImageGenerator"; + +export type { GraphExportData, PidSettings, PidData } from "./excelExportTypes"; /** - * 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; -}; - -type CombinedSheetData = { - sheetName: string; - timestamps: number[]; - values: number[]; - unit: string; - seriesTitle: string; - graphTitle: string; - targetLines: GraphLine[]; - color?: string; -}; - -/** - * Utility class for date/time formatting and manipulation - */ -class DateFormatter { - static readonly GERMAN_LOCALE = "de-DE"; - - static format(date: Date): string { - return date.toLocaleString(this.GERMAN_LOCALE, { - day: "2-digit", - month: "2-digit", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - } - - static getExportTimestamp(): string { - return new Date() - .toISOString() - .replace(/[:.]/g, "-") - .slice(0, 19); - } - - static formatTimeRange(startTime: number, endTime: number): string { - const startDate = new Date(startTime); - const endDate = new Date(endTime); - return `${this.format(startDate)} bis ${this.format(endDate)}`; - } -} - -/** - * Manages unique sheet name generation for Excel workbooks - */ -class SheetNameManager { - private usedNames = new Set(); - - private readonly UNIT_FRIENDLY_NAMES: Record = { - "°C": "Temp", - "W": "Watt", - "A": "Ampere", - "bar": "Bar", - "rpm": "Rpm", - "1/min": "Rpm", - "mm": "mm", - "%": "Percent", - }; - - 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.UNIT_FRIENDLY_NAMES[unitSymbol]; - sheetName = friendlyUnitName || seriesTitle; - } else { - const friendlyUnitName = this.UNIT_FRIENDLY_NAMES[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; - } -} - -/** - * Handles statistical calculations for time series data - */ -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 }; - } -} - -/** - * Filters and manages log comments for 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 - ); - } -} - -/** - * Builds metadata sections for Excel sheets - */ -class MetadataBuilder { - private rows: string[][] = []; - - addSection(title: string, columnCount: number): this { - this.rows.push([title, ...Array(columnCount - 1).fill("")]); - return this; - } - - addRow(key: string, value: string, columnCount: number): this { - this.rows.push([key, value, ...Array(columnCount - 2).fill("")]); - return this; - } - - addEmptyRow(columnCount: number): this { - this.rows.push(Array(columnCount).fill("")); - return this; - } - - addExportInfo(columnCount: number): this { - this.addSection("Export Information", columnCount); - this.addRow("Software", "QiTech Control", columnCount); - this.addRow( - "Export Date", - DateFormatter.format(new Date()), - columnCount - ); - - return this; - } - - addPidSettings(pidData: PidData | undefined, columnCount: number): this { - if (!pidData) return this; - - this.addEmptyRow(columnCount); - this.addSection("PID Controller Settings", columnCount); - - // Temperature PID settings - if (pidData.temperature) { - this.addEmptyRow(columnCount); - this.addRow("Temperature Controllers", "", columnCount); - - Object.entries(pidData.temperature).forEach(([zone, settings]) => { - this.addRow(` ${zone} - Kp`, settings.kp.toFixed(3), columnCount); - this.addRow(` ${zone} - Ki`, settings.ki.toFixed(3), columnCount); - this.addRow(` ${zone} - Kd`, settings.kd.toFixed(3), columnCount); - }); - } - - // Pressure PID settings - if (pidData.pressure) { - this.addEmptyRow(columnCount); - this.addRow("Pressure Controller", "", columnCount); - this.addRow(" Kp", pidData.pressure.kp.toFixed(3), columnCount); - this.addRow(" Ki", pidData.pressure.ki.toFixed(3), columnCount); - this.addRow(" Kd", pidData.pressure.kd.toFixed(3), columnCount); - } - - return this; - } - - getRows(): string[][] { - return this.rows; - } -} - -/** - * Creates individual data sheets for each series + * Main orchestrator for Excel export functionality */ -class DataSheetBuilder { - 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 - ) {} - - 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); - } - - // Convert to worksheet - const worksheet = XLSX.utils.aoa_to_sheet(sheetData); - 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", DateFormatter.format(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", DateFormatter.format(firstDate)]); - statsRows.push(["Time Range End", DateFormatter.format(lastDate)]); - - const duration = timestamps[timestamps.length - 1] - timestamps[0]; - const durationHours = (duration / (1000 * 60 * 60)).toFixed(2); - 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] = DateFormatter.format(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) - : value?.toFixed(3) || ""; - } -} +export class ExcelExporter { + private config: IExportConfig; + private formatter: IValueFormatter; + private sheetNameManager: SheetNameManager; + private pidDataProvider?: IPidDataProvider; -/** - * Creates the combined analysis sheet with all series data - */ -class AnalysisSheetBuilder { constructor( - private allSheetData: CombinedSheetData[], - private groupId: string, - private logs: LogEntry[], - private pidData?: PidData - ) {} - - 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 = DateFormatter.formatTimeRange(startTime, endTime); - sheetData.push([ - `${this.groupId} - ${timeRangeTitle}`, - ...Array(columns.length - 1).fill(""), - ]); - sheetData.push(Array(columns.length).fill("")); - - // 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 = Math.floor((timestamp - startTime) / 1000); - 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 - ); - - // Convert to worksheet - const worksheet = XLSX.utils.aoa_to_sheet(sheetData); - - // 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); + 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; } - 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(targetLine.value.toFixed(2)); - hasTargets = true; - } else { - targetValues.push(""); - } - } else { - targetValues.push(""); - } - }); - - targetValues.push(""); // Empty for comments column - - if (hasTargets) { - sheetData.push(targetValues); - sheetData.push(Array(columnCount).fill("")); - } - } - - private buildDataRow( - timestamp: number, - startTime: number, - dataByTimestamp: Map>, - relevantComments: LogEntry[] - ): any[] { - const row: any[] = []; - - // Calculate seconds from start - const secondsFromStart = Math.floor((timestamp - startTime) / 1000); - 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(Number(tsData.get(sheetDataEntry.sheetName)!.toFixed(2))); - } else { - row.push(""); - } - }); - - // Check for comments at this timestamp - const comment = CommentManager.findAtTimestamp(relevantComments, timestamp); - row.push(comment ? comment.message : ""); - - return row; - } - - private async addMetadata( - sheetData: any[][], - columnCount: number, - relevantComments: LogEntry[] - ): Promise { - sheetData.push(Array(columnCount).fill("")); - sheetData.push(Array(columnCount).fill("")); - - const metadataBuilder = new MetadataBuilder(); - metadataBuilder.addExportInfo(columnCount); - - // Add PID settings if available - metadataBuilder.addPidSettings(this.pidData, columnCount); - - // Add comment statistics - metadataBuilder.addEmptyRow(columnCount); - metadataBuilder.addSection("Comment Statistics", columnCount); - metadataBuilder.addRow( - "Total Comments", - relevantComments.length.toString(), - columnCount - ); - - sheetData.push(...metadataBuilder.getRows()); - } - - private addChartInstructions( - sheetData: any[][], - columnCount: number, - dataStartRow: number, - maxSeconds: number, - timeRangeTitle: string - ): void { - sheetData.push(Array(columnCount).fill("")); - sheetData.push(Array(columnCount).fill("")); - sheetData.push(["Chart Instructions", ...Array(columnCount - 1).fill("")]); - sheetData.push([ - `1. Select all data from row ${dataStartRow} to the last data row`, - ...Array(columnCount - 1).fill(""), - ]); - sheetData.push([ - "2. Insert > Chart > Scatter Chart with Straight Lines and Markers", - ...Array(columnCount - 1).fill(""), - ]); - sheetData.push([ - "3. X-axis: Time (seconds), Y-axis: All measurement columns", - ...Array(columnCount - 1).fill(""), - ]); - sheetData.push([ - `4. Set X-axis range: 0 to ${maxSeconds}`, - ...Array(columnCount - 1).fill(""), - ]); - sheetData.push([ - "5. Set Y-axis range: 0 to 1000", - ...Array(columnCount - 1).fill(""), - ]); - sheetData.push([ - "6. Position legend at bottom", - ...Array(columnCount - 1).fill(""), - ]); - sheetData.push([ - `7. Chart Title: ${this.groupId} - ${timeRangeTitle}`, - ...Array(columnCount - 1).fill(""), - ]); - } - - 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; - } -} - -/** - * Handles version information retrieval and rendering - * Follows Single Responsibility Principle - only manages version info - */ -class VersionInfoRenderer { - private versionInfo: string = ""; - private commitInfo: string = ""; - - async fetchVersionInfo(): Promise { - try { - const envInfo = await window.environment.getInfo(); - if (envInfo.qitechOsGitAbbreviation) { - this.versionInfo = envInfo.qitechOsGitAbbreviation; - } - if (envInfo.qitechOsGitCommit) { - this.commitInfo = envInfo.qitechOsGitCommit.substring(0, 8); - } - } catch (error) { - console.warn("Failed to fetch environment info", error); - } - } - - renderOnCanvas(ctx: CanvasRenderingContext2D, canvasWidth: number): void { - if (!this.versionInfo && !this.commitInfo) return; - - const versionText = this.formatVersionText(); - - ctx.save(); - ctx.font = "12px sans-serif"; - ctx.fillStyle = "#666"; - ctx.textAlign = "center"; - ctx.fillText(versionText, canvasWidth / 2, 20); - ctx.restore(); - } - - private formatVersionText(): string { - const parts: string[] = []; - if (this.versionInfo) { - parts.push(`Version: ${this.versionInfo}`); - } - if (this.commitInfo) { - parts.push(`Commit: ${this.commitInfo}`); - } - return parts.join(" | "); - } - - getVersionInfo(): string { - return this.versionInfo; - } - - getCommitInfo(): string { - return this.commitInfo; - } -} - -/** - * Generates chart images using uPlot - */ -class ChartImageGenerator { - static async generate( - allSheetData: CombinedSheetData[], - groupId: string, - timeRangeTitle: string, - sortedTimestamps: number[], - startTime: number, - versionRenderer?: VersionInfoRenderer - ): Promise { - let container: HTMLDivElement | null = null; - let plot: uPlot | null = null; - - try { - container = this.createOffScreenContainer(); - document.body.appendChild(container); - - const chartData = this.prepareChartData( - allSheetData, - sortedTimestamps, - startTime - ); - const series = this.buildSeriesConfig(allSheetData); - const opts = this.buildPlotOptions(groupId, timeRangeTitle, series); - - plot = new uPlot(opts, chartData as uPlot.AlignedData, container); - - await this.waitForRender(container); - - const canvas = container.querySelector("canvas"); - if (!canvas) return null; - - // Render version info centered at top of chart - if (versionRenderer) { - const ctx = canvas.getContext("2d"); - if (ctx) { - versionRenderer.renderOnCanvas(ctx, canvas.width); - } - } - - const imageData = canvas.toDataURL("image/png"); - return imageData.split(",")[1]; - } catch (error) { - console.error("Error generating chart image:", error); - return null; - } finally { - if (plot) plot.destroy(); - if (container && document.body.contains(container)) { - document.body.removeChild(container); - } - } - } - - private static createOffScreenContainer(): HTMLDivElement { - const container = document.createElement("div"); - container.style.width = "1200px"; - container.style.height = "600px"; - container.style.position = "absolute"; - container.style.left = "-9999px"; - return container; - } - - private static prepareChartData( - allSheetData: CombinedSheetData[], - sortedTimestamps: number[], - startTime: number - ): number[][] { - const chartData: number[][] = [ - sortedTimestamps.map((ts) => (ts - startTime) / 1000), - ]; - - const timestampIndexMap = new Map(); - sortedTimestamps.forEach((ts, idx) => { - timestampIndexMap.set(ts, idx); - }); - - allSheetData.forEach((sheetData) => { - const values = new Array(sortedTimestamps.length).fill(null); - - sheetData.timestamps.forEach((ts, idx) => { - const timeIndex = timestampIndexMap.get(ts); - if (timeIndex !== undefined) { - values[timeIndex] = sheetData.values[idx]; - } - }); - - chartData.push(values); - }); - - return chartData; - } - - private static buildSeriesConfig( - allSheetData: CombinedSheetData[] - ): uPlot.Series[] { - const series: uPlot.Series[] = [{ label: "Time (s)" }]; - - allSheetData.forEach((sheetData) => { - const color = sheetData.color || "#9b59b6"; - series.push({ - label: sheetData.sheetName, - stroke: color, - width: 2, - points: { - show: true, - size: 3, - width: 1, - }, - }); - }); - - return series; - } - - private static buildPlotOptions( - groupId: string, - timeRangeTitle: string, - series: uPlot.Series[] - ): uPlot.Options { - return { - title: `${groupId} - ${timeRangeTitle}`, - width: 1200, - height: 600, - series, - scales: { - x: { time: false }, - }, - axes: [ - { - label: "Time (seconds)", - stroke: "#333", - grid: { stroke: "#e0e0e0", width: 1 }, - }, - { - label: "Values", - stroke: "#333", - grid: { stroke: "#e0e0e0", width: 1 }, - }, - ], - legend: { show: false }, - cursor: { show: false }, - }; - } - - private static async waitForRender(container: HTMLDivElement): Promise { - return new Promise((resolve) => { - const checkCanvas = () => { - const canvas = container.querySelector("canvas"); - if (canvas && canvas.width > 0 && canvas.height > 0) { - requestAnimationFrame(() => resolve()); - } else { - requestAnimationFrame(checkCanvas); - } - }; - checkCanvas(); - - // Fallback timeout - setTimeout(() => resolve(), 500); - }); - } -} - -/** - * Generates legend images for charts - */ -class LegendImageGenerator { - static generate(allSheetData: CombinedSheetData[]): string | null { - try { - const dimensions = this.calculateDimensions(allSheetData); - const canvas = this.createCanvas(dimensions.width, dimensions.height); - const ctx = canvas.getContext("2d"); - - if (!ctx) return null; - - this.drawBackground(ctx, dimensions.width, dimensions.height); - this.drawLegendItems(ctx, allSheetData, dimensions); - - const imageData = canvas.toDataURL("image/png"); - return imageData.split(",")[1]; - } catch (error) { - console.error("Error generating legend image:", error); - return null; - } - } - - private static calculateDimensions(allSheetData: CombinedSheetData[]): { - width: number; - height: number; - rows: number; - } { - const canvasWidth = 1200; - const itemSpacing = 15; - const rowHeight = 20; - const topPadding = 5; - const bottomPadding = 5; - const maxItemWidth = 1100; - const itemStartX = 20; - - const tempCanvas = document.createElement("canvas"); - const tempCtx = tempCanvas.getContext("2d"); - if (!tempCtx) return { width: canvasWidth, height: 50, rows: 1 }; - - tempCtx.font = "12px sans-serif"; - - let legendX = itemStartX; - let rowCount = 1; - - allSheetData.forEach((sheetData, index) => { - const textWidth = tempCtx.measureText(sheetData.sheetName).width; - const itemWidth = 16 + textWidth + itemSpacing; - - if ( - legendX + itemWidth > maxItemWidth && - index < allSheetData.length - 1 - ) { - legendX = itemStartX; - rowCount++; - } - - legendX += itemWidth; - }); - - const height = - topPadding + rowHeight + (rowCount - 1) * rowHeight + bottomPadding; - - return { width: canvasWidth, height, rows: rowCount }; - } - - private static createCanvas(width: number, height: number): HTMLCanvasElement { - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - return canvas; - } - - private static drawBackground( - ctx: CanvasRenderingContext2D, - width: number, - height: number - ): void { - ctx.fillStyle = "#ffffff"; - ctx.fillRect(0, 0, width, height); - } - - private static drawLegendItems( - ctx: CanvasRenderingContext2D, - allSheetData: CombinedSheetData[], - dimensions: { width: number; height: number; rows: number } - ): void { - const itemSpacing = 15; - const rowHeight = 20; - const topPadding = 5; - const maxItemWidth = 1100; - const itemStartX = 20; - - ctx.font = "12px sans-serif"; - ctx.textAlign = "left"; - - let currentX = itemStartX; - let currentY = topPadding + 15; - - allSheetData.forEach((sheetData, index) => { - const color = sheetData.color || "#9b59b6"; - const label = sheetData.sheetName; - const textWidth = ctx.measureText(label).width; - const itemWidth = 16 + textWidth + itemSpacing; - - if ( - currentX + itemWidth > maxItemWidth && - index < allSheetData.length - 1 - ) { - currentX = itemStartX; - currentY += rowHeight; - } - - // Draw color indicator - ctx.fillStyle = color; - ctx.fillRect(currentX, currentY - 6, 12, 12); - - // Draw label text - ctx.fillStyle = "#333"; - ctx.fillText(label, currentX + 16, currentY + 4); - - currentX += itemWidth; - }); - } -} - -/** - * Main orchestrator for Excel export functionality - */ -export class ExcelExporter { - private sheetNameManager = new SheetNameManager(); - async export( graphDataMap: Map GraphExportData | null>, groupId: string, logs: LogEntry[] = [], - pidData?: PidData + 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(); @@ -1077,14 +72,14 @@ export class ExcelExporter { 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 + exportData.unit, ); const targetLines: GraphLine[] = [ @@ -1105,7 +100,8 @@ export class ExcelExporter { targetLines, }, seriesTitle, - exportData.unit + exportData.unit, + this.formatter, ); const worksheet = dataSheetBuilder.build(); @@ -1135,7 +131,9 @@ export class ExcelExporter { allSheetData, groupId, logs, - pidData + pidData, + this.config, + this.formatter, ); const analysisSheet = await analysisSheetBuilder.build(); XLSX.utils.book_append_sheet(workbook, analysisSheet, "Analysis"); @@ -1146,13 +144,13 @@ export class ExcelExporter { alert( `Error exporting data to Excel: ${ error instanceof Error ? error.message : "Unknown error" - }. Please try again.` + }. Please try again.`, ); } } private filterValidSeries( - graphDataMap: Map GraphExportData | null> + graphDataMap: Map GraphExportData | null>, ): Map GraphExportData | null> { const filteredMap = new Map GraphExportData | null>(); graphDataMap.forEach((getDataFn, seriesId) => { @@ -1166,7 +164,7 @@ export class ExcelExporter { private async addChartImages( workbook: XLSX.WorkBook, allSheetData: CombinedSheetData[], - groupId: string + groupId: string, ): Promise { const xlsxBuffer = XLSX.write(workbook, { type: "buffer", @@ -1180,7 +178,7 @@ export class ExcelExporter { } catch (error) { console.error("Failed to load XLSX buffer into ExcelJS", error); alert( - "Export failed while preparing the Excel file. The generated workbook data was invalid or could not be processed." + "Export failed while preparing the Excel file. The generated workbook data was invalid or could not be processed.", ); return; } @@ -1189,15 +187,16 @@ export class ExcelExporter { if (!analysisWorksheet) return; const sortedTimestamps = Array.from( - new Set(allSheetData.flatMap((d) => d.timestamps)) + new Set(allSheetData.flatMap((d) => d.timestamps)), ).sort((a, b) => a - b); const startTime = sortedTimestamps[0]; const endTime = sortedTimestamps[sortedTimestamps.length - 1]; - const timeRangeTitle = DateFormatter.formatTimeRange(startTime, endTime); + const timeRangeTitle = this.formatter.formatTimeRange(startTime, endTime); // Fetch version info for chart rendering - const versionRenderer = new VersionInfoRenderer(); + const envProvider = new WindowEnvironmentProvider(); + const versionRenderer = new VersionInfoRenderer(envProvider); await versionRenderer.fetchVersionInfo(); const chartImage = await ChartImageGenerator.generate( @@ -1206,10 +205,13 @@ export class ExcelExporter { timeRangeTitle, sortedTimestamps, startTime, - versionRenderer + versionRenderer, + this.config, ); if (chartImage) { + const dimensions = this.config.getChartDimensions(); + const chartImageId = excelJSWorkbook.addImage({ base64: chartImage, extension: "png", @@ -1218,10 +220,13 @@ export class ExcelExporter { const lastRow = analysisWorksheet.rowCount; analysisWorksheet.addImage(chartImageId, { tl: { col: 0, row: lastRow + 2 }, - ext: { width: 1200, height: 600 }, + ext: { width: dimensions.width, height: dimensions.height }, }); - const legendImage = LegendImageGenerator.generate(allSheetData); + const legendImage = LegendImageGenerator.generate( + allSheetData, + this.config, + ); if (legendImage) { const legendImageId = excelJSWorkbook.addImage({ base64: legendImage, @@ -1230,7 +235,7 @@ export class ExcelExporter { analysisWorksheet.addImage(legendImageId, { tl: { col: 0, row: lastRow + 2 + 32 }, - ext: { width: 1200, height: 50 }, + ext: { width: dimensions.width, height: 50 }, }); } } @@ -1243,7 +248,7 @@ export class ExcelExporter { private triggerDownload( buffer: ArrayBuffer, groupId: string, - exportTimestamp: string + exportTimestamp: string, ): void { const filename = `${groupId .toLowerCase() @@ -1268,7 +273,7 @@ export async function exportGraphsToExcel( graphDataMap: Map GraphExportData | null>, groupId: string, logs: LogEntry[] = [], - pidData?: PidData + pidData?: PidData, ): Promise { const exporter = new ExcelExporter(); await exporter.export(graphDataMap, groupId, logs, pidData); diff --git a/electron/src/components/graph/excelExportConfig.ts b/electron/src/components/graph/excelExportConfig.ts new file mode 100644 index 000000000..b88f691bd --- /dev/null +++ b/electron/src/components/graph/excelExportConfig.ts @@ -0,0 +1,171 @@ +/** + * 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; + getChartDimensions(): { width: number; height: number }; + getLegendDimensions(): { baseHeight: number; itemSpacing: number }; + 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", + chartDimensions: { width: 1200, height: 600 }, + legendDimensions: { baseHeight: 50, itemSpacing: 15 }, + 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; + } + + getChartDimensions(): { width: number; height: number } { + return this.config.chartDimensions; + } + + getLegendDimensions(): { baseHeight: number; itemSpacing: number } { + return this.config.legendDimensions; + } + + 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(); + } +} + +/** + * Environment information provider interface + * Abstracts away the window.environment dependency + */ +export interface IEnvironmentInfoProvider { + getVersionInfo(): Promise<{ + version?: string; + commit?: string; + }>; +} + +/** + * Default implementation using window.environment + */ +export class WindowEnvironmentProvider implements IEnvironmentInfoProvider { + async getVersionInfo(): Promise<{ + version?: string; + commit?: string; + }> { + try { + const envInfo = await window.environment.getInfo(); + return { + version: envInfo.qitechOsGitAbbreviation, + commit: envInfo.qitechOsGitCommit?.substring(0, 8), + }; + } catch (error) { + console.warn("Failed to fetch environment info", error); + return {}; + } + } +} + +/** + * Mock implementation for testing + */ +export class MockEnvironmentProvider implements IEnvironmentInfoProvider { + constructor( + private mockVersion?: string, + private mockCommit?: string, + ) {} + + async getVersionInfo(): Promise<{ + version?: string; + commit?: string; + }> { + return { + version: this.mockVersion, + commit: this.mockCommit, + }; + } +} diff --git a/electron/src/components/graph/excelExportTypes.ts b/electron/src/components/graph/excelExportTypes.ts new file mode 100644 index 000000000..9d85a1ca4 --- /dev/null +++ b/electron/src/components/graph/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/excelFormatters.ts b/electron/src/components/graph/excelFormatters.ts new file mode 100644 index 000000000..b535410b9 --- /dev/null +++ b/electron/src/components/graph/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/excelLegendImageGenerator.ts b/electron/src/components/graph/excelLegendImageGenerator.ts new file mode 100644 index 000000000..05b2c687f --- /dev/null +++ b/electron/src/components/graph/excelLegendImageGenerator.ts @@ -0,0 +1,143 @@ +import { ExportConfig, IExportConfig } from "./excelExportConfig"; +import { CombinedSheetData } from "./excelExportTypes"; + +/** + * Generates legend images for charts + */ +export class LegendImageGenerator { + static generate( + allSheetData: CombinedSheetData[], + config?: IExportConfig, + ): string | null { + const exportConfig = config || new ExportConfig(); + + try { + const dimensions = this.calculateDimensions(allSheetData, exportConfig); + const canvas = this.createCanvas(dimensions.width, dimensions.height); + const ctx = canvas.getContext("2d"); + + if (!ctx) return null; + + this.drawBackground(ctx, dimensions.width, dimensions.height); + this.drawLegendItems(ctx, allSheetData, dimensions, exportConfig); + + const imageData = canvas.toDataURL("image/png"); + return imageData.split(",")[1]; + } catch (error) { + console.error("Error generating legend image:", error); + return null; + } + } + + private static calculateDimensions( + allSheetData: CombinedSheetData[], + config: IExportConfig, + ): { + width: number; + height: number; + rows: number; + } { + const chartDimensions = config.getChartDimensions(); + const legendDimensions = config.getLegendDimensions(); + const canvasWidth = chartDimensions.width; + const itemSpacing = legendDimensions.itemSpacing; + const rowHeight = 20; + const topPadding = 5; + const bottomPadding = 5; + const maxItemWidth = canvasWidth - 100; + const itemStartX = 20; + + const tempCanvas = document.createElement("canvas"); + const tempCtx = tempCanvas.getContext("2d"); + if (!tempCtx) return { width: canvasWidth, height: 50, rows: 1 }; + + tempCtx.font = "12px sans-serif"; + + let legendX = itemStartX; + let rowCount = 1; + + allSheetData.forEach((sheetData, index) => { + const textWidth = tempCtx.measureText(sheetData.sheetName).width; + const itemWidth = 16 + textWidth + itemSpacing; + + if ( + legendX + itemWidth > maxItemWidth && + index < allSheetData.length - 1 + ) { + legendX = itemStartX; + rowCount++; + } + + legendX += itemWidth; + }); + + const height = + topPadding + rowHeight + (rowCount - 1) * rowHeight + bottomPadding; + + return { width: canvasWidth, height, rows: rowCount }; + } + + private static createCanvas( + width: number, + height: number, + ): HTMLCanvasElement { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + return canvas; + } + + private static drawBackground( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + ): void { + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, width, height); + } + + private static drawLegendItems( + ctx: CanvasRenderingContext2D, + allSheetData: CombinedSheetData[], + dimensions: { width: number; height: number; rows: number }, + config: IExportConfig, + ): void { + const legendDimensions = config.getLegendDimensions(); + const itemSpacing = legendDimensions.itemSpacing; + const rowHeight = 20; + const topPadding = 5; + const maxItemWidth = dimensions.width - 100; + const itemStartX = 20; + + ctx.font = "12px sans-serif"; + ctx.textAlign = "left"; + + let currentX = itemStartX; + let currentY = topPadding + 15; + + allSheetData.forEach((sheetData, index) => { + const color = sheetData.color || config.getDefaultChartColor(); + const label = sheetData.sheetName; + const textWidth = ctx.measureText(label).width; + const itemWidth = 16 + textWidth + itemSpacing; + + if ( + currentX + itemWidth > maxItemWidth && + index < allSheetData.length - 1 + ) { + currentX = itemStartX; + currentY += rowHeight; + } + + // Draw color indicator + ctx.fillStyle = color; + ctx.fillRect(currentX, currentY - 6, 12, 12); + + // Draw label text + ctx.fillStyle = "#333"; + ctx.fillText(label, currentX + 16, currentY + 4); + + currentX += itemWidth; + }); + } +} diff --git a/electron/src/components/graph/excelMetadata.ts b/electron/src/components/graph/excelMetadata.ts new file mode 100644 index 000000000..36369ca8a --- /dev/null +++ b/electron/src/components/graph/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/excelMetadataBuilder.ts b/electron/src/components/graph/excelMetadataBuilder.ts new file mode 100644 index 000000000..59432cebb --- /dev/null +++ b/electron/src/components/graph/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/excelSheetNameManager.ts b/electron/src/components/graph/excelSheetNameManager.ts new file mode 100644 index 000000000..889bb4489 --- /dev/null +++ b/electron/src/components/graph/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/excelStatisticsCalculator.ts b/electron/src/components/graph/excelStatisticsCalculator.ts new file mode 100644 index 000000000..ac6757009 --- /dev/null +++ b/electron/src/components/graph/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/excelUtils.ts b/electron/src/components/graph/excelUtils.ts new file mode 100644 index 000000000..244942fb0 --- /dev/null +++ b/electron/src/components/graph/excelUtils.ts @@ -0,0 +1,153 @@ +/** + * Additional utility classes for Excel export + * Following SOLID principles and DRY + */ + +/** + * Utility for creating offscreen canvas containers + * Eliminates duplicate container creation code + */ +export class CanvasUtils { + static createOffscreenContainer( + width: number, + height: number, + ): HTMLDivElement { + const container = document.createElement("div"); + container.style.width = `${width}px`; + container.style.height = `${height}px`; + container.style.position = "absolute"; + container.style.left = "-9999px"; + return container; + } +} + +/** + * 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}`; + } +} + +/** + * 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/excelVersionInfoRenderer.ts b/electron/src/components/graph/excelVersionInfoRenderer.ts new file mode 100644 index 000000000..bdcf051c2 --- /dev/null +++ b/electron/src/components/graph/excelVersionInfoRenderer.ts @@ -0,0 +1,55 @@ +import { IEnvironmentInfoProvider } from "./excelExportConfig"; + +/** + * Handles version information retrieval and rendering + * Follows Single Responsibility Principle - only manages version info + * Now uses IEnvironmentInfoProvider for better testability + */ +export class VersionInfoRenderer { + private versionInfo: string = ""; + private commitInfo: string = ""; + + constructor(private envProvider: IEnvironmentInfoProvider) {} + + async fetchVersionInfo(): Promise { + try { + const info = await this.envProvider.getVersionInfo(); + this.versionInfo = info.version ?? ""; + this.commitInfo = info.commit ?? ""; + } catch (error) { + console.warn("Failed to fetch environment info", error); + } + } + + renderOnCanvas(ctx: CanvasRenderingContext2D, canvasWidth: number): void { + if (!this.versionInfo && !this.commitInfo) return; + + const versionText = this.formatVersionText(); + + ctx.save(); + ctx.font = "12px sans-serif"; + ctx.fillStyle = "#666"; + ctx.textAlign = "center"; + ctx.fillText(versionText, canvasWidth / 2, 20); + ctx.restore(); + } + + private formatVersionText(): string { + const parts: string[] = []; + if (this.versionInfo) { + parts.push(`Version: ${this.versionInfo}`); + } + if (this.commitInfo) { + parts.push(`Commit: ${this.commitInfo}`); + } + return parts.join(" | "); + } + + getVersionInfo(): string { + return this.versionInfo; + } + + getCommitInfo(): string { + return this.commitInfo; + } +} diff --git a/electron/src/components/graph/useGraphSync.ts b/electron/src/components/graph/useGraphSync.ts index c583e64e9..de5b348d4 100644 --- a/electron/src/components/graph/useGraphSync.ts +++ b/electron/src/components/graph/useGraphSync.ts @@ -128,10 +128,13 @@ export function useGraphSync(exportGroupId?: string) { return; } const logs = useLogsStore.getState().entries; - exportGraphsToExcel(graphDataRef.current, exportGroupId || "synced-graphs", logs) - .catch((error) => { - console.error("Failed to export graphs:", error); - }); + exportGraphsToExcel( + graphDataRef.current, + exportGroupId || "synced-graphs", + logs, + ).catch((error) => { + console.error("Failed to export graphs:", error); + }); }, [exportGroupId]); const handleTimeWindowChange = useCallback( From 14fab15bd61b015fd9c2376c4090bcc7206e54ff Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Sun, 25 Jan 2026 23:30:09 +0100 Subject: [PATCH 23/26] resolved npm issue --- electron/src/components/graph/excelExport.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index 3cb46c352..169fb8a75 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -1,5 +1,5 @@ import * as XLSX from "xlsx"; -// @ts-ignore - ExcelJS types not installed +// @ts-expect-error - ExcelJS types not installed import ExcelJS from "exceljs"; import { seriesToUPlotData } from "@/lib/timeseries"; import { renderUnitSymbol } from "@/control/units"; From 27a9c73e3e327a76b813fbbb1e5cecee667c6fe9 Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Sun, 25 Jan 2026 23:47:13 +0100 Subject: [PATCH 24/26] fix: update npmDepsHash for electron nix build --- nixos/packages/electron.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nixos/packages/electron.nix b/nixos/packages/electron.nix index 4cc74322d..b605608de 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-twp+30+hPKoMUojuGyJI1R6dknWWx9i5waqFw2FlHPc="; + npmDepsHash = lib.fakeHash; npmFlags = [ "--no-audit" "--no-fund" ]; installPhase = '' From 9b43c7fd74de465b0df2d9591167e6b97d3531cc Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Sun, 25 Jan 2026 23:48:58 +0100 Subject: [PATCH 25/26] removed the unnecessary ts error directive causing failed pull request check --- electron/src/components/graph/excelExport.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index 169fb8a75..5fd5a3104 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -1,5 +1,4 @@ import * as XLSX from "xlsx"; -// @ts-expect-error - ExcelJS types not installed import ExcelJS from "exceljs"; import { seriesToUPlotData } from "@/lib/timeseries"; import { renderUnitSymbol } from "@/control/units"; From 099cb3442dd67cb790b9f5e6607a75e7ca20f0e1 Mon Sep 17 00:00:00 2001 From: Saurabh Bagade Date: Sun, 25 Jan 2026 23:52:16 +0100 Subject: [PATCH 26/26] fix: correct npmDepsHash for electron nix package --- nixos/packages/electron.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nixos/packages/electron.nix b/nixos/packages/electron.nix index b605608de..7a8cbdd24 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 = lib.fakeHash; + npmDepsHash = "sha256-o5pUrx99LEihFKo3zSv35HKoCkxKcyyNE1Zevw7/To4="; npmFlags = [ "--no-audit" "--no-fund" ]; installPhase = ''