diff --git a/package-lock.json b/package-lock.json index c07de6c..8f7d671 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,13 @@ "chart.js": "^4.5.0", "chartjs-plugin-zoom": "^2.2.0", "lucide-react": "^0.522.0", + "lz-string": "^1.5.0", "postcss": "^8.5.6", "react": "^19.1.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.1.0", - "tailwindcss": "^3.4.4" + "tailwindcss": "^3.4.4", + "zustand": "^5.0.7" }, "devDependencies": { "@eslint/js": "^9.25.0", @@ -1839,7 +1841,7 @@ "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2558,7 +2560,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-urls": { @@ -3988,9 +3990,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7029,6 +7029,35 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.7.tgz", + "integrity": "sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index b8b0f28..5134ab7 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,13 @@ "chart.js": "^4.5.0", "chartjs-plugin-zoom": "^2.2.0", "lucide-react": "^0.522.0", + "lz-string": "^1.5.0", "postcss": "^8.5.6", "react": "^19.1.0", "react-chartjs-2": "^5.3.0", "react-dom": "^19.1.0", - "tailwindcss": "^3.4.4" + "tailwindcss": "^3.4.4", + "zustand": "^5.0.7" }, "devDependencies": { "@eslint/js": "^9.25.0", diff --git a/src/App.jsx b/src/App.jsx index fce2a15..5b6c6da 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,132 +1,52 @@ import React, { useState, useCallback, useEffect } from 'react'; +import { useStore } from './store'; +import { deserializeStateFromURL } from './utils/sharing'; import { FileUpload } from './components/FileUpload'; import { RegexControls } from './components/RegexControls'; import { FileList } from './components/FileList'; import ChartContainer from './components/ChartContainer'; import { ComparisonControls } from './components/ComparisonControls'; -import { Header } from './components/Header'; import { FileConfigModal } from './components/FileConfigModal'; import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'; -import { mergeFilesWithReplacement } from './utils/mergeFiles.js'; function App() { - const [uploadedFiles, setUploadedFiles] = useState([]); + const { + uploadedFiles, + globalParsingConfig, + compareMode, + relativeBaseline, + absoluteBaseline, + configModalOpen, + configFile, + xRange, + maxStep, + sidebarVisible, + handleFilesUploaded, + processGlobalFiles, + handleFileRemove, + handleFileToggle, + handleFileConfig, + handleConfigSave, + handleConfigClose, + handleGlobalParsingConfigChange, + setCompareMode, + setRelativeBaseline, + setAbsoluteBaseline, + setXRange, + setMaxStep, + setSidebarVisible, + } = useStore(); - // 全局解析配置状态 - const [globalParsingConfig, setGlobalParsingConfig] = useState({ - metrics: [ - { - name: 'Loss', - mode: 'keyword', // 'keyword' | 'regex' - keyword: 'loss:', - regex: 'loss:\\s*([\\d.eE+-]+)' - }, - { - name: 'Grad Norm', - mode: 'keyword', - keyword: 'norm:', - regex: 'grad[\\s_]norm:\\s*([\\d.eE+-]+)' - } - ] - }); - - const [compareMode, setCompareMode] = useState('normal'); - const [relativeBaseline, setRelativeBaseline] = useState(0.002); - const [absoluteBaseline, setAbsoluteBaseline] = useState(0.005); - const [configModalOpen, setConfigModalOpen] = useState(false); - const [configFile, setConfigFile] = useState(null); const [globalDragOver, setGlobalDragOver] = useState(false); - const [, setDragCounter] = useState(0); - const [xRange, setXRange] = useState({ min: undefined, max: undefined }); - const [maxStep, setMaxStep] = useState(0); - const [sidebarVisible, setSidebarVisible] = useState(true); - - const handleFilesUploaded = useCallback((files) => { - const filesWithDefaults = files.map(file => ({ - ...file, - enabled: true, - config: { - // 使用全局解析配置作为默认值 - metrics: globalParsingConfig.metrics.map(m => ({ ...m })), - dataRange: { - start: 0, // 默认从第一个数据点开始 - end: undefined, // 默认到最后一个数据点 - useRange: false // 保留这个字段用于向后兼容,但默认不启用 - } - } - })); - setUploadedFiles(prev => mergeFilesWithReplacement(prev, filesWithDefaults)); - }, [globalParsingConfig]); - - // 全局文件处理函数 - const processGlobalFiles = useCallback((files) => { - const fileArray = Array.from(files); - - if (fileArray.length === 0) return; + const [dragCounter, setDragCounter] = useState(0); - const processedFiles = fileArray.map(file => ({ - file, - name: file.name, - id: Math.random().toString(36).substr(2, 9), - data: null, - content: null - })); - - // Read file contents - Promise.all( - processedFiles.map(fileObj => - new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = (e) => { - fileObj.content = e.target.result; - resolve(fileObj); - }; - reader.readAsText(fileObj.file); - }) - ) - ).then(files => { - handleFilesUploaded(files); - }); - }, [handleFilesUploaded]); - - const handleFileRemove = useCallback((index) => { - setUploadedFiles(prev => prev.filter((_, i) => i !== index)); - }, []); - - const handleFileToggle = useCallback((index, enabled) => { - setUploadedFiles(prev => prev.map((file, i) => - i === index ? { ...file, enabled } : file - )); - }, []); - - const handleFileConfig = useCallback((file) => { - setConfigFile(file); - setConfigModalOpen(true); - }, []); - - const handleConfigSave = useCallback((fileId, config) => { - setUploadedFiles(prev => prev.map(file => - file.id === fileId ? { ...file, config } : file - )); - }, []); - - const handleConfigClose = useCallback(() => { - setConfigModalOpen(false); - setConfigFile(null); - }, []); - - // 全局解析配置变更处理 - const handleGlobalParsingConfigChange = useCallback((newConfig) => { - setGlobalParsingConfig(newConfig); - - // 同步所有文件的解析配置 - setUploadedFiles(prev => prev.map(file => ({ - ...file, - config: { - ...file.config, - metrics: newConfig.metrics.map(m => ({ ...m })) - } - }))); + useEffect(() => { + const sharedState = deserializeStateFromURL(); + if (sharedState) { + useStore.setState(sharedState); + // Clear the hash to avoid re-loading the shared state on every refresh + window.location.hash = ''; + } }, []); // 全局拖拽事件处理 @@ -134,7 +54,6 @@ function App() { e.preventDefault(); setDragCounter(prev => prev + 1); - // 检查是否包含文件 if (e.dataTransfer.types.includes('Files')) { setGlobalDragOver(true); } @@ -142,7 +61,6 @@ function App() { const handleGlobalDragOver = useCallback((e) => { e.preventDefault(); - // 设置拖拽效果 e.dataTransfer.dropEffect = 'copy'; }, []); diff --git a/src/components/ChartContainer.jsx b/src/components/ChartContainer.jsx index b10f0f5..4731333 100644 --- a/src/components/ChartContainer.jsx +++ b/src/components/ChartContainer.jsx @@ -1,6 +1,7 @@ import React, { useMemo, useRef, useCallback, useEffect } from 'react'; import { Line } from 'react-chartjs-2'; import { ResizablePanel } from './ResizablePanel'; +import { movingAverage } from '../utils/smoothing.js'; import { Chart as ChartJS, Chart, @@ -60,16 +61,20 @@ const ChartWrapper = ({ data, options, chartId, onRegisterChart, onSyncHover }) ); }; -export default function ChartContainer({ - files, - metrics = [], - compareMode, - relativeBaseline = 0.002, - absoluteBaseline = 0.005, - xRange = { min: undefined, max: undefined }, - onXRangeChange, - onMaxStepChange -}) { +import { useStore } from '../store'; + +export default function ChartContainer() { + const files = useStore(state => state.uploadedFiles); + const metrics = useStore(state => state.globalParsingConfig.metrics); + const compareMode = useStore(state => state.compareMode); + const relativeBaseline = useStore(state => state.relativeBaseline); + const absoluteBaseline = useStore(state => state.absoluteBaseline); + const xRange = useStore(state => state.xRange); + const onXRangeChange = useStore(state => state.setXRange); + const onMaxStepChange = useStore(state => state.setMaxStep); + const smoothingEnabled = useStore(state => state.smoothingEnabled); + const smoothingWindow = useStore(state => state.smoothingWindow); + const chartRefs = useRef(new Map()); const registerChart = useCallback((id, inst) => { chartRefs.current.set(id, inst); @@ -153,9 +158,16 @@ export default function ChartContainer({ }); } + // Apply smoothing if enabled + if (smoothingEnabled && smoothingWindow > 1) { + Object.keys(metricsData).forEach(key => { + metricsData[key] = movingAverage(metricsData[key], smoothingWindow); + }); + } + return { ...file, metricsData }; }); - }, [files, metrics]); + }, [files, metrics, smoothingEnabled, smoothingWindow]); useEffect(() => { const maxStep = parsedData.reduce((m, f) => { @@ -176,10 +188,9 @@ export default function ChartContainer({ } }, [parsedData, onXRangeChange]); - const colors = ['#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#f97316']; const createChartData = dataArray => ({ datasets: dataArray.map((item, index) => { - const color = colors[index % colors.length]; + const color = item.color || '#000000'; // Fallback to black return { label: item.name?.replace(/\.(log|txt)$/i, '') || `File ${index + 1}`, data: item.data, diff --git a/src/components/ComparisonControls.jsx b/src/components/ComparisonControls.jsx index d844575..86ddb25 100644 --- a/src/components/ComparisonControls.jsx +++ b/src/components/ComparisonControls.jsx @@ -1,10 +1,16 @@ import React from 'react'; import { BarChart2 } from 'lucide-react'; +import { useStore } from '../store'; + +export function ComparisonControls() { + const { + compareMode, + onCompareModeChange + } = useStore(state => ({ + compareMode: state.compareMode, + onCompareModeChange: state.setCompareMode + })); -export function ComparisonControls({ - compareMode, - onCompareModeChange -}) { const modes = [ { value: 'normal', label: '📊 平均误差 (normal)', description: '未取绝对值的平均误差' }, { value: 'absolute', label: '📈 平均误差 (absolute)', description: '绝对值差值的平均' }, diff --git a/src/components/FileConfigModal.jsx b/src/components/FileConfigModal.jsx index 6428b20..ab55031 100644 --- a/src/components/FileConfigModal.jsx +++ b/src/components/FileConfigModal.jsx @@ -34,22 +34,37 @@ function getMetricTitle(metric, index) { return `Metric ${index + 1}`; } -export function FileConfigModal({ file, isOpen, onClose, onSave, globalParsingConfig }) { +import { useStore } from '../store'; + +export function FileConfigModal() { + const { + file, + isOpen, + onClose, + onSave, + globalParsingConfig + } = useStore(state => ({ + file: state.configFile, + isOpen: state.configModalOpen, + onClose: state.handleConfigClose, + onSave: state.handleConfigSave, + globalParsingConfig: state.globalParsingConfig + })); + const [config, setConfig] = useState({ metrics: [], dataRange: { - start: 0, // 起始位置,默认为0(第一个数据点) - end: undefined, // 结束位置,默认为undefined(最后一个数据点) - useRange: false // 保留用于向后兼容 + start: 0, + end: undefined, + useRange: false } }); useEffect(() => { if (file && isOpen) { - // 如果文件有配置,使用文件配置,否则使用全局配置 const fileConfig = file.config || {}; setConfig({ - metrics: fileConfig.metrics || globalParsingConfig.metrics, + metrics: fileConfig.metrics || globalParsingConfig.metrics.map(m => ({...m})), dataRange: fileConfig.dataRange || { start: 0, end: undefined, diff --git a/src/components/FileList.jsx b/src/components/FileList.jsx index 37fb183..ee460ac 100644 --- a/src/components/FileList.jsx +++ b/src/components/FileList.jsx @@ -1,7 +1,14 @@ import React from 'react'; import { FileText, X, Settings } from 'lucide-react'; +import { useStore } from '../store'; + +export function FileList() { + const files = useStore(state => state.uploadedFiles); + const onFileRemove = useStore(state => state.handleFileRemove); + const onFileToggle = useStore(state => state.handleFileToggle); + const onFileConfig = useStore(state => state.handleFileConfig); + const setFileColor = useStore(state => state.setFileColor); -export function FileList({ files, onFileRemove, onFileToggle, onFileConfig }) { if (files.length === 0) { return (
@@ -43,10 +50,12 @@ export function FileList({ files, onFileRemove, onFileToggle, onFileConfig }) { className="rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" aria-describedby={`file-status-${file.id}`} /> -