diff --git a/package-lock.json b/package-lock.json index 9d2fb4d..06be926 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "chart.js": "^4.5.0", "chartjs-plugin-zoom": "^2.2.0", "i18next": "^25.4.2", + "jspdf": "^2.5.1", "lucide-react": "^0.522.0", "postcss": "^8.5.6", "react": "^19.1.0", @@ -1836,6 +1837,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", @@ -2157,6 +2165,18 @@ "dev": true, "license": "MIT" }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -2200,6 +2220,16 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2267,6 +2297,18 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2330,6 +2372,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chai": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", @@ -2501,6 +2563,18 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.45.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.1.tgz", + "integrity": "sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2515,6 +2589,16 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -2671,6 +2755,13 @@ "license": "MIT", "peer": true }, + "node_modules/dompurify": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz", + "integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3099,6 +3190,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3481,6 +3578,20 @@ "void-elements": "3.1.0" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3907,6 +4018,24 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz", + "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "atob": "^2.1.2", + "btoa": "^1.2.1", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.6", + "core-js": "^3.6.0", + "dompurify": "^2.5.4", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4526,6 +4655,13 @@ "node": "*" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4812,6 +4948,16 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -4921,6 +5067,13 @@ "node": ">=8" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -4968,6 +5121,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.44.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz", @@ -5130,6 +5293,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/std-env": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", @@ -5339,6 +5512,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -5420,6 +5603,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -5657,6 +5850,16 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", diff --git a/package.json b/package.json index 693739c..ffae6fe 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "chartjs-plugin-zoom": "^2.2.0", "i18next": "^25.4.2", "lucide-react": "^0.522.0", + "jspdf": "^2.5.1", "postcss": "^8.5.6", "react": "^19.1.0", "react-chartjs-2": "^5.3.0", diff --git a/src/components/ChartContainer.jsx b/src/components/ChartContainer.jsx index 0e84f03..9bb8de6 100644 --- a/src/components/ChartContainer.jsx +++ b/src/components/ChartContainer.jsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef, useCallback, useEffect } from 'react'; +import React, { useMemo, useRef, useCallback, useEffect, useState } from 'react'; import { Line } from 'react-chartjs-2'; import { ResizablePanel } from './ResizablePanel'; import { @@ -13,7 +13,8 @@ import { Legend, } from 'chart.js'; import zoomPlugin from 'chartjs-plugin-zoom'; -import { ImageDown, Copy, FileDown } from 'lucide-react'; +import { Copy, FileDown } from 'lucide-react'; +import jsPDF from 'jspdf'; import { getMinSteps } from "../utils/getMinSteps.js"; import { useTranslation } from 'react-i18next'; @@ -102,14 +103,86 @@ export default function ChartContainer({ const chartRefs = useRef(new Map()); const { t } = useTranslation(); const syncLockRef = useRef(false); + const [smoothing, setSmoothing] = useState({ method: 'none', window: 5 }); + const [exportFormat, setExportFormat] = useState('png'); + const registerChart = useCallback((id, inst) => { chartRefs.current.set(id, inst); }, []); - const exportChartPNG = useCallback((id) => { + const exportChart = useCallback((id, format) => { const chart = chartRefs.current.get(id); if (!chart) return; + + const datasets = chart.data.datasets || []; + const xValues = new Set(); + datasets.forEach(ds => { + (ds.data || []).forEach(p => xValues.add(p.x)); + }); + const sortedX = Array.from(xValues).sort((a, b) => a - b); + + if (format === 'csv') { + const header = ['step', ...datasets.map(ds => ds.label || '')]; + const rows = sortedX.map(x => { + const cols = [x]; + datasets.forEach(ds => { + const pt = (ds.data || []).find(p => p.x === x); + cols.push(pt ? pt.y : ''); + }); + return cols.join(','); + }); + const csv = [header.join(','), ...rows].join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${id}.csv`; + link.click(); + URL.revokeObjectURL(url); + return; + } + + if (format === 'json') { + const jsonData = sortedX.map(x => { + const entry = { step: x }; + datasets.forEach(ds => { + const pt = (ds.data || []).find(p => p.x === x); + entry[ds.label || ''] = pt ? pt.y : null; + }); + return entry; + }); + const blob = new Blob([JSON.stringify(jsonData, null, 2)], { type: 'application/json;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${id}.json`; + link.click(); + URL.revokeObjectURL(url); + return; + } + const url = chart.toBase64Image(); + + if (format === 'pdf') { + const pdf = new jsPDF(); + const width = pdf.internal.pageSize.getWidth(); + const height = (chart.height / chart.width) * width; + pdf.addImage(url, 'PNG', 0, 0, width, height); + pdf.save(`${id}.pdf`); + return; + } + + if (format === 'svg') { + const svg = ``; + const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8;' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `${id}.svg`; + link.click(); + return; + } + + // default PNG export const link = document.createElement('a'); link.href = url; link.download = `${id}.png`; @@ -131,33 +204,20 @@ export default function ChartContainer({ } }, [t]); - const exportChartCSV = useCallback((id) => { - const chart = chartRefs.current.get(id); - if (!chart) return; - const datasets = chart.data.datasets || []; - const xValues = new Set(); - datasets.forEach(ds => { - (ds.data || []).forEach(p => xValues.add(p.x)); - }); - const sortedX = Array.from(xValues).sort((a, b) => a - b); - const header = ['step', ...datasets.map(ds => ds.label || '')]; - const rows = sortedX.map(x => { - const cols = [x]; - datasets.forEach(ds => { - const pt = (ds.data || []).find(p => p.x === x); - cols.push(pt ? pt.y : ''); - }); - return cols.join(','); + const applyMovingAverage = (data, windowSize) => { + if (windowSize <= 1) return data; + const half = Math.floor(windowSize / 2); + return data.map((point, idx) => { + const start = Math.max(0, idx - half); + const end = Math.min(data.length - 1, idx + half); + let sum = 0; + for (let i = start; i <= end; i++) { + sum += data[i].y; + } + const avg = sum / (end - start + 1); + return { x: point.x, y: avg }; }); - const csv = [header.join(','), ...rows].join('\n'); - const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `${id}.csv`; - link.click(); - URL.revokeObjectURL(url); - }, []); + }; const syncHoverToAllCharts = useCallback((step, sourceId) => { if (syncLockRef.current) return; @@ -290,13 +350,17 @@ export default function ChartContainer({ }; const reindex = data => stepCfg.enabled ? data : data.map((p, idx) => ({ x: idx, y: p.y })); Object.keys(metricsData).forEach(k => { - metricsData[k] = reindex(applyRange(metricsData[k])); + let processed = reindex(applyRange(metricsData[k])); + if (smoothing.method === 'moving-average') { + processed = applyMovingAverage(processed, smoothing.window); + } + metricsData[k] = processed; }); } return { ...file, metricsData }; }); - }, [files, metrics]); + }, [files, metrics, smoothing]); useEffect(() => { const maxStep = parsedData.reduce((m, f) => { @@ -672,11 +736,11 @@ export default function ChartContainer({ - ); comparisonChart = ( @@ -722,11 +777,11 @@ export default function ChartContainer({ - )} > @@ -775,8 +821,46 @@ export default function ChartContainer({ }); return ( -
- {metricElements} +
+
+ + {smoothing.method === 'moving-average' && ( + setSmoothing({ ...smoothing, window: Math.max(1, parseInt(e.target.value) || 1) })} + className="w-16 border rounded p-0.5 text-xs" + /> + )} + +
+
+ {metricElements} +
); } diff --git a/src/components/__tests__/ChartContainer.test.jsx b/src/components/__tests__/ChartContainer.test.jsx index 378703a..4524897 100644 --- a/src/components/__tests__/ChartContainer.test.jsx +++ b/src/components/__tests__/ChartContainer.test.jsx @@ -48,6 +48,13 @@ vi.mock('react-chartjs-2', async () => { import { __charts, __lineProps } from 'react-chartjs-2'; vi.mock('chartjs-plugin-zoom', () => ({ default: {} })); +vi.mock('jspdf', () => ({ + default: class { + constructor() { this.internal = { pageSize: { getWidth: () => 100 } }; } + addImage() {} + save() {} + } +})); describe('ChartContainer', () => { it('prompts to upload files when none provided', () => {