Compare commits

...
22 changed files with 1653 additions and 378 deletions

No files matched your search

+82 -15
View File
@@ -4698,8 +4698,15 @@
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": ">=18"
"node": "*"
}
},
"node_modules/chalk": {
@@ -4759,6 +4766,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -5040,6 +5057,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -7943,6 +7970,13 @@
"loose-envify": "cli.js"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
@@ -8920,6 +8954,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -9299,6 +9345,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -10439,8 +10495,31 @@
"peer": true,
"dependencies": {
"csstype": "^3.1.0",
"seroval": "~1.5.0",
"seroval-plugins": "~1.5.0"
"seroval": "~1.3.0",
"seroval-plugins": "~1.3.0"
}
},
"node_modules/solid-js/node_modules/seroval": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/solid-js/node_modules/seroval-plugins": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.3.tgz",
"integrity": "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"peerDependencies": {
"seroval": "^1.0"
}
},
"node_modules/sonner": {
@@ -11241,18 +11320,6 @@
"yaku": "^0.16.6"
}
},
"node_modules/unzip-crx-3/node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",
+27 -31
View File
@@ -111,10 +111,26 @@ export function BigGraph({
// State for series visibility
const normalizedSeries = normalizeDataSeries(newData);
const latestExportContextRef = useRef({
normalizedSeries,
config,
unit,
renderValue,
});
const [visibleSeries, setVisibleSeries] = useState<boolean[]>(
new Array(normalizedSeries.length).fill(true),
);
// Keep latest export context in a ref so export callbacks always read fresh data.
useEffect(() => {
latestExportContextRef.current = {
normalizedSeries,
config,
unit,
renderValue,
};
}, [normalizedSeries, config, unit, renderValue]);
// Enhanced configuration with visible series lines
const enhancedConfig = React.useMemo(() => {
return mergeConfigWithVisibleLines(config, newData, visibleSeries);
@@ -207,35 +223,27 @@ export function BigGraph({
// Register export functionality for the graph
useEffect(() => {
if (onRegisterForExport) {
// Unregister any previous registrations for this graph
if (onUnregisterFromExport) {
// Clear any existing registrations for this graph's series
normalizedSeries.forEach((_, index) => {
const seriesId = `${graphId}-series-${index}`;
onUnregisterFromExport(seriesId);
});
}
// Register each series as a separate export entry
normalizedSeries.forEach((series, index) => {
if (!series.newData) return;
normalizedSeries.forEach((_, index) => {
const seriesId = `${graphId}-series-${index}`;
const seriesTitle = series.title || `Series ${index + 1}`;
const getSeriesExportData = (): GraphExportData | null => {
const current = latestExportContextRef.current;
const series = current.normalizedSeries[index];
if (!series?.newData) return null;
// Create export data for this specific series only
const seriesLines = series.lines || [];
const exportConfig = {
...config,
title: config.title, // Keep original graph title
lines: [...(config.lines || []), ...seriesLines],
...current.config,
title: current.config.title, // Keep original graph title
lines: [...(current.config.lines || []), ...seriesLines],
};
// Create a single-series data structure for this series
const singleSeriesData: SeriesData = {
newData: series.newData,
title: seriesTitle,
title: series.title || `Series ${index + 1}`,
color: series.color,
lines: series.lines,
};
@@ -243,8 +251,8 @@ export function BigGraph({
return {
config: exportConfig,
data: singleSeriesData, // Return ONLY this specific series
unit,
renderValue,
unit: current.unit,
renderValue: current.renderValue,
};
};
@@ -265,19 +273,7 @@ export function BigGraph({
onRegisterForExport,
onUnregisterFromExport,
graphId,
config,
normalizedSeries.length,
unit,
renderValue,
// Add dependencies for series data
JSON.stringify(
normalizedSeries.map((s) => ({
title: s.title,
color: s.color,
hasData: !!s.newData,
linesCount: s.lines?.length || 0,
})),
),
]);
// Updates Y-axis scale dynamically based on visible data
@@ -1,318 +0,0 @@
import * as XLSX from "xlsx";
import { TimeSeries, seriesToUPlotData } from "@/lib/timeseries";
import { renderUnitSymbol, Unit } from "@/control/units";
import { GraphConfig, SeriesData, GraphLine } from "./types";
export type GraphExportData = {
config: GraphConfig;
data: SeriesData; // Always a single series
unit?: Unit;
renderValue?: (value: number) => string;
};
export function exportGraphsToExcel(
graphDataMap: Map<string, () => GraphExportData | null>,
groupId: string,
): void {
try {
// Filter out invalid series IDs (those without "-series-")
const filteredMap = new Map<string, () => GraphExportData | null>();
graphDataMap.forEach((getDataFn, seriesId) => {
if (seriesId.includes("-series-")) {
filteredMap.set(seriesId, getDataFn);
}
});
const workbook = XLSX.utils.book_new();
const exportTimestamp = new Date()
.toISOString()
.replace(/[:.]/g, "-")
.slice(0, 19);
const usedSheetNames = new Set<string>(); // Track unique sheet names
let processedCount = 0;
// Process each valid series
filteredMap.forEach((getDataFn, seriesId) => {
const exportData = getDataFn();
if (!exportData?.data?.newData) {
console.warn(`No data for series: ${seriesId}`);
return;
}
const series = exportData.data;
const seriesTitle = series.title || `Series ${processedCount + 1}`;
if (!series.newData) {
console.warn(`Series ${seriesTitle} has null data`);
return;
}
const targetLines: GraphLine[] = [
...(exportData.config.lines || []),
...(series.lines || []),
];
const graphLineData = {
graphTitle: exportData.config.title,
lineTitle: seriesTitle,
series: series.newData,
color: series.color,
unit: exportData.unit,
renderValue: exportData.renderValue,
config: exportData.config,
targetLines: targetLines,
};
// Create and append statistics sheet
const statsData = createGraphLineStatsSheet(graphLineData);
const statsWorksheet = XLSX.utils.aoa_to_sheet(statsData);
const statsSheetName = generateUniqueSheetName(
`${seriesTitle} Stats`,
usedSheetNames,
);
XLSX.utils.book_append_sheet(workbook, statsWorksheet, statsSheetName);
// Create and append data sheet
const dataRows = createGraphLineDataSheet(graphLineData);
if (dataRows.length > 0) {
const dataWorksheet = XLSX.utils.json_to_sheet(dataRows);
const dataSheetName = generateUniqueSheetName(
`${seriesTitle} Data`,
usedSheetNames,
);
XLSX.utils.book_append_sheet(workbook, dataWorksheet, dataSheetName);
}
processedCount++;
});
if (processedCount === 0) {
alert("No data available to export from any graphs in this group");
return;
}
const filename = `${groupId.toLowerCase().replace(/\s+/g, "_")}_export_${exportTimestamp}.xlsx`;
XLSX.writeFile(workbook, filename);
} catch (error) {
alert(
`Error exporting data to Excel: ${error instanceof Error ? error.message : "Unknown error"}. Please try again.`,
);
}
}
// Generate statistics sheet for a graph line
function createGraphLineStatsSheet(graphLine: {
graphTitle: string;
lineTitle: string;
series: TimeSeries;
color?: string;
unit?: Unit;
renderValue?: (value: number) => string;
config: GraphConfig;
targetLines: GraphLine[];
}): any[][] {
const [timestamps, values] = seriesToUPlotData(graphLine.series.long);
const unitSymbol = renderUnitSymbol(graphLine.unit) || "";
const statsData = [
[`Graph Line Statistics: ${graphLine.lineTitle}`, ""],
["Graph", graphLine.graphTitle],
["Line Name", graphLine.lineTitle],
["Line Color", graphLine.color || "Default"],
["Generated", new Date()],
["", ""],
["Data Points Information", ""],
["Total Data Points", timestamps.length.toString()],
];
if (timestamps.length > 0) {
statsData.push(["Time Range Start", new Date(timestamps[0])]);
statsData.push([
"Time Range End",
new Date(timestamps[timestamps.length - 1]),
]);
const duration = timestamps[timestamps.length - 1] - timestamps[0];
const durationHours = (duration / (1000 * 60 * 60)).toFixed(2);
statsData.push(["Duration (hours)", durationHours]);
if (values.length > 0) {
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const avgValue = values.reduce((a, b) => a + b, 0) / values.length;
const stdDev = Math.sqrt(
values.reduce((sum, val) => sum + Math.pow(val - avgValue, 2), 0) /
values.length,
);
statsData.push(["", ""], ["Value Statistics", ""]);
statsData.push([
`Minimum Value (${unitSymbol})`,
graphLine.renderValue
? graphLine.renderValue(minValue)
: minValue.toFixed(3),
]);
statsData.push([
`Maximum Value (${unitSymbol})`,
graphLine.renderValue
? graphLine.renderValue(maxValue)
: maxValue.toFixed(3),
]);
statsData.push([
`Average Value (${unitSymbol})`,
graphLine.renderValue
? graphLine.renderValue(avgValue)
: avgValue.toFixed(3),
]);
statsData.push([
`Standard Deviation (${unitSymbol})`,
graphLine.renderValue
? graphLine.renderValue(stdDev)
: stdDev.toFixed(3),
]);
statsData.push([
`Range (${unitSymbol})`,
graphLine.renderValue
? graphLine.renderValue(maxValue - minValue)
: (maxValue - minValue).toFixed(3),
]);
// Percentiles
const sortedValues = [...values].sort((a, b) => a - b);
const p25 = sortedValues[Math.floor(sortedValues.length * 0.25)];
const p50 = sortedValues[Math.floor(sortedValues.length * 0.5)];
const p75 = sortedValues[Math.floor(sortedValues.length * 0.75)];
statsData.push(["", ""], ["Percentiles", ""]);
statsData.push([
`25th Percentile (${unitSymbol})`,
graphLine.renderValue ? graphLine.renderValue(p25) : p25.toFixed(3),
]);
statsData.push([
`50th Percentile/Median (${unitSymbol})`,
graphLine.renderValue ? graphLine.renderValue(p50) : p50.toFixed(3),
]);
statsData.push([
`75th Percentile (${unitSymbol})`,
graphLine.renderValue ? graphLine.renderValue(p75) : p75.toFixed(3),
]);
}
}
// Add target line information
if (graphLine.targetLines.length > 0) {
statsData.push(["", ""], ["Target Lines", ""]);
graphLine.targetLines.forEach((line, index) => {
statsData.push([
`Target Line ${index + 1}`,
line.label || `Line ${line.value}`,
]);
statsData.push([
` Value (${unitSymbol})`,
graphLine.renderValue
? graphLine.renderValue(line.value)
: line.value.toFixed(3),
]);
statsData.push([` Type`, line.type || "reference"]);
statsData.push([` Color`, line.color || "default"]);
statsData.push([` Show`, line.show !== false ? "Yes" : "No"]);
if (line.type === "threshold" && values.length > 0) {
const withinThreshold = values.filter(
(val) => Math.abs(val - line.value) <= line.value * 0.05,
).length;
const percentageWithin = (
(withinThreshold / values.length) *
100
).toFixed(1);
statsData.push([
` Points Within Threshold (5%)`,
`${withinThreshold} (${percentageWithin}%)`,
]);
const differences = values.map((val) => Math.abs(val - line.value));
const minDifference = Math.min(...differences);
const maxDifference = Math.max(...differences);
statsData.push([
` Closest Approach (${unitSymbol})`,
graphLine.renderValue
? graphLine.renderValue(minDifference)
: minDifference.toFixed(3),
]);
statsData.push([
` Furthest Distance (${unitSymbol})`,
graphLine.renderValue
? graphLine.renderValue(maxDifference)
: maxDifference.toFixed(3),
]);
}
if (index < graphLine.targetLines.length - 1) {
statsData.push([""]);
}
});
} else {
statsData.push(["", ""], ["Target Lines", ""]);
statsData.push(["No target lines defined", ""]);
}
return statsData;
}
// Generate data sheet for a graph line
function createGraphLineDataSheet(graphLine: {
graphTitle: string;
lineTitle: string;
series: TimeSeries;
color?: string;
unit?: Unit;
renderValue?: (value: number) => string;
config: GraphConfig;
targetLines: GraphLine[];
}): any[] {
const [timestamps, values] = seriesToUPlotData(graphLine.series.long);
if (timestamps.length === 0) return [];
const unitSymbol = renderUnitSymbol(graphLine.unit) || "";
return timestamps.map((timestamp, index) => {
const value = values[index];
return {
Timestamp: new Date(timestamp),
[`Value (${unitSymbol})`]: graphLine.renderValue
? graphLine.renderValue(value)
: value?.toFixed(3) || "",
};
});
}
// Ensure sheet names are unique and valid for Excel
function generateUniqueSheetName(
name: string,
usedSheetNames: Set<string>,
): string {
let baseSheetName = name
.replace(/[\\/?*$:[\]]/g, "_") // Remove invalid characters
.substring(0, 31); // Excel sheet name limit
if (!baseSheetName || baseSheetName.trim().length === 0) {
baseSheetName = "Sheet";
}
let sheetName = baseSheetName;
let counter = 1;
while (usedSheetNames.has(sheetName)) {
const suffix = `_${counter}`;
const maxBaseLength = 31 - suffix.length;
sheetName = `${baseSheetName.substring(0, maxBaseLength)}${suffix}`;
counter++;
}
usedSheetNames.add(sheetName);
return sheetName;
}
@@ -0,0 +1,309 @@
import * as XLSX from "xlsx";
import { LogEntry } from "@/stores/logsStore";
import { ExportConfig, IExportConfig } from "./excelExportConfig";
import {
IValueFormatter,
ValueFormatter,
TimestampConverter,
ArrayUtils,
} from "./excelFormatters";
import { MetadataProviderFactory } from "./excelMetadata";
import { ChartAxisCalculator, ExcelCellSanitizer } from "./excelUtils";
import { CommentManager } from "./excelCommentManager";
import { CombinedSheetData, PidData } from "./excelExportTypes";
/**
* Creates the combined analysis sheet with all series data
*/
export class AnalysisSheetBuilder {
private config: IExportConfig;
private formatter: IValueFormatter;
constructor(
private allSheetData: CombinedSheetData[],
private groupId: string,
private logs: LogEntry[],
private pidData?: PidData,
config?: IExportConfig,
formatter?: IValueFormatter,
) {
this.config = config || new ExportConfig();
this.formatter = formatter || new ValueFormatter();
}
async build(): Promise<XLSX.WorkSheet> {
// Get sorted timestamps
const sortedTimestamps = this.getSortedTimestamps();
const startTime = sortedTimestamps[0];
const endTime = sortedTimestamps[sortedTimestamps.length - 1];
// Filter relevant comments
const relevantComments = CommentManager.filterRelevant(
this.logs,
startTime,
endTime,
);
// Build data by timestamp map
const dataByTimestamp = this.buildDataByTimestampMap();
// Build columns
const columns = this.buildColumns();
// Create sheet data array
const sheetData: any[][] = [];
// Add title row
const timeRangeTitle = this.formatter.formatTimeRange(startTime, endTime);
sheetData.push(
ArrayUtils.createRow(
[`${this.groupId} - ${timeRangeTitle}`],
columns.length,
),
);
sheetData.push(ArrayUtils.createEmptyArray(columns.length));
// Add target values row if applicable
this.addTargetValuesRow(sheetData, columns.length);
// Add header row
sheetData.push(columns);
// Add data rows
const dataStartRow = sheetData.length;
let maxSeconds = 0;
sortedTimestamps.forEach((timestamp) => {
const row = this.buildDataRow(
timestamp,
startTime,
dataByTimestamp,
relevantComments,
);
const secondsFromStart = TimestampConverter.toSecondsFromStart(
timestamp,
startTime,
);
maxSeconds = Math.max(maxSeconds, secondsFromStart);
sheetData.push(row);
});
// Add metadata
await this.addMetadata(sheetData, columns.length, relevantComments);
// Add chart instructions
this.addChartInstructions(
sheetData,
columns.length,
dataStartRow,
maxSeconds,
timeRangeTitle,
);
const sanitizedSheetData = sheetData.map((row) =>
ExcelCellSanitizer.sanitizeRow(row),
);
// Convert to worksheet
const worksheet = XLSX.utils.aoa_to_sheet(sanitizedSheetData);
// Configure worksheet
this.configureWorksheet(worksheet, columns.length);
return worksheet;
}
private getSortedTimestamps(): number[] {
const allTimestamps = new Set<number>();
this.allSheetData.forEach((data) => {
data.timestamps.forEach((ts) => allTimestamps.add(ts));
});
return Array.from(allTimestamps).sort((a, b) => a - b);
}
private buildDataByTimestampMap(): Map<number, Map<string, number>> {
const dataByTimestamp = new Map<number, Map<string, number>>();
this.allSheetData.forEach((sheetData) => {
sheetData.timestamps.forEach((ts, idx) => {
if (!dataByTimestamp.has(ts)) {
dataByTimestamp.set(ts, new Map());
}
dataByTimestamp
.get(ts)!
.set(sheetData.sheetName, sheetData.values[idx]);
});
});
return dataByTimestamp;
}
private buildColumns(): string[] {
const columns: string[] = ["Timestamp"];
const availableColumns = this.allSheetData.map((d) => d.sheetName);
columns.push(...availableColumns);
columns.push("User Comments");
return columns;
}
private addTargetValuesRow(sheetData: any[][], columnCount: number): void {
const targetValues: any[] = ["Target Values"];
let hasTargets = false;
this.allSheetData.forEach((sheetDataEntry) => {
if (sheetDataEntry.targetLines.length > 0) {
const targetLine = sheetDataEntry.targetLines.find(
(line) => line.type === "target",
);
if (targetLine) {
targetValues.push(this.formatter.formatNumber(targetLine.value));
hasTargets = true;
} else {
targetValues.push("");
}
} else {
targetValues.push("");
}
});
targetValues.push(""); // Empty for comments column
if (hasTargets) {
sheetData.push(targetValues);
sheetData.push(ArrayUtils.createEmptyArray(columnCount));
}
}
private buildDataRow(
timestamp: number,
startTime: number,
dataByTimestamp: Map<number, Map<string, number>>,
relevantComments: LogEntry[],
): any[] {
const row: any[] = [];
// Calculate seconds from start using TimestampConverter
const secondsFromStart = TimestampConverter.toSecondsFromStart(
timestamp,
startTime,
);
row.push(secondsFromStart);
// Add data for each column
this.allSheetData.forEach((sheetDataEntry) => {
const tsData = dataByTimestamp.get(timestamp);
if (tsData && tsData.has(sheetDataEntry.sheetName)) {
row.push(
this.sanitizeNumber(tsData.get(sheetDataEntry.sheetName) ?? NaN),
);
} else {
row.push("");
}
});
// Check for comments at this timestamp
const comment = CommentManager.findAtTimestamp(relevantComments, timestamp);
row.push(comment ? comment.message : "");
return row;
}
private sanitizeNumber(value: number): number | "" {
return Number.isFinite(value) ? value : "";
}
private async addMetadata(
sheetData: any[][],
columnCount: number,
relevantComments: LogEntry[],
): Promise<void> {
sheetData.push(ArrayUtils.createEmptyArray(columnCount));
sheetData.push(ArrayUtils.createEmptyArray(columnCount));
// Use MetadataProvider to build metadata
const metadataProvider = MetadataProviderFactory.createForExport({
softwareName: this.config.getSoftwareName(),
exportDate: this.formatter.formatDate(new Date()),
pidData: this.pidData,
commentCount: relevantComments.length,
});
sheetData.push(...metadataProvider.buildRows(columnCount));
}
private addChartInstructions(
sheetData: any[][],
columnCount: number,
dataStartRow: number,
maxSeconds: number,
timeRangeTitle: string,
): void {
// Calculate optimal Y-axis range from all data
const allValues = this.allSheetData.flatMap((sheet) => sheet.values);
const yAxisRange = ChartAxisCalculator.calculateOptimalRange(allValues);
const yAxisInstruction = ChartAxisCalculator.formatRangeInstruction(
yAxisRange.min,
yAxisRange.max,
);
sheetData.push(ArrayUtils.createEmptyArray(columnCount));
sheetData.push(ArrayUtils.createEmptyArray(columnCount));
sheetData.push(ArrayUtils.createRow(["Chart Instructions"], columnCount));
sheetData.push(
ArrayUtils.createRow(
[`1. Select all data from row ${dataStartRow} to the last data row`],
columnCount,
),
);
sheetData.push(
ArrayUtils.createRow(
["2. Insert > Chart > Scatter Chart with Straight Lines and Markers"],
columnCount,
),
);
sheetData.push(
ArrayUtils.createRow(
["3. X-axis: Time (seconds), Y-axis: All measurement columns"],
columnCount,
),
);
sheetData.push(
ArrayUtils.createRow(
[`4. Set X-axis range: 0 to ${maxSeconds}`],
columnCount,
),
);
sheetData.push(ArrayUtils.createRow([yAxisInstruction], columnCount));
sheetData.push(
ArrayUtils.createRow(["6. Position legend at bottom"], columnCount),
);
sheetData.push(
ArrayUtils.createRow(
[`7. Chart Title: ${this.groupId} - ${timeRangeTitle}`],
columnCount,
),
);
}
private configureWorksheet(
worksheet: XLSX.WorkSheet,
columnCount: number,
): void {
// Merge title cells
if (!worksheet["!merges"]) worksheet["!merges"] = [];
worksheet["!merges"].push({
s: { r: 0, c: 0 },
e: { r: 0, c: columnCount - 1 },
});
// Set column widths
const colWidths = [
{ wch: 12 }, // Timestamp
...this.allSheetData.map(() => ({ wch: 12 })),
{ wch: 40 }, // Comments
];
worksheet["!cols"] = colWidths;
}
}
@@ -0,0 +1,30 @@
import { LogEntry } from "@/stores/logsStore";
/**
* Filters and manages log comments for export
*/
export class CommentManager {
static filterRelevant(
logs: LogEntry[],
startTime: number,
endTime: number,
): LogEntry[] {
return logs.filter(
(log) =>
log.timestamp.getTime() >= startTime &&
log.timestamp.getTime() <= endTime &&
log.level === "info" &&
log.message.toLowerCase().includes("comment"),
);
}
static findAtTimestamp(
comments: LogEntry[],
timestamp: number,
tolerance: number = 1000,
): LogEntry | undefined {
return comments.find(
(log) => Math.abs(log.timestamp.getTime() - timestamp) < tolerance,
);
}
}
@@ -0,0 +1,178 @@
import * as XLSX from "xlsx";
import { TimeSeries, seriesToUPlotData } from "@/lib/timeseries";
import { renderUnitSymbol, Unit } from "@/control/units";
import { GraphConfig, GraphLine } from "../types";
import { IValueFormatter, ValueFormatter } from "./excelFormatters";
import { StatisticsCalculator } from "./excelStatisticsCalculator";
import { ExcelCellSanitizer } from "./excelUtils";
/**
* Creates individual data sheets for each series
*/
export class DataSheetBuilder {
private formatter: IValueFormatter;
constructor(
private graphLine: {
graphTitle: string;
lineTitle: string;
series: TimeSeries;
color?: string;
unit?: Unit;
renderValue?: (value: number) => string;
config: GraphConfig;
targetLines: GraphLine[];
},
private seriesTitle: string,
private unit: Unit | undefined,
formatter?: IValueFormatter,
) {
this.formatter = formatter || new ValueFormatter();
}
build(): XLSX.WorkSheet {
const [timestamps, values] = seriesToUPlotData(this.graphLine.series.long);
const unitSymbol = renderUnitSymbol(this.unit) || "";
const sheetData: any[][] = [];
// Build header
const col1Header = unitSymbol
? `${unitSymbol} ${this.seriesTitle}`
: this.seriesTitle;
sheetData.push(["Timestamp", col1Header, "", "", "Statistic", "Value"]);
// Build stats section
const statsRows = this.buildStatsRows(timestamps, values, unitSymbol);
// Combine data and stats rows
const maxRows = Math.max(timestamps.length, statsRows.length);
for (let i = 0; i < maxRows; i++) {
const row = this.buildDataRow(i, timestamps, values, statsRows);
sheetData.push(row);
}
const sanitizedSheetData = sheetData.map((row) =>
ExcelCellSanitizer.sanitizeRow(row),
);
// Convert to worksheet
const worksheet = XLSX.utils.aoa_to_sheet(sanitizedSheetData);
worksheet["!cols"] = [
{ wch: 20 }, // Timestamp
{ wch: 15 }, // Value
{ wch: 5 }, // Empty
{ wch: 5 }, // Empty
{ wch: 30 }, // Statistic name
{ wch: 20 }, // Statistic value
];
return worksheet;
}
private buildStatsRows(
timestamps: number[],
values: number[],
unitSymbol: string,
): string[][] {
const statsRows: string[][] = [];
statsRows.push(["Graph", this.graphLine.graphTitle]);
statsRows.push(["Line Name", this.graphLine.lineTitle]);
statsRows.push(["Line Color", this.graphLine.color || "Default"]);
statsRows.push(["Generated", this.formatter.formatDate(new Date())]);
statsRows.push(["", ""]);
statsRows.push(["Total Data Points", timestamps.length.toString()]);
if (timestamps.length > 0) {
const firstDate = new Date(timestamps[0]);
const lastDate = new Date(timestamps[timestamps.length - 1]);
statsRows.push([
"Time Range Start",
this.formatter.formatDate(firstDate),
]);
statsRows.push(["Time Range End", this.formatter.formatDate(lastDate)]);
const duration = timestamps[timestamps.length - 1] - timestamps[0];
const durationHours = this.formatter.formatDuration(duration);
statsRows.push(["Duration (hours)", durationHours]);
if (values.length > 0) {
const stats = StatisticsCalculator.calculate(values);
statsRows.push(["", ""]);
statsRows.push([
`Minimum Value (${unitSymbol})`,
this.formatValue(stats.min),
]);
statsRows.push([
`Maximum Value (${unitSymbol})`,
this.formatValue(stats.max),
]);
statsRows.push([
`Average Value (${unitSymbol})`,
this.formatValue(stats.avg),
]);
statsRows.push([
`Standard Deviation (${unitSymbol})`,
this.formatValue(stats.stdDev),
]);
statsRows.push([
`Range (${unitSymbol})`,
this.formatValue(stats.range),
]);
statsRows.push(["", ""]);
statsRows.push([
`25th Percentile (${unitSymbol})`,
this.formatValue(stats.p25),
]);
statsRows.push([
`50th Percentile (${unitSymbol})`,
this.formatValue(stats.p50),
]);
statsRows.push([
`75th Percentile (${unitSymbol})`,
this.formatValue(stats.p75),
]);
}
}
return statsRows;
}
private buildDataRow(
index: number,
timestamps: number[],
values: number[],
statsRows: string[][],
): any[] {
const row: any[] = ["", "", "", ""];
// Add timestamp and value data
if (index < timestamps.length) {
const date = new Date(timestamps[index]);
row[0] = this.formatter.formatDate(date);
row[1] = this.formatValue(values[index]);
}
// Add stats
if (index < statsRows.length) {
row[4] = statsRows[index][0];
row[5] = statsRows[index][1];
} else {
row[4] = "";
row[5] = "";
}
return row;
}
private formatValue(value: number): string {
return this.graphLine.renderValue
? this.graphLine.renderValue(value)
: this.formatter.formatNumber(value);
}
}
@@ -0,0 +1,21 @@
import { ValueFormatter } from "./excelFormatters";
/**
* Utility class for date/time formatting and manipulation
* Now uses ValueFormatter internally
*/
export class DateFormatter {
private static formatter = new ValueFormatter();
static format(date: Date): string {
return this.formatter.formatDate(date);
}
static getExportTimestamp(): string {
return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
}
static formatTimeRange(startTime: number, endTime: number): string {
return this.formatter.formatTimeRange(startTime, endTime);
}
}
@@ -0,0 +1,197 @@
import * as XLSX from "xlsx";
import { seriesToUPlotData } from "@/lib/timeseries";
import { renderUnitSymbol } from "@/control/units";
import { GraphLine } from "../types";
import { LogEntry } from "@/stores/logsStore";
import { ExportConfig, IExportConfig } from "./excelExportConfig";
import { IValueFormatter, ValueFormatter } from "./excelFormatters";
import { ExcelCellSanitizer, IPidDataProvider } from "./excelUtils";
import {
CombinedSheetData,
GraphExportData,
PidData,
} from "./excelExportTypes";
import { DateFormatter } from "./excelDateFormatter";
import { SheetNameManager } from "./excelSheetNameManager";
import { DataSheetBuilder } from "./excelDataSheetBuilder";
import { AnalysisSheetBuilder } from "./excelAnalysisSheetBuilder";
export type { GraphExportData, PidSettings, PidData } from "./excelExportTypes";
/**
* Main orchestrator for Excel export functionality
*/
export class ExcelExporter {
private config: IExportConfig;
private formatter: IValueFormatter;
private sheetNameManager: SheetNameManager;
private pidDataProvider?: IPidDataProvider;
constructor(
config?: IExportConfig,
formatter?: IValueFormatter,
pidDataProvider?: IPidDataProvider,
) {
this.config = config || new ExportConfig();
this.formatter = formatter || new ValueFormatter();
this.sheetNameManager = new SheetNameManager(this.config);
this.pidDataProvider = pidDataProvider;
}
async export(
graphDataMap: Map<string, () => GraphExportData | null>,
groupId: string,
logs: LogEntry[] = [],
pidData?: PidData,
): Promise<void> {
try {
// If PID data provider is available and no PID data provided, fetch it
if (!pidData && this.pidDataProvider) {
pidData = (await this.pidDataProvider.fetchPidSettings()) || undefined;
}
const filteredMap = this.filterValidSeries(graphDataMap);
const workbook = XLSX.utils.book_new();
const exportTimestamp = DateFormatter.getExportTimestamp();
const allSheetData: CombinedSheetData[] = [];
// Process each series
filteredMap.forEach((getDataFn) => {
const exportData = getDataFn();
if (!exportData?.data?.newData) return;
const series = exportData.data;
const seriesTitle = series.title || "Series";
// Ensure newData is not null before proceeding
if (!series.newData) return;
const sheetName = this.sheetNameManager.generate(
exportData.config.title,
seriesTitle,
exportData.unit,
);
const targetLines: GraphLine[] = [
...(exportData.config.lines || []),
...(series.lines || []),
];
// Create data sheet
const dataSheetBuilder = new DataSheetBuilder(
{
graphTitle: exportData.config.title,
lineTitle: seriesTitle,
series: series.newData,
color: series.color,
unit: exportData.unit,
renderValue: exportData.renderValue,
config: exportData.config,
targetLines,
},
seriesTitle,
exportData.unit,
this.formatter,
);
const worksheet = dataSheetBuilder.build();
ExcelCellSanitizer.sanitizeWorksheet(worksheet);
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
// Collect data for analysis sheet
const [timestamps, values] = seriesToUPlotData(series.newData.long);
allSheetData.push({
sheetName,
timestamps,
values,
unit: renderUnitSymbol(exportData.unit) || "",
seriesTitle,
graphTitle: exportData.config.title,
targetLines,
color: series.color,
});
});
if (allSheetData.length === 0) {
alert("No data available to export from any graphs in this group");
return;
}
// Create analysis sheet
const analysisSheetBuilder = new AnalysisSheetBuilder(
allSheetData,
groupId,
logs,
pidData,
this.config,
this.formatter,
);
const analysisSheet = await analysisSheetBuilder.build();
ExcelCellSanitizer.sanitizeWorksheet(analysisSheet);
XLSX.utils.book_append_sheet(workbook, analysisSheet, "Analysis");
const xlsxBuffer = XLSX.write(workbook, {
type: "buffer",
bookType: "xlsx",
});
this.triggerDownload(
xlsxBuffer,
groupId,
DateFormatter.getExportTimestamp(),
);
} catch (error) {
alert(
`Error exporting data to Excel: ${
error instanceof Error ? error.message : "Unknown error"
}. Please try again.`,
);
}
}
private filterValidSeries(
graphDataMap: Map<string, () => GraphExportData | null>,
): Map<string, () => GraphExportData | null> {
const filteredMap = new Map<string, () => GraphExportData | null>();
graphDataMap.forEach((getDataFn, seriesId) => {
if (seriesId.includes("-series-")) {
filteredMap.set(seriesId, getDataFn);
}
});
return filteredMap;
}
private triggerDownload(
buffer: ArrayBuffer,
groupId: string,
exportTimestamp: string,
): void {
const filename = `${groupId
.toLowerCase()
.replace(/\s+/g, "_")}_export_${exportTimestamp}.xlsx`;
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
}
}
/**
* Convenience function to maintain backward compatibility with existing code
*/
export async function exportGraphsToExcel(
graphDataMap: Map<string, () => GraphExportData | null>,
groupId: string,
logs: LogEntry[] = [],
pidData?: PidData,
): Promise<void> {
const exporter = new ExcelExporter();
await exporter.export(graphDataMap, groupId, logs, pidData);
}
@@ -0,0 +1,107 @@
/**
* Configuration system for Excel Export
* Centralizes all hardcoded values and provides type-safe access
* Follows Dependency Inversion Principle - depends on abstractions
*/
export interface IExportConfig {
getSoftwareName(): string;
getDefaultPrecision(): number;
getUnitFriendlyName(unit: string): string | undefined;
getDefaultChartColor(): string;
getDateLocale(): string;
getYAxisRange(): { min: number; max: number } | null; // null means auto-scale
}
/**
* Default implementation that can be extended or replaced
*/
export class ExportConfig implements IExportConfig {
private readonly config = {
softwareName: "QiTech Control",
defaultPrecision: 3,
unitFriendlyNames: {
"°C": "Temp",
W: "Watt",
A: "Ampere",
bar: "Bar",
rpm: "Rpm",
"1/min": "Rpm",
mm: "mm",
"%": "Percent",
} as Record<string, string>,
defaultChartColor: "#9b59b6",
dateLocale: "de-DE",
// null means auto-scale based on data
yAxisRange: null as { min: number; max: number } | null,
};
getSoftwareName(): string {
return this.config.softwareName;
}
getDefaultPrecision(): number {
return this.config.defaultPrecision;
}
getUnitFriendlyName(unit: string): string | undefined {
return this.config.unitFriendlyNames[unit];
}
getDefaultChartColor(): string {
return this.config.defaultChartColor;
}
getDateLocale(): string {
return this.config.dateLocale;
}
getYAxisRange(): { min: number; max: number } | null {
return this.config.yAxisRange;
}
/**
* Allow runtime configuration updates
*/
setYAxisRange(min: number, max: number): void {
this.config.yAxisRange = { min, max };
}
/**
* Add new unit mapping at runtime
*/
addUnitFriendlyName(unit: string, friendlyName: string): void {
this.config.unitFriendlyNames[unit] = friendlyName;
}
}
/**
* Machine-aware configuration that fetches data from machine context
* This can be extended to fetch PID settings, machine-specific units, etc.
*/
export class MachineAwareExportConfig extends ExportConfig {
constructor(private machineContext?: any) {
super();
}
/**
* Override to get software name from machine context if available
*/
override getSoftwareName(): string {
// Could fetch from machine context in future
return this.machineContext?.softwareName ?? super.getSoftwareName();
}
}
/**
* Factory for creating configuration instances
* Follows Abstract Factory Pattern
*/
export class ExportConfigFactory {
static create(machineContext?: any): IExportConfig {
if (machineContext) {
return new MachineAwareExportConfig(machineContext);
}
return new ExportConfig();
}
}
@@ -0,0 +1,35 @@
import { Unit } from "@/control/units";
import { GraphConfig, SeriesData, GraphLine } from "../types";
/**
* Type definitions for export data structures
*/
export type GraphExportData = {
config: GraphConfig;
data: SeriesData;
unit?: Unit;
renderValue?: (value: number) => string;
};
export type PidSettings = {
kp: number;
ki: number;
kd: number;
zone?: string; // For temperature zones (front, middle, back, nozzle)
};
export type PidData = {
temperature?: Record<string, PidSettings>; // keyed by zone
pressure?: PidSettings;
};
export type CombinedSheetData = {
sheetName: string;
timestamps: number[];
values: number[];
unit: string;
seriesTitle: string;
graphTitle: string;
targetLines: GraphLine[];
color?: string;
};
@@ -0,0 +1,120 @@
/**
* Value formatting utilities following Strategy Pattern
* Centralizes all formatting logic to eliminate duplication
*/
export interface IValueFormatter {
formatNumber(value: number): string;
formatDate(date: Date): string;
formatTimeRange(startTime: number, endTime: number): string;
formatDuration(milliseconds: number): string;
}
/**
* Default formatter implementation
*/
export class ValueFormatter implements IValueFormatter {
constructor(
private precision: number = 3,
private locale: string = "de-DE",
) {}
formatNumber(value: number): string {
if (value == null || isNaN(value)) {
return "";
}
return value.toFixed(this.precision);
}
formatDate(date: Date): string {
return date.toLocaleString(this.locale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
formatTimeRange(startTime: number, endTime: number): string {
const startDate = new Date(startTime);
const endDate = new Date(endTime);
return `${this.formatDate(startDate)} bis ${this.formatDate(endDate)}`;
}
formatDuration(milliseconds: number): string {
const hours = (milliseconds / (1000 * 60 * 60)).toFixed(2);
return hours;
}
setPrecision(precision: number): void {
this.precision = precision;
}
setLocale(locale: string): void {
this.locale = locale;
}
}
/**
* Custom formatter that can use a render function
*/
export class CustomValueFormatter extends ValueFormatter {
constructor(
private customRenderFn?: (value: number) => string,
precision?: number,
locale?: string,
) {
super(precision, locale);
}
override formatNumber(value: number): string {
if (this.customRenderFn) {
return this.customRenderFn(value);
}
return super.formatNumber(value);
}
}
/**
* Timestamp utilities to eliminate duplicate conversion logic
*/
export class TimestampConverter {
/**
* Convert timestamp to seconds from start
*/
static toSecondsFromStart(timestamp: number, startTime: number): number {
return Math.floor((timestamp - startTime) / 1000);
}
/**
* Convert multiple timestamps to seconds from start
*/
static arrayToSecondsFromStart(
timestamps: number[],
startTime: number,
): number[] {
return timestamps.map((ts) => this.toSecondsFromStart(ts, startTime));
}
}
/**
* Array utilities to eliminate duplicate fill patterns
*/
export class ArrayUtils {
/**
* Create an array filled with empty strings
*/
static createEmptyArray(count: number): string[] {
return Array(count).fill("");
}
/**
* Create a row with initial values and fill rest with empty strings
*/
static createRow(initialValues: any[], totalColumns: number): any[] {
const emptyCount = Math.max(0, totalColumns - initialValues.length);
return [...initialValues, ...this.createEmptyArray(emptyCount)];
}
}
@@ -0,0 +1,188 @@
/**
* Metadata provider system following Open/Closed Principle
* New metadata sections can be added without modifying existing code
*/
export interface IMetadataSection {
getTitle(): string;
getRows(): Array<{ key: string; value: string }>;
}
/**
* Base metadata section implementation
*/
export abstract class MetadataSection implements IMetadataSection {
abstract getTitle(): string;
abstract getRows(): Array<{ key: string; value: string }>;
}
/**
* Export information section
*/
export class ExportInfoSection extends MetadataSection {
constructor(
private softwareName: string,
private exportDate: string,
) {
super();
}
getTitle(): string {
return "Export Information";
}
getRows(): Array<{ key: string; value: string }> {
return [
{ key: "Software", value: this.softwareName },
{ key: "Export Date", value: this.exportDate },
];
}
}
/**
* PID settings section for temperature controllers
*/
export class TemperaturePidSection extends MetadataSection {
constructor(
private pidSettings: Record<string, { kp: number; ki: number; kd: number }>,
) {
super();
}
getTitle(): string {
return "Temperature Controllers";
}
getRows(): Array<{ key: string; value: string }> {
const rows: Array<{ key: string; value: string }> = [];
Object.entries(this.pidSettings).forEach(([zone, settings]) => {
rows.push(
{ key: ` ${zone} - Kp`, value: settings.kp.toFixed(3) },
{ key: ` ${zone} - Ki`, value: settings.ki.toFixed(3) },
{ key: ` ${zone} - Kd`, value: settings.kd.toFixed(3) },
);
});
return rows;
}
}
/**
* PID settings section for pressure controller
*/
export class PressurePidSection extends MetadataSection {
constructor(private pidSettings: { kp: number; ki: number; kd: number }) {
super();
}
getTitle(): string {
return "Pressure Controller";
}
getRows(): Array<{ key: string; value: string }> {
return [
{ key: " Kp", value: this.pidSettings.kp.toFixed(3) },
{ key: " Ki", value: this.pidSettings.ki.toFixed(3) },
{ key: " Kd", value: this.pidSettings.kd.toFixed(3) },
];
}
}
/**
* Comment statistics section
*/
export class CommentStatsSection extends MetadataSection {
constructor(private commentCount: number) {
super();
}
getTitle(): string {
return "Comment Statistics";
}
getRows(): Array<{ key: string; value: string }> {
return [{ key: "Total Comments", value: this.commentCount.toString() }];
}
}
/**
* Metadata provider that aggregates multiple sections
* Follows Composite Pattern
*/
export class MetadataProvider {
private sections: IMetadataSection[] = [];
addSection(section: IMetadataSection): this {
this.sections.push(section);
return this;
}
getSections(): IMetadataSection[] {
return this.sections;
}
/**
* Build metadata rows for Excel sheet
*/
buildRows(columnCount: number): string[][] {
const rows: string[][] = [];
this.sections.forEach((section, index) => {
// Add empty row before each section except the first
if (index > 0) {
rows.push(Array(columnCount).fill(""));
}
// Add section title
rows.push([section.getTitle(), ...Array(columnCount - 1).fill("")]);
// Add section rows
section.getRows().forEach((row) => {
rows.push([row.key, row.value, ...Array(columnCount - 2).fill("")]);
});
});
return rows;
}
}
/**
* Factory for creating metadata providers
*/
export class MetadataProviderFactory {
static createForExport(params: {
softwareName: string;
exportDate: string;
pidData?: {
temperature?: Record<string, { kp: number; ki: number; kd: number }>;
pressure?: { kp: number; ki: number; kd: number };
};
commentCount?: number;
}): MetadataProvider {
const provider = new MetadataProvider();
// Always add export info
provider.addSection(
new ExportInfoSection(params.softwareName, params.exportDate),
);
// Add PID sections if available
if (params.pidData?.temperature) {
provider.addSection(
new TemperaturePidSection(params.pidData.temperature),
);
}
if (params.pidData?.pressure) {
provider.addSection(new PressurePidSection(params.pidData.pressure));
}
// Add comment stats if provided
if (params.commentCount !== undefined) {
provider.addSection(new CommentStatsSection(params.commentCount));
}
return provider;
}
}
@@ -0,0 +1,52 @@
import { IExportConfig } from "./excelExportConfig";
import { IValueFormatter } from "./excelFormatters";
import { MetadataProvider, MetadataProviderFactory } from "./excelMetadata";
import { PidData } from "./excelExportTypes";
/**
* Builds metadata sections for Excel sheets
* Now uses MetadataProvider for better separation of concerns
* @deprecated Use MetadataProvider directly for new code
*/
export class MetadataBuilder {
private metadataProvider: MetadataProvider;
private config: IExportConfig;
private formatter: IValueFormatter;
constructor(config: IExportConfig, formatter: IValueFormatter) {
this.config = config;
this.formatter = formatter;
this.metadataProvider = new MetadataProvider();
}
addExportInfo(columnCount: number): this {
// This method is kept for backward compatibility
// Actual data is added when building final rows
return this;
}
addPidSettings(pidData: PidData | undefined, columnCount: number): this {
// This method is kept for backward compatibility
// Actual data is added when building final rows
return this;
}
/**
* Build metadata rows using MetadataProvider
*/
getRows(
columnCount: number,
pidData?: PidData,
commentCount?: number,
): string[][] {
// Create metadata provider with all sections
const provider = MetadataProviderFactory.createForExport({
softwareName: this.config.getSoftwareName(),
exportDate: this.formatter.formatDate(new Date()),
pidData: pidData,
commentCount: commentCount,
});
return provider.buildRows(columnCount);
}
}
@@ -0,0 +1,63 @@
import { renderUnitSymbol, Unit } from "@/control/units";
import { IExportConfig } from "./excelExportConfig";
/**
* Manages unique sheet name generation for Excel workbooks
* Now uses IExportConfig for unit mappings
*/
export class SheetNameManager {
private usedNames = new Set<string>();
constructor(private config: IExportConfig) {}
generate(
graphTitle: string,
seriesTitle: string,
unit: Unit | undefined,
): string {
const unitSymbol = renderUnitSymbol(unit) || "";
let sheetName = "";
// Use unit-based name for generic series, otherwise use series title
if (/^Series \d+$/i.test(seriesTitle)) {
const friendlyUnitName = this.config.getUnitFriendlyName(unitSymbol);
sheetName = friendlyUnitName || seriesTitle;
} else {
const friendlyUnitName = this.config.getUnitFriendlyName(unitSymbol);
if (
friendlyUnitName &&
!seriesTitle.toLowerCase().includes(friendlyUnitName.toLowerCase())
) {
sheetName = `${seriesTitle} ${friendlyUnitName}`;
} else {
sheetName = seriesTitle;
}
}
return this.makeUnique(this.sanitize(sheetName));
}
private sanitize(name: string): string {
return (
name
.replace(/[\\/?*$:[\]]/g, "_")
.substring(0, 31)
.trim() || "Sheet"
);
}
private makeUnique(name: string): string {
let finalName = name;
let counter = 1;
while (this.usedNames.has(finalName)) {
const suffix = `_${counter}`;
const maxBaseLength = 31 - suffix.length;
finalName = `${name.substring(0, maxBaseLength)}${suffix}`;
counter++;
}
this.usedNames.add(finalName);
return finalName;
}
}
@@ -0,0 +1,35 @@
/**
* Handles statistical calculations for time series data
*/
export class StatisticsCalculator {
static calculate(values: number[]): {
min: number;
max: number;
avg: number;
stdDev: number;
range: number;
p25: number;
p50: number;
p75: number;
} {
if (values.length === 0) {
throw new Error("Cannot calculate statistics for empty array");
}
const min = Math.min(...values);
const max = Math.max(...values);
const avg = values.reduce((a, b) => a + b, 0) / values.length;
const stdDev = Math.sqrt(
values.reduce((sum, val) => sum + Math.pow(val - avg, 2), 0) /
values.length,
);
const range = max - min;
const sortedValues = [...values].sort((a, b) => a - b);
const p25 = sortedValues[Math.floor(sortedValues.length * 0.25)];
const p50 = sortedValues[Math.floor(sortedValues.length * 0.5)];
const p75 = sortedValues[Math.floor(sortedValues.length * 0.75)];
return { min, max, avg, stdDev, range, p25, p50, p75 };
}
}
@@ -0,0 +1,191 @@
/**
* Additional utility classes for Excel export
* Following SOLID principles and DRY
*/
/**
* Utility for calculating optimal Y-axis range based on data
* Can be extended for more sophisticated scaling algorithms
*/
export class ChartAxisCalculator {
/**
* Calculate optimal Y-axis range with padding
*/
static calculateOptimalRange(
values: number[],
paddingPercent: number = 10,
): { min: number; max: number } {
if (values.length === 0) {
return { min: 0, max: 1000 }; // fallback
}
const dataMin = Math.min(...values);
const dataMax = Math.max(...values);
const range = dataMax - dataMin;
// Add padding
const padding = range * (paddingPercent / 100);
return {
min: Math.floor(dataMin - padding),
max: Math.ceil(dataMax + padding),
};
}
/**
* Format Y-axis range instruction for Excel
*/
static formatRangeInstruction(min: number, max: number): string {
return `5. Set Y-axis range: ${min} to ${max}`;
}
}
import * as XLSX from "xlsx";
/**
* Sanitizes cell values to avoid invalid XML in XLSX exports.
*/
export class ExcelCellSanitizer {
/* eslint-disable no-control-regex */
private static invalidXmlChars =
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\uD800-\uDFFF\uFFFE\uFFFF]/g;
/* eslint-enable no-control-regex */
static sanitizeCell(value: unknown): string | number {
if (typeof value === "number") {
return Number.isFinite(value) ? value : "";
}
if (typeof value === "boolean") {
return value ? 1 : 0;
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === "string") {
return value.replace(this.invalidXmlChars, "");
}
if (value === null || value === undefined) {
return "";
}
return String(value).replace(this.invalidXmlChars, "");
}
static sanitizeRow(row: unknown[]): Array<string | number> {
return row.map((value) => this.sanitizeCell(value));
}
static sanitizeWorksheet(worksheet: XLSX.WorkSheet): void {
Object.keys(worksheet).forEach((key) => {
if (key.startsWith("!")) return;
const cell = worksheet[key] as XLSX.CellObject | undefined;
if (!cell) return;
const sanitized = this.sanitizeCell(cell.v);
if (sanitized === "") {
delete worksheet[key];
return;
}
cell.v = sanitized as any;
});
}
}
/**
* Interface for fetching machine PID settings
*/
export interface IPidDataProvider {
fetchPidSettings(): Promise<{
temperature?: Record<string, { kp: number; ki: number; kd: number }>;
pressure?: { kp: number; ki: number; kd: number };
} | null>;
}
/**
* Provider that fetches PID settings from machine API
* Implements Dependency Inversion Principle
*/
export class MachinePidDataProvider implements IPidDataProvider {
constructor(
private baseUrl: string = "http://10.10.10.1:3001",
private machineSlug?: string,
private machineSerial?: number,
) {}
async fetchPidSettings(): Promise<{
temperature?: Record<string, { kp: number; ki: number; kd: number }>;
pressure?: { kp: number; ki: number; kd: number };
} | null> {
try {
// If machine slug/serial provided, fetch from specific machine
if (this.machineSlug && this.machineSerial !== undefined) {
const response = await fetch(
`${this.baseUrl}/api/v2/machine/${this.machineSlug}/${this.machineSerial}`,
);
if (!response.ok) {
console.warn("Failed to fetch machine data for PID settings");
return null;
}
const data = await response.json();
// Extract PID settings from machine data
// This is a placeholder - actual implementation depends on machine API structure
return this.extractPidFromMachineData(data);
}
// Otherwise, return null (PID data should be passed in)
return null;
} catch (error) {
console.error("Error fetching PID settings from machine:", error);
return null;
}
}
private extractPidFromMachineData(machineData: any): {
temperature?: Record<string, { kp: number; ki: number; kd: number }>;
pressure?: { kp: number; ki: number; kd: number };
} | null {
// This is a placeholder implementation
// Actual extraction depends on machine API structure
// TODO: Implement based on actual machine data structure
const pidSettings: any = {};
// Example: Look for PID-related fields in machine data
if (machineData.temperature_controllers) {
pidSettings.temperature = machineData.temperature_controllers;
}
if (machineData.pressure_controller) {
pidSettings.pressure = machineData.pressure_controller;
}
return Object.keys(pidSettings).length > 0 ? pidSettings : null;
}
}
/**
* Mock provider for testing
*/
export class MockPidDataProvider implements IPidDataProvider {
constructor(
private mockData: {
temperature?: Record<string, { kp: number; ki: number; kd: number }>;
pressure?: { kp: number; ki: number; kd: number };
} | null,
) {}
async fetchPidSettings(): Promise<{
temperature?: Record<string, { kp: number; ki: number; kd: number }>;
pressure?: { kp: number; ki: number; kd: number };
} | null> {
return Promise.resolve(this.mockData);
}
}
@@ -0,0 +1,2 @@
// Version info rendering removed (YAGNI).
export {};
@@ -0,0 +1,2 @@
export { exportGraphsToExcel } from "./excelExport";
export type { GraphExportData } from "./excelExport";
@@ -1,6 +1,7 @@
import { useState, useCallback, useRef } from "react";
import { PropGraphSync, SwitchOrigin } from "./types";
import { GraphExportData, exportGraphsToExcel } from "./excelExport";
import { useLogsStore } from "@/stores/logsStore";
import { useGraphSettingsStore } from "@/stores/graphSettingsStore";
export function useGraphSync(exportGroupId?: string) {
@@ -132,7 +133,14 @@ export function useGraphSync(exportGroupId?: string) {
console.warn("No graphs registered for export");
return;
}
exportGraphsToExcel(graphDataRef.current, exportGroupId || "synced-graphs");
const logs = useLogsStore.getState().entries;
exportGraphsToExcel(
graphDataRef.current,
exportGroupId || "synced-graphs",
logs,
).catch((error) => {
console.error("Failed to export graphs:", error);
});
}, [exportGroupId]);
const handleTimeWindowChange = useCallback(
+1 -1
View File
@@ -132,7 +132,7 @@ export function usePresets<T>({
if (latestPresetId === undefined) {
const preset = store.insert({
id: undefined,
name: "Latest Machine Stettings",
name: "Latest Machine Settings",
machineIdentification: machine_identification,
lastModified: new Date(),
schemaVersion,
+3 -11
View File
@@ -4,7 +4,6 @@ import { SectionTitle } from "@/components/SectionTitle";
import { Terminal } from "@/components/Terminal";
import { TouchButton } from "@/components/touch/TouchButton";
import { UpdateProgressBar } from "@/components/UpdateProgressBar";
import { Icon } from "@/components/Icon";
import { updateExecute, updateCancelWithStore } from "@/helpers/update_helpers";
import { useUpdateStore } from "@/stores/updateStore";
import { useSearch } from "@tanstack/react-router";
@@ -89,7 +88,7 @@ export function UpdateExecutePage() {
<Page>
<SectionTitle title="Apply Update" />
<div className="flex flex-row items-center gap-4">
<div className="flex flex-row gap-4">
<TouchButton
className="w-max"
icon="lu:CircleFadingArrowUp"
@@ -109,14 +108,6 @@ export function UpdateExecutePage() {
Cancel Update
</TouchButton>
)}
{isUpdating && (
<div className="ml-auto flex items-center gap-2 rounded-xl border border-blue-400 bg-blue-600 px-4 py-2.5 text-white shadow-xl backdrop-blur-sm transition-all duration-300">
<Icon name="lu:Info" className="h-5 w-5 text-blue-100" />
<span className="text-base leading-snug text-blue-50">
Updates typically take approximately <strong>5 minutes</strong>
</span>
</div>
)}
</div>
{currentUpdateInfo && (
<Alert title="Update Information" variant="info">
@@ -145,7 +136,8 @@ export function UpdateExecutePage() {
</div>
<div className="text-sm">
Please stay connected to the internet and leave the power on. The
machine will reboot after the update completes.
update procedure takes a couple of minutes and reboots the machine
afterwards.
</div>
</div>
</Alert>
+1 -1
View File
@@ -10,7 +10,7 @@ buildNpmPackage rec {
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
makeCacheWritable = true;
npmDepsHash = "sha256-jW9nogboiuYBzjLNM5rlYsdfp+58OlDf+kxjgNlST70=";
npmDepsHash = "sha256-Asa8lfMXGUvGkudj0DXQk9VRQ8ugmmP969t7vcaTE0E=";
npmFlags = [ "--no-audit" "--no-fund" ];
installPhase = ''