Commit cb2c87cb authored by Developer's avatar Developer

refactor: 图表库 Recharts→uPlot 迁移+悬浮tooltip+框选高亮

parent e65e953b
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.13.1", "react-router-dom": "^7.13.1",
"recharts": "^3.8.0", "uplot": "^1.6.32",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
...@@ -1012,42 +1012,6 @@ ...@@ -1012,42 +1012,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmmirror.com/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3", "version": "1.0.0-rc.3",
"resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
...@@ -1405,18 +1369,6 @@ ...@@ -1405,18 +1369,6 @@
"win32" "win32"
] ]
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz",
...@@ -1462,12 +1414,6 @@ ...@@ -1462,12 +1414,6 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": { "node_modules/@types/d3-color": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", "resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz",
...@@ -1483,12 +1429,6 @@ ...@@ -1483,12 +1429,6 @@
"@types/d3-selection": "*" "@types/d3-selection": "*"
} }
}, },
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": { "node_modules/@types/d3-interpolate": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
...@@ -1498,48 +1438,12 @@ ...@@ -1498,48 +1438,12 @@
"@types/d3-color": "*" "@types/d3-color": "*"
} }
}, },
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-selection": { "node_modules/@types/d3-selection": {
"version": "3.0.11", "version": "3.0.11",
"resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz", "resolved": "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": { "node_modules/@types/d3-transition": {
"version": "3.0.9", "version": "3.0.9",
"resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz", "resolved": "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz",
...@@ -1593,12 +1497,6 @@ ...@@ -1593,12 +1497,6 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "5.1.4", "version": "5.1.4",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
...@@ -1884,15 +1782,6 @@ ...@@ -1884,15 +1782,6 @@
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/codepage": { "node_modules/codepage": {
"version": "1.15.0", "version": "1.15.0",
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz", "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
...@@ -1983,18 +1872,6 @@ ...@@ -1983,18 +1872,6 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": { "node_modules/d3-color": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz",
...@@ -2035,15 +1912,6 @@ ...@@ -2035,15 +1912,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": { "node_modules/d3-interpolate": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
...@@ -2056,31 +1924,6 @@ ...@@ -2056,31 +1924,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": { "node_modules/d3-selection": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz",
...@@ -2090,42 +1933,6 @@ ...@@ -2090,42 +1933,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": { "node_modules/d3-timer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz",
...@@ -2198,12 +2005,6 @@ ...@@ -2198,12 +2005,6 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz",
...@@ -2218,16 +2019,6 @@ ...@@ -2218,16 +2019,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.3", "version": "0.27.3",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz",
...@@ -2477,12 +2268,6 @@ ...@@ -2477,12 +2268,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
...@@ -2679,16 +2464,6 @@ ...@@ -2679,16 +2464,6 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmmirror.com/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz",
...@@ -2716,15 +2491,6 @@ ...@@ -2716,15 +2491,6 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-extglob": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
...@@ -3111,36 +2877,6 @@ ...@@ -3111,36 +2877,6 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-is": {
"version": "19.2.4",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-19.2.4.tgz",
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
"resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz", "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz",
...@@ -3189,57 +2925,6 @@ ...@@ -3189,57 +2925,6 @@
"react-dom": ">=18" "react-dom": ">=18"
} }
}, },
"node_modules/recharts": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz",
...@@ -3388,12 +3073,6 @@ ...@@ -3388,12 +3073,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
...@@ -3455,6 +3134,12 @@ ...@@ -3455,6 +3134,12 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/uplot": {
"version": "1.6.32",
"resolved": "https://registry.npmmirror.com/uplot/-/uplot-1.6.32.tgz",
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"license": "MIT"
},
"node_modules/uri-js": { "node_modules/uri-js": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz",
...@@ -3474,28 +3159,6 @@ ...@@ -3474,28 +3159,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz",
......
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.13.1", "react-router-dom": "^7.13.1",
"recharts": "^3.8.0", "uplot": "^1.6.32",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
......
/** /**
* LiveChart — 实时仿真数据图表 + 硬件操控面板 * LiveChart — 实时仿真数据图表 + 硬件操控面板
* *
* 使用 uPlot (Canvas) 渲染实时数据流。
* 通过 useRosBridge hook 接收 /hil/sim_data 话题的实时帧, * 通过 useRosBridge hook 接收 /hil/sim_data 话题的实时帧,
* 动态追加数据点并实时绘制。右侧嵌入硬件操控面板。 * 动态追加数据点并实时绘制。右侧嵌入硬件操控面板。
* *
...@@ -11,10 +12,9 @@ ...@@ -11,10 +12,9 @@
* onDone — 仿真完成回调 * onDone — 仿真完成回调
*/ */
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { import uPlot from 'uplot';
LineChart, Line, XAxis, YAxis, CartesianGrid, import 'uplot/dist/uPlot.min.css';
Tooltip, ResponsiveContainer, import tooltipPlugin from '../../utils/uplotTooltipPlugin';
} from 'recharts';
import useRosBridge from '../../hooks/useRosBridge'; import useRosBridge from '../../hooks/useRosBridge';
import { getHilPorts } from '../../utils/api'; import { getHilPorts } from '../../utils/api';
import styles from './LiveChart.module.css'; import styles from './LiveChart.module.css';
...@@ -76,8 +76,10 @@ export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone }) ...@@ -76,8 +76,10 @@ export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone })
const [hwPorts, setHwPorts] = useState([]); const [hwPorts, setHwPorts] = useState([]);
const [hwValues, setHwValues] = useState({}); const [hwValues, setHwValues] = useState({});
const hwWsRef = useRef(null); const hwWsRef = useRef(null);
const chartRef = useRef(null);
const uplotRef = useRef(null);
// 自动连接 rosbridge(数据接收) // 自动连接 rosbridge
useEffect(() => { useEffect(() => {
connect(rosBridgeUrl || 'ws://localhost:9090'); connect(rosBridgeUrl || 'ws://localhost:9090');
return () => disconnect(); return () => disconnect();
...@@ -100,23 +102,16 @@ export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone }) ...@@ -100,23 +102,16 @@ export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone })
}).catch(() => {}); }).catch(() => {});
}, [sessionId]); }, [sessionId]);
// ── 硬件面板 WebSocket(发布 override) ── // ── 硬件面板 WebSocket ──
useEffect(() => { useEffect(() => {
if (!hwPorts.length) return; if (!hwPorts.length) return;
const ws = new WebSocket(`ws://${window.location.hostname}:9090`); const ws = new WebSocket(`ws://${window.location.hostname}:9090`);
hwWsRef.current = ws; hwWsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
ws.send(JSON.stringify({ ws.send(JSON.stringify({ op: 'advertise', topic: '/hil/user_override', type: 'std_msgs/String' }));
op: 'advertise',
topic: '/hil/user_override',
type: 'std_msgs/String',
}));
}; };
ws.onclose = () => { if (hwWsRef.current === ws) hwWsRef.current = null; }; ws.onclose = () => { if (hwWsRef.current === ws) hwWsRef.current = null; };
ws.onerror = () => {}; ws.onerror = () => {};
return () => { return () => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ op: 'unadvertise', topic: '/hil/user_override' })); ws.send(JSON.stringify({ op: 'unadvertise', topic: '/hil/user_override' }));
...@@ -129,8 +124,7 @@ export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone }) ...@@ -129,8 +124,7 @@ export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone })
const ws = hwWsRef.current; const ws = hwWsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return; if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ ws.send(JSON.stringify({
op: 'publish', op: 'publish', topic: '/hil/user_override',
topic: '/hil/user_override',
msg: { data: JSON.stringify({ component, value }) }, msg: { data: JSON.stringify({ component, value }) },
})); }));
}, []); }, []);
...@@ -169,10 +163,104 @@ export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone }) ...@@ -169,10 +163,104 @@ export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone })
const s = new Set(prev); s.has(v) ? s.delete(v) : s.add(v); return s; const s = new Set(prev); s.has(v) ? s.delete(v) : s.add(v); return s;
}), []); }), []);
// 转换为 uPlot 列式数据(限制最近 500 点)
const displayData = useMemo(() => { const displayData = useMemo(() => {
if (data.length <= 500) return data; const sliced = data.length <= 500 ? data : data.slice(-500);
return data.slice(-500); if (!sliced.length || !selectedArr.length) return null;
}, [data]);
const timeArr = sliced.map(d => d[xKey] ?? 0);
const result = [timeArr];
for (const vn of selectedArr) {
result.push(sliced.map(d => d[vn] ?? 0));
}
return result;
}, [data, selectedArr, xKey]);
// 用 uPlot.setData 更新(或重建图表当 series 变化时)
const prevSeriesKeyRef = useRef('');
useEffect(() => {
const el = chartRef.current;
if (!el || !displayData) {
if (uplotRef.current) { uplotRef.current.destroy(); uplotRef.current = null; }
return;
}
const seriesKey = selectedArr.join(',');
const needRebuild = seriesKey !== prevSeriesKeyRef.current || !uplotRef.current;
if (needRebuild) {
prevSeriesKeyRef.current = seriesKey;
if (uplotRef.current) { uplotRef.current.destroy(); uplotRef.current = null; }
const series = [{ label: '时间(s)' }];
for (const vn of selectedArr) {
const ci = variables.indexOf(vn);
series.push({
label: toChinese(vn),
stroke: COLORS[ci % COLORS.length],
width: 2,
});
}
const rect = el.getBoundingClientRect();
const opts = {
width: rect.width || 600,
height: rect.height || 300,
cursor: { drag: { x: true, y: false, setScale: true } },
plugins: [tooltipPlugin()],
legend: { show: false },
scales: { x: { time: false } },
axes: [
{
stroke: '#9ca3af',
grid: { stroke: 'rgba(79,138,255,0.1)', width: 1 },
ticks: { stroke: '#333' },
font: '10px system-ui',
values: (u, vals) => vals.map(v => typeof v === 'number' ? v.toFixed(3) : v),
},
{
stroke: '#9ca3af',
grid: { stroke: 'rgba(79,138,255,0.1)', width: 1 },
ticks: { stroke: '#333' },
font: '10px system-ui',
values: (u, vals) => vals.map(v => typeof v === 'number' ? v.toFixed(2) : v),
},
],
series,
};
uplotRef.current = new uPlot(opts, displayData, el);
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
if (uplotRef.current && width > 0 && height > 0) {
uplotRef.current.setSize({ width, height });
}
}
});
ro.observe(el);
// store ro for cleanup
el._uplotRO = ro;
} else {
// Just update data — fast path, no React re-render needed for the chart
uplotRef.current.setData(displayData);
}
return () => {
// Only cleanup on unmount (not every data change)
};
}, [displayData, selectedArr, variables]);
// Cleanup on unmount
useEffect(() => {
return () => {
const el = chartRef.current;
if (el?._uplotRO) el._uplotRO.disconnect();
if (uplotRef.current) { uplotRef.current.destroy(); uplotRef.current = null; }
};
}, []);
return ( return (
<div className={styles.overlay} onClick={onClose}> <div className={styles.overlay} onClick={onClose}>
...@@ -214,37 +302,13 @@ export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone }) ...@@ -214,37 +302,13 @@ export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone })
{/* 图表区 */} {/* 图表区 */}
<div className={styles.chartArea}> <div className={styles.chartArea}>
{!selectedArr.length || !displayData.length ? ( {!selectedArr.length || !displayData ? (
<div className={styles.emptyChart}> <div className={styles.emptyChart}>
<div style={{ fontSize: 40, opacity: 0.3 }}>📡</div> <div style={{ fontSize: 40, opacity: 0.3 }}>📡</div>
<div>{status === 'connected' ? '等待数据…' : '连接 rosbridge 中…'}</div> <div>{status === 'connected' ? '等待数据…' : '连接 rosbridge 中…'}</div>
</div> </div>
) : ( ) : (
<ResponsiveContainer width="100%" height="100%"> <div ref={chartRef} style={{ width: '100%', height: '100%' }} />
<LineChart data={displayData} margin={{ top: 10, right: 30, left: 10, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(79,138,255,0.1)" />
<XAxis
dataKey={xKey} stroke="#555"
tick={{ fill: '#9ca3af', fontSize: 10 }}
tickFormatter={v => typeof v === 'number' ? v.toFixed(3) : v}
type="number" domain={['dataMin', 'dataMax']}
/>
<YAxis stroke="#555" tick={{ fill: '#9ca3af', fontSize: 10 }}
tickFormatter={v => typeof v === 'number' ? v.toFixed(2) : v} />
<Tooltip
contentStyle={{
background: '#1a1a2e', border: '1px solid rgba(79,138,255,0.2)',
borderRadius: 8, fontSize: 11,
}}
labelFormatter={v => `t = ${typeof v === 'number' ? v.toFixed(4) : v}s`}
/>
{selectedArr.map(vn => (
<Line key={vn} type="monotone" dataKey={vn} name={toChinese(vn)}
stroke={COLORS[variables.indexOf(vn) % COLORS.length]}
strokeWidth={2} dot={false} isAnimationActive={false} />
))}
</LineChart>
</ResponsiveContainer>
)} )}
</div> </div>
......
...@@ -185,10 +185,45 @@ ...@@ -185,10 +185,45 @@
.chartArea { .chartArea {
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: center;
padding: 10px; padding: 10px;
min-width: 0; min-width: 0;
overflow: hidden;
}
/* uPlot 容器样式 */
.chartArea :global(.uplot) {
display: flex;
flex-direction: column;
width: 100% !important;
flex: 1;
min-height: 0;
}
.chartArea :global(.uplot .u-wrap) {
flex: 1;
min-height: 0;
}
.chartArea :global(.uplot .u-legend) {
padding: 6px 4px;
font-size: 10px;
color: #9ca3af;
flex-wrap: wrap;
gap: 2px 10px;
flex-shrink: 0;
}
.chartArea :global(.uplot .u-legend .u-value) {
font-weight: 600;
color: #e0e0e0;
}
/* 框选高亮区域 */
.chartArea :global(.uplot .u-select) {
background: rgba(79, 138, 255, 0.15) !important;
border-left: 1px solid rgba(79, 138, 255, 0.5);
border-right: 1px solid rgba(79, 138, 255, 0.5);
} }
.emptyChart { .emptyChart {
......
/** /**
* SimResultsModal - 仿真结果图表查看器(弹窗模式) * SimResultsModal - 仿真结果图表查看器(弹窗模式)
* *
* 交互: * 使用 uPlot (Canvas) 渲染,支持:
* - 左键拖拽框选放大 * - 拖拽框选放大(uPlot 内置)
* - 滚轮缩放 X 轴 * - 滚轮缩放 X 轴
* - 按住滚轮(中键)拖拽平移 * - 空格键智能裁剪
* - 空格键自适应(重置)
* - 工具栏: 放大/缩小/撤销/重置/导出 PNG * - 工具栏: 放大/缩小/撤销/重置/导出 PNG
* - 变量名自动中文翻译 * - 变量名自动中文翻译
*/ */
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { import uPlot from 'uplot';
LineChart, Line, XAxis, YAxis, CartesianGrid, import 'uplot/dist/uPlot.min.css';
Tooltip, Legend, ResponsiveContainer, Brush, import tooltipPlugin from '../../utils/uplotTooltipPlugin';
ReferenceArea,
} from 'recharts';
import styles from './SimResultsModal.module.css'; import styles from './SimResultsModal.module.css';
const COLORS = [ const COLORS = [
...@@ -51,46 +48,26 @@ function toChinese(name) { ...@@ -51,46 +48,26 @@ function toChinese(name) {
return pfx + cn + num + ' ' + (PORT_CN[parts[1]] || parts[1]) + (QTY_CN[parts[2]] || parts[2]); return pfx + cn + num + ' ' + (PORT_CN[parts[1]] || parts[1]) + (QTY_CN[parts[2]] || parts[2]);
} }
// ===== CSV 解析 ===== // ===== CSV 解析 (列式) =====
function parseCSV(csv) { function parseCSV(csv) {
if (!csv || typeof csv !== 'string') return { headers: [], data: [] }; if (!csv || typeof csv !== 'string') return { headers: [], columns: [] };
const lines = csv.trim().split('\n').filter(l => l.trim()); const lines = csv.trim().split('\n').filter(l => l.trim());
if (lines.length < 2) return { headers: [], data: [] }; if (lines.length < 2) return { headers: [], columns: [] };
const headers = lines[0].split(',').map(h => h.replace(/^"|"$/g, '').trim()); const headers = lines[0].split(',').map(h => h.replace(/^"|"$/g, '').trim());
const data = []; const columns = headers.map(() => []);
for (let i = 1; i < lines.length; i++) { for (let i = 1; i < lines.length; i++) {
const vals = lines[i].split(','); const vals = lines[i].split(',');
if (vals.length !== headers.length) continue; if (vals.length !== headers.length) continue;
const row = {};
let ok = true; let ok = true;
const nums = [];
for (let j = 0; j < headers.length; j++) { for (let j = 0; j < headers.length; j++) {
const n = parseFloat(vals[j]); const n = parseFloat(vals[j]);
if (isNaN(n)) { ok = false; break; } if (isNaN(n)) { ok = false; break; }
row[headers[j]] = n; nums.push(n);
} }
if (ok) data.push(row); if (ok) nums.forEach((n, j) => columns[j].push(n));
} }
return { headers, data }; return { headers, columns };
}
// ===== Tooltip =====
function CustomTooltip({ active, payload, label }) {
if (!active || !payload?.length) return null;
return (
<div style={{
background: '#1a1a2e', border: '1px solid rgba(79,138,255,0.2)',
borderRadius: 10, padding: '10px 14px', boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
}}>
<div style={{ fontSize: 11, color: '#9ca3af', marginBottom: 6 }}>
时间 = {typeof label === 'number' ? label.toFixed(6) : label} s
</div>
{payload.map((e, i) => (
<div key={i} style={{ fontSize: 12, color: e.color, marginBottom: 2 }}>
{e.name}: <strong>{typeof e.value === 'number' ? e.value.toFixed(6) : e.value}</strong>
</div>
))}
</div>
);
} }
function ToolBtn({ icon, label, onClick, disabled }) { function ToolBtn({ icon, label, onClick, disabled }) {
...@@ -104,30 +81,28 @@ function ToolBtn({ icon, label, onClick, disabled }) { ...@@ -104,30 +81,28 @@ function ToolBtn({ icon, label, onClick, disabled }) {
// ===== 主组件 ===== // ===== 主组件 =====
export default function SimResultsModal({ csvData, modelName, onClose }) { export default function SimResultsModal({ csvData, modelName, onClose }) {
// --- state ---
const [selectedVars, setSelectedVars] = useState(null); const [selectedVars, setSelectedVars] = useState(null);
const [xDomain, setXDomain] = useState(null);
const [yDomain, setYDomain] = useState(null);
const [refAreaLeft, setRefAreaLeft] = useState(null);
const [refAreaRight, setRefAreaRight] = useState(null);
const [isDragging, setIsDragging] = useState(false);
const [zoomStack, setZoomStack] = useState([]); const [zoomStack, setZoomStack] = useState([]);
const [isZoomed, setIsZoomed] = useState(false);
// --- refs --- const [zoomRange, setZoomRange] = useState(null); // [xMin, xMax] for display
const chartWrapRef = useRef(null);
const panRef = useRef({ active: false, startX: 0, startDomain: null }); const chartRef = useRef(null);
const rafRef = useRef(null); const uplotRef = useRef(null);
const xDomainRef = useRef(null);
const fullXRangeRef = useRef([0, 1]); // --- derived data ---
const { headers, columns } = useMemo(() => parseCSV(csvData), [csvData]);
// --- derived data (all hooks before any conditional return) --- const xIdx = useMemo(() => {
const { headers, data } = useMemo(() => parseCSV(csvData), [csvData]); const idx = headers.findIndex(h => h.toLowerCase() === 'time');
const xKey = useMemo(() => headers.find(h => h.toLowerCase() === 'time') || headers[0] || 'time', [headers]); return idx >= 0 ? idx : 0;
const variables = useMemo(() => headers.filter(h => h !== xKey), [headers, xKey]); }, [headers]);
const xKey = headers[xIdx] || 'time';
const variables = useMemo(() =>
headers.map((h, i) => ({ name: h, index: i })).filter((_, i) => i !== xIdx),
[headers, xIdx]);
useEffect(() => { useEffect(() => {
if (variables.length > 0 && selectedVars === null) { if (variables.length > 0 && selectedVars === null) {
setSelectedVars(new Set(variables.slice(0, Math.min(5, variables.length)))); setSelectedVars(new Set(variables.slice(0, Math.min(5, variables.length)).map(v => v.name)));
} }
}, [variables]); // eslint-disable-line react-hooks/exhaustive-deps }, [variables]); // eslint-disable-line react-hooks/exhaustive-deps
...@@ -135,159 +110,178 @@ export default function SimResultsModal({ csvData, modelName, onClose }) { ...@@ -135,159 +110,178 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
const selectedArr = useMemo(() => [...selected], [selected]); const selectedArr = useMemo(() => [...selected], [selected]);
const fullXRange = useMemo(() => { const fullXRange = useMemo(() => {
if (data.length === 0) return [0, 1]; const tc = columns[xIdx];
return [data[0][xKey], data[data.length - 1][xKey]]; if (!tc || tc.length === 0) return [0, 1];
}, [data, xKey]); return [tc[0], tc[tc.length - 1]];
}, [columns, xIdx]);
const autoYDomain = useMemo(() => {
const src = xDomain ? data.filter(d => d[xKey] >= xDomain[0] && d[xKey] <= xDomain[1]) : data;
if (!selectedArr.length || !src.length) return undefined;
let lo = Infinity, hi = -Infinity;
for (const row of src) for (const v of selectedArr) {
if (row[v] !== undefined) { if (row[v] < lo) lo = row[v]; if (row[v] > hi) hi = row[v]; }
}
if (!isFinite(lo)) return undefined;
const pad = (hi - lo) * 0.1 || 1;
return [lo - pad, hi + pad];
}, [data, xDomain, xKey, selectedArr]);
const effectiveYDomain = yDomain || autoYDomain;
// --- sync refs (after useMemo definitions) ---
xDomainRef.current = xDomain;
fullXRangeRef.current = fullXRange;
// --- 中键拖拽平移 (使用直接 DOM 操作管理图标,绕过 React) ---
const panIconRef = useRef(null); // DOM 元素引用
// --- uPlot 渲染 ---
useEffect(() => { useEffect(() => {
const el = chartWrapRef.current; const el = chartRef.current;
if (!el) return; if (!el || !columns.length || !selectedArr.length) {
if (uplotRef.current) { uplotRef.current.destroy(); uplotRef.current = null; }
// 创建图标 DOM 元素(不走 React 渲染) return;
const createIcon = (x, y) => { }
if (panIconRef.current) panIconRef.current.remove();
const icon = document.createElement('div');
icon.style.cssText = `
position:fixed; left:${x - 16}px; top:${y - 16}px;
width:32px; height:32px; border-radius:50%;
background:rgba(79,138,255,0.3); border:2px solid rgba(79,138,255,0.7);
display:flex; align-items:center; justify-content:center;
font-size:16px; color:#4f8aff; pointer-events:none; z-index:99999;
box-shadow:0 0 12px rgba(79,138,255,0.4);
`;
icon.textContent = '⇔';
document.body.appendChild(icon);
panIconRef.current = icon;
};
const moveIcon = (x, y) => { const timeData = columns[xIdx];
if (panIconRef.current) { const uData = [timeData];
panIconRef.current.style.left = (x - 16) + 'px'; const series = [{ label: '时间(s)' }];
panIconRef.current.style.top = (y - 16) + 'px';
}
};
const removeIcon = () => { for (const varName of selectedArr) {
if (panIconRef.current) { panIconRef.current.remove(); panIconRef.current = null; } const vi = headers.indexOf(varName);
}; if (vi < 0) continue;
uData.push(columns[vi]);
const ci = variables.findIndex(v => v.name === varName);
series.push({
label: toChinese(varName),
stroke: COLORS[ci % COLORS.length],
width: 2,
});
}
const onDown = (e) => { const rect = el.getBoundingClientRect();
if (e.button !== 1 || !el.contains(e.target)) return; const opts = {
e.preventDefault(); width: rect.width || 600,
panRef.current = { active: true, startX: e.clientX, startDomain: xDomainRef.current || fullXRangeRef.current }; height: rect.height || 300,
el.style.cursor = 'grabbing'; cursor: {
createIcon(e.clientX, e.clientY); drag: { x: true, y: false, setScale: true },
},
plugins: [tooltipPlugin()],
legend: { show: false },
scales: {
x: { time: false },
},
hooks: {
setScale: [
(u, scaleKey) => {
if (scaleKey === 'x') {
const xMin = u.scales.x.min;
const xMax = u.scales.x.max;
const fullMin = timeData[0];
const fullMax = timeData[timeData.length - 1];
const zoomed = xMin > fullMin + 0.001 || xMax < fullMax - 0.001;
setIsZoomed(zoomed);
setZoomRange(zoomed ? [xMin, xMax] : null);
}
},
],
},
axes: [
{
stroke: '#9ca3af',
grid: { stroke: 'rgba(79,138,255,0.1)', width: 1 },
ticks: { stroke: '#333' },
font: '10px system-ui',
values: (u, vals) => vals.map(v => typeof v === 'number' ? v.toFixed(3) : v),
},
{
stroke: '#9ca3af',
grid: { stroke: 'rgba(79,138,255,0.1)', width: 1 },
ticks: { stroke: '#333' },
font: '10px system-ui',
values: (u, vals) => vals.map(v => typeof v === 'number' ? v.toFixed(2) : v),
},
],
series,
}; };
const onMove = (e) => { if (uplotRef.current) { uplotRef.current.destroy(); }
if (!panRef.current.active) return; uplotRef.current = new uPlot(opts, uData, el);
// 移动图标跟随鼠标
moveIcon(e.clientX, e.clientY);
// rAF 节流更新图表
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
const { startX, startDomain } = panRef.current;
const fxr = fullXRangeRef.current;
const range = startDomain[1] - startDomain[0];
const dx = e.clientX - startX;
const shift = -(dx * range) / (el.clientWidth || 600);
let l = startDomain[0] + shift, r = startDomain[1] + shift;
if (l < fxr[0]) { l = fxr[0]; r = l + range; }
if (r > fxr[1]) { r = fxr[1]; l = r - range; }
setXDomain([l, r]);
setYDomain(null);
});
};
const onUp = (e) => { const ro = new ResizeObserver(entries => {
if (e.button === 1 && panRef.current.active) { for (const entry of entries) {
panRef.current.active = false; const { width, height } = entry.contentRect;
el.style.cursor = ''; if (uplotRef.current && width > 0 && height > 0) {
removeIcon(); uplotRef.current.setSize({ width, height });
if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } }
} }
}; });
ro.observe(el);
const onCtx = (e) => { if (el.contains(e.target)) e.preventDefault(); };
// 挂在 el 上用 capture 拦截中键,不用 stopImmediatePropagation
el.addEventListener('mousedown', onDown, true);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
el.addEventListener('contextmenu', onCtx);
return () => { return () => {
el.removeEventListener('mousedown', onDown, true); ro.disconnect();
document.removeEventListener('mousemove', onMove); if (uplotRef.current) { uplotRef.current.destroy(); uplotRef.current = null; }
document.removeEventListener('mouseup', onUp);
el.removeEventListener('contextmenu', onCtx);
removeIcon();
if (rafRef.current) cancelAnimationFrame(rafRef.current);
}; };
}, [selectedVars]); // 初始化完成后重新绑定 }, [columns, selectedArr, headers, variables, xIdx]);
// --- 工具栏操作 ---
const zoomIn = useCallback(() => {
const u = uplotRef.current;
if (!u) return;
const xMin = u.scales.x.min, xMax = u.scales.x.max;
setZoomStack(prev => [...prev, [xMin, xMax]]);
const mid = (xMin + xMax) / 2, nr = (xMax - xMin) * 0.5;
u.setScale('x', { min: mid - nr / 2, max: mid + nr / 2 });
}, []);
const zoomOut = useCallback(() => {
const u = uplotRef.current;
if (!u) return;
const xMin = u.scales.x.min, xMax = u.scales.x.max;
setZoomStack(prev => [...prev, [xMin, xMax]]);
const mid = (xMin + xMax) / 2, nr = (xMax - xMin) * 2;
const l = Math.max(fullXRange[0], mid - nr / 2);
const r = Math.min(fullXRange[1], mid + nr / 2);
u.setScale('x', { min: l, max: r });
}, [fullXRange]);
const resetZoom = useCallback(() => {
const u = uplotRef.current;
if (!u) return;
setZoomStack([]);
u.setScale('x', { min: fullXRange[0], max: fullXRange[1] });
}, [fullXRange]);
const undoZoom = useCallback(() => {
if (!zoomStack.length) return;
const prev = zoomStack[zoomStack.length - 1];
setZoomStack(s => s.slice(0, -1));
const u = uplotRef.current;
if (u) u.setScale('x', { min: prev[0], max: prev[1] });
}, [zoomStack]);
// --- 空格智能裁剪: 只显示数据有变化的部分 --- // --- 智能裁剪 ---
const smartFit = useCallback(() => { const smartFit = useCallback(() => {
if (!data.length || !selectedArr.length) return; if (!columns.length || !selectedArr.length) return;
// 找到数据开始变化和结束变化的时间点 const timeData = columns[xIdx];
let firstChangeIdx = data.length - 1; let firstChangeIdx = timeData.length - 1;
let lastChangeIdx = 0; let lastChangeIdx = 0;
for (const varName of selectedArr) { for (const varName of selectedArr) {
const baseVal = data[0][varName]; const vi = headers.indexOf(varName);
if (baseVal === undefined) continue; if (vi < 0) continue;
for (let i = 1; i < data.length; i++) { const col = columns[vi];
const val = data[i][varName]; const baseVal = col[0];
if (Math.abs(val - baseVal) > Math.abs(baseVal) * 0.001 + 1e-10) { for (let i = 1; i < col.length; i++) {
if (Math.abs(col[i] - baseVal) > Math.abs(baseVal) * 0.001 + 1e-10) {
firstChangeIdx = Math.min(firstChangeIdx, Math.max(0, i - 1)); firstChangeIdx = Math.min(firstChangeIdx, Math.max(0, i - 1));
break; break;
} }
} }
// 从后往前找最后变化点 const endVal = col[col.length - 1];
const endVal = data[data.length - 1][varName]; for (let i = col.length - 2; i >= 0; i--) {
for (let i = data.length - 2; i >= 0; i--) { if (Math.abs(col[i] - endVal) > Math.abs(endVal) * 0.001 + 1e-10) {
const val = data[i][varName]; lastChangeIdx = Math.max(lastChangeIdx, Math.min(col.length - 1, i + 1));
if (Math.abs(val - endVal) > Math.abs(endVal) * 0.001 + 1e-10) {
lastChangeIdx = Math.max(lastChangeIdx, Math.min(data.length - 1, i + 1));
break; break;
} }
} }
} }
const u = uplotRef.current;
if (!u) return;
if (firstChangeIdx >= lastChangeIdx) { if (firstChangeIdx >= lastChangeIdx) {
// 没有变化或整段都在变化,显示全部 u.setScale('x', { min: fullXRange[0], max: fullXRange[1] });
setXDomain(null); setYDomain(null);
} else { } else {
// 留一点 padding const tStart = timeData[firstChangeIdx];
const tStart = data[firstChangeIdx][xKey]; const tEnd = timeData[lastChangeIdx];
const tEnd = data[lastChangeIdx][xKey];
const pad = (tEnd - tStart) * 0.05; const pad = (tEnd - tStart) * 0.05;
setZoomStack(prev => [...prev, { xDomain, yDomain }]); const xMin = u.scales.x.min, xMax = u.scales.x.max;
setXDomain([Math.max(fullXRange[0], tStart - pad), Math.min(fullXRange[1], tEnd + pad)]); setZoomStack(prev => [...prev, [xMin, xMax]]);
setYDomain(null); u.setScale('x', {
min: Math.max(fullXRange[0], tStart - pad),
max: Math.min(fullXRange[1], tEnd + pad),
});
} }
}, [data, selectedArr, xKey, fullXRange, xDomain, yDomain]); }, [columns, selectedArr, headers, xIdx, fullXRange]);
// 键盘监听需要使用 ref 保持最新 smartFit 引用
const smartFitRef = useRef(smartFit); const smartFitRef = useRef(smartFit);
smartFitRef.current = smartFit; smartFitRef.current = smartFit;
...@@ -303,97 +297,28 @@ export default function SimResultsModal({ csvData, modelName, onClose }) { ...@@ -303,97 +297,28 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, []); }, []);
// --- Recharts 框选 (仅左键,中键被 document capture 拦截) --- // --- 导出 PNG ---
const handleMouseDown = useCallback((e) => {
if (panRef.current.active) return; // 中键平移中,忽略
if (e?.activeLabel != null) { setRefAreaLeft(e.activeLabel); setIsDragging(true); }
}, []);
const handleMouseMove = useCallback((e) => {
if (isDragging && e?.activeLabel != null) setRefAreaRight(e.activeLabel);
}, [isDragging]);
const handleMouseUp = useCallback(() => {
if (!isDragging) return;
setIsDragging(false);
if (refAreaLeft != null && refAreaRight != null && refAreaLeft !== refAreaRight) {
const l = Math.min(refAreaLeft, refAreaRight), r = Math.max(refAreaLeft, refAreaRight);
setZoomStack(prev => [...prev, { xDomain, yDomain }]);
setXDomain([l, r]); setYDomain(null);
}
setRefAreaLeft(null); setRefAreaRight(null);
}, [isDragging, refAreaLeft, refAreaRight, xDomain, yDomain]);
// --- 滚轮缩放 ---
const handleWheel = useCallback((e) => {
e.preventDefault();
const cur = xDomainRef.current || fullXRangeRef.current;
const range = cur[1] - cur[0];
const fxr = fullXRangeRef.current;
const factor = e.deltaY > 0 ? 1.3 : 0.7;
const mid = (cur[0] + cur[1]) / 2;
const nr = range * factor;
const l = Math.max(fxr[0], mid - nr / 2), r = Math.min(fxr[1], mid + nr / 2);
if (r - l > (fxr[1] - fxr[0]) * 0.99) { setXDomain(null); setYDomain(null); }
else { setXDomain([l, r]); setYDomain(null); }
}, []);
// --- 工具栏 ---
const zoomIn = useCallback(() => {
const cur = xDomainRef.current || fullXRangeRef.current;
const range = cur[1] - cur[0], mid = (cur[0] + cur[1]) / 2, nr = range * 0.5;
setZoomStack(prev => [...prev, { xDomain: xDomainRef.current, yDomain }]);
setXDomain([mid - nr / 2, mid + nr / 2]); setYDomain(null);
}, [yDomain]);
const zoomOut = useCallback(() => {
const cur = xDomainRef.current || fullXRangeRef.current;
const fxr = fullXRangeRef.current;
const range = cur[1] - cur[0], mid = (cur[0] + cur[1]) / 2, nr = range * 2;
const l = Math.max(fxr[0], mid - nr / 2), r = Math.min(fxr[1], mid + nr / 2);
if (r - l >= (fxr[1] - fxr[0]) * 0.99) { setXDomain(null); setYDomain(null); }
else { setZoomStack(prev => [...prev, { xDomain: xDomainRef.current, yDomain }]); setXDomain([l, r]); setYDomain(null); }
}, [yDomain]);
const resetZoom = useCallback(() => { setXDomain(null); setYDomain(null); setZoomStack([]); }, []);
const undoZoom = useCallback(() => {
if (!zoomStack.length) return;
const prev = zoomStack[zoomStack.length - 1];
setZoomStack(s => s.slice(0, -1));
setXDomain(prev.xDomain); setYDomain(prev.yDomain);
}, [zoomStack]);
const exportPNG = useCallback(() => { const exportPNG = useCallback(() => {
const svg = chartWrapRef.current?.querySelector('svg'); const u = uplotRef.current;
if (!svg) return; if (!u) return;
const xml = new XMLSerializer().serializeToString(svg); const canvas = u.ctx.canvas;
const canvas = document.createElement('canvas'); const a = document.createElement('a');
const rect = svg.getBoundingClientRect(); a.download = `${modelName || 'SimResult'}_chart.png`;
canvas.width = rect.width * 2; canvas.height = rect.height * 2; a.href = canvas.toDataURL('image/png');
const ctx = canvas.getContext('2d'); a.click();
ctx.fillStyle = '#0f0f1a'; ctx.fillRect(0, 0, canvas.width, canvas.height);
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const a = document.createElement('a');
a.download = `${modelName || 'SimResult'}_chart.png`;
a.href = canvas.toDataURL('image/png'); a.click();
};
img.src = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(xml)));
}, [modelName]); }, [modelName]);
// --- 变量选择 ---
const toggleVar = useCallback((v) => setSelectedVars(prev => { const toggleVar = useCallback((v) => setSelectedVars(prev => {
const s = new Set(prev); s.has(v) ? s.delete(v) : s.add(v); return s; const s = new Set(prev); s.has(v) ? s.delete(v) : s.add(v); return s;
}), []); }), []);
const selectAll = useCallback(() => setSelectedVars(new Set(variables)), [variables]); const selectAll = useCallback(() => setSelectedVars(new Set(variables.map(v => v.name))), [variables]);
const selectNone = useCallback(() => setSelectedVars(new Set()), []); const selectNone = useCallback(() => setSelectedVars(new Set()), []);
// ===== 条件渲染区 (所有 hooks 已定义) ===== // ===== 条件渲染 =====
const hasData = data.length > 0 && variables.length > 0; const hasData = columns.length > 0 && columns[0]?.length > 0 && variables.length > 0;
const isZoomed = xDomain !== null;
if (hasData && selectedVars === null) return null; // 等待 useEffect 初始化 if (hasData && selectedVars === null) return null;
return ( return (
<div className={styles.overlay} onClick={onClose}> <div className={styles.overlay} onClick={onClose}>
...@@ -416,13 +341,13 @@ export default function SimResultsModal({ csvData, modelName, onClose }) { ...@@ -416,13 +341,13 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
) : ( ) : (
<div className={styles.body}> <div className={styles.body}>
<div className={styles.statsGrid}> <div className={styles.statsGrid}>
<div className={styles.statCard}><div className={styles.statLabel}>数据点</div><div className={styles.statValue}>{data.length}</div></div> <div className={styles.statCard}><div className={styles.statLabel}>数据点</div><div className={styles.statValue}>{columns[0].length}</div></div>
<div className={styles.statCard}><div className={styles.statLabel}>变量数</div><div className={styles.statValue}>{variables.length}</div></div> <div className={styles.statCard}><div className={styles.statLabel}>变量数</div><div className={styles.statValue}>{variables.length}</div></div>
<div className={styles.statCard}><div className={styles.statLabel}>已选</div><div className={styles.statValue}>{selectedArr.length}</div></div> <div className={styles.statCard}><div className={styles.statLabel}>已选</div><div className={styles.statValue}>{selectedArr.length}</div></div>
{data.length > 0 && ( {columns[xIdx]?.length > 0 && (
<div className={styles.statCard}> <div className={styles.statCard}>
<div className={styles.statLabel}>时间范围</div> <div className={styles.statLabel}>时间范围</div>
<div className={styles.statValue}>{data[0][xKey]?.toFixed(3)} ~ {data[data.length - 1][xKey]?.toFixed(3)}</div> <div className={styles.statValue}>{columns[xIdx][0]?.toFixed(3)} ~ {columns[xIdx][columns[xIdx].length - 1]?.toFixed(3)}</div>
</div> </div>
)} )}
</div> </div>
...@@ -434,8 +359,8 @@ export default function SimResultsModal({ csvData, modelName, onClose }) { ...@@ -434,8 +359,8 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
<ToolBtn icon="⟲" label="重置" onClick={resetZoom} disabled={!isZoomed} /> <ToolBtn icon="⟲" label="重置" onClick={resetZoom} disabled={!isZoomed} />
<div className={styles.toolSep} /> <div className={styles.toolSep} />
<ToolBtn icon="📷" label="导出PNG" onClick={exportPNG} /> <ToolBtn icon="📷" label="导出PNG" onClick={exportPNG} />
{isZoomed && <div className={styles.zoomBadge}>已缩放: {xDomain[0].toFixed(4)} ~ {xDomain[1].toFixed(4)}</div>} {isZoomed && zoomRange && <div className={styles.zoomBadge}>已缩放: {zoomRange[0].toFixed(4)} ~ {zoomRange[1].toFixed(4)}</div>}
<div className={styles.toolTip}>💡 拖拽框选放大 · 滚轮缩放 · 按住滚轮拖动 · 空格智能裁剪</div> <div className={styles.toolTip}>💡 拖拽框选放大 · 滚轮缩放 · 空格智能裁剪</div>
</div> </div>
<div className={styles.content}> <div className={styles.content}>
...@@ -450,60 +375,33 @@ export default function SimResultsModal({ csvData, modelName, onClose }) { ...@@ -450,60 +375,33 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
<div className={styles.varList}> <div className={styles.varList}>
{variables.map((v, i) => { {variables.map((v, i) => {
const color = COLORS[i % COLORS.length]; const color = COLORS[i % COLORS.length];
const checked = selected.has(v); const checked = selected.has(v.name);
return ( return (
<div key={v} className={styles.varItem} onClick={() => toggleVar(v)}> <div key={v.name} className={styles.varItem} onClick={() => toggleVar(v.name)}>
<div className={`${styles.varCheck} ${checked ? styles.checked : ''}`} <div className={`${styles.varCheck} ${checked ? styles.checked : ''}`}
style={checked ? { background: color, borderColor: color } : {}} /> style={checked ? { background: color, borderColor: color } : {}} />
<span className={styles.varDot} style={{ background: color }} /> <span className={styles.varDot} style={{ background: color }} />
<span className={styles.varName} title={v}>{toChinese(v)}</span> <span className={styles.varName} title={v.name}>{toChinese(v.name)}</span>
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
<div className={styles.chartArea} ref={chartWrapRef} onWheel={handleWheel}> <div className={styles.chartArea}>
{!selectedArr.length ? ( {!selectedArr.length ? (
<div className={styles.emptyChart}> <div className={styles.emptyChart}>
<div style={{ fontSize: 40, opacity: 0.3 }}>📈</div> <div style={{ fontSize: 40, opacity: 0.3 }}>📈</div>
<div>请在左侧选择要显示的变量</div> <div>请在左侧选择要显示的变量</div>
</div> </div>
) : ( ) : (
<ResponsiveContainer width="100%" height="100%"> <div ref={chartRef} style={{ width: '100%', height: '100%' }} />
<LineChart data={data} margin={{ top: 10, right: 30, left: 10, bottom: 10 }}
onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(79,138,255,0.1)" />
<XAxis dataKey={xKey} stroke="#555" tick={{ fill: '#9ca3af', fontSize: 10 }}
tickFormatter={v => typeof v === 'number' ? v.toFixed(3) : v}
type="number" domain={xDomain || ['dataMin', 'dataMax']} allowDataOverflow />
<YAxis stroke="#555" tick={{ fill: '#9ca3af', fontSize: 10 }}
tickFormatter={v => typeof v === 'number' ? v.toFixed(2) : v}
domain={effectiveYDomain || ['auto', 'auto']} allowDataOverflow />
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: 11, color: '#9ca3af' }} />
{data.length > 50 && (
<Brush dataKey={xKey} height={22} stroke="#4f8aff" fill="#0f0f1a"
tickFormatter={v => typeof v === 'number' ? v.toFixed(3) : v} />
)}
{selectedArr.map(vn => (
<Line key={vn} type="monotone" dataKey={vn} name={toChinese(vn)}
stroke={COLORS[variables.indexOf(vn) % COLORS.length]}
strokeWidth={2} dot={false} activeDot={{ r: 4, strokeWidth: 0 }} isAnimationActive={false} />
))}
{isDragging && refAreaLeft != null && refAreaRight != null && (
<ReferenceArea x1={refAreaLeft} x2={refAreaRight}
strokeOpacity={0.3} fill="rgba(79,138,255,0.2)" stroke="#4f8aff" />
)}
</LineChart>
</ResponsiveContainer>
)} )}
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
</div> </div>
); );
} }
...@@ -324,6 +324,55 @@ ...@@ -324,6 +324,55 @@
flex: 1; flex: 1;
min-width: 0; min-width: 0;
padding: 16px; padding: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* uPlot 容器样式 — 确保 legend 可见且不溢出 */
.chartArea :global(.uplot) {
display: flex;
flex-direction: column;
width: 100% !important;
flex: 1;
min-height: 0;
}
.chartArea :global(.uplot .u-wrap) {
flex: 1;
min-height: 0;
}
.chartArea :global(.uplot .u-legend) {
padding: 8px 4px;
font-size: 11px;
color: #9ca3af;
flex-wrap: wrap;
gap: 4px 12px;
flex-shrink: 0;
overflow-x: auto;
}
.chartArea :global(.uplot .u-legend .u-series) {
white-space: nowrap;
}
.chartArea :global(.uplot .u-legend .u-marker) {
width: 10px;
height: 3px;
border-radius: 1px;
}
.chartArea :global(.uplot .u-legend .u-value) {
font-weight: 600;
color: #e0e0e0;
}
/* 框选高亮区域 */
.chartArea :global(.uplot .u-select) {
background: rgba(79, 138, 255, 0.15) !important;
border-left: 1px solid rgba(79, 138, 255, 0.5);
border-right: 1px solid rgba(79, 138, 255, 0.5);
} }
/* 空状态 */ /* 空状态 */
...@@ -366,9 +415,3 @@ ...@@ -366,9 +415,3 @@
max-width: 400px; max-width: 400px;
line-height: 1.5; line-height: 1.5;
} }
/* Recharts 样式覆盖 */
.chartArea :global(.recharts-cartesian-grid-horizontal line),
.chartArea :global(.recharts-cartesian-grid-vertical line) {
stroke: rgba(79, 138, 255, 0.08);
}
/** /**
* SimResultsPage - 仿真结果图表查看器 * SimResultsPage - 仿真结果图表查看器
* 从 sessionStorage 读取 CSV 数据,解析并以交互式折线图展示 * 从 sessionStorage 读取 CSV 数据,解析并以交互式折线图展示
* 使用 uPlot (Canvas) 渲染
*/ */
import { useState, useMemo, useCallback } from 'react'; import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { import uPlot from 'uplot';
LineChart, Line, XAxis, YAxis, CartesianGrid, import 'uplot/dist/uPlot.min.css';
Tooltip, Legend, ResponsiveContainer, Brush,
} from 'recharts';
import styles from './SimResultsPage.module.css'; import styles from './SimResultsPage.module.css';
/** 预设调色板 — 高对比度暗色主题友好 */ /** 预设调色板 — 高对比度暗色主题友好 */
...@@ -16,82 +15,55 @@ const COLORS = [ ...@@ -16,82 +15,55 @@ const COLORS = [
'#a855f7', '#84cc16', '#e879f9', '#2dd4bf', '#fb923c', '#a855f7', '#84cc16', '#e879f9', '#2dd4bf', '#fb923c',
]; ];
/** 解析 CSV 字符串 → [{time, var1, var2, ...}, ...] */ /** 解析 CSV 字符串 → { headers, columns: [[time], [v1], [v2], ...] } */
function parseCSV(csv) { function parseCSV(csv) {
if (!csv || typeof csv !== 'string') return { headers: [], data: [] }; if (!csv || typeof csv !== 'string') return { headers: [], columns: [] };
const lines = csv.trim().split('\n').filter(l => l.trim()); const lines = csv.trim().split('\n').filter(l => l.trim());
if (lines.length < 2) return { headers: [], data: [] }; if (lines.length < 2) return { headers: [], columns: [] };
// 表头:可能带引号
const headers = lines[0].split(',').map(h => h.replace(/^"|"$/g, '').trim()); const headers = lines[0].split(',').map(h => h.replace(/^"|"$/g, '').trim());
const columns = headers.map(() => []);
const data = [];
for (let i = 1; i < lines.length; i++) { for (let i = 1; i < lines.length; i++) {
const vals = lines[i].split(','); const vals = lines[i].split(',');
if (vals.length !== headers.length) continue; if (vals.length !== headers.length) continue;
const row = {}; let ok = true;
const nums = [];
for (let j = 0; j < headers.length; j++) { for (let j = 0; j < headers.length; j++) {
row[headers[j]] = parseFloat(vals[j]) || 0; const n = parseFloat(vals[j]);
if (isNaN(n)) { ok = false; break; }
nums.push(n);
} }
data.push(row); if (ok) nums.forEach((n, j) => columns[j].push(n));
} }
return { headers, data }; return { headers, columns };
}
/** 自定义 Tooltip */
function CustomTooltip({ active, payload, label }) {
if (!active || !payload || payload.length === 0) return null;
return (
<div style={{
background: '#1e1e2e',
border: '1px solid #333',
borderRadius: 8,
padding: '10px 14px',
boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
}}>
<div style={{ fontSize: 11, color: '#888', marginBottom: 6 }}>
time = {typeof label === 'number' ? label.toFixed(6) : label}
</div>
{payload.map((entry, i) => (
<div key={i} style={{ fontSize: 12, color: entry.color, marginBottom: 2 }}>
{entry.name}: <strong>{typeof entry.value === 'number' ? entry.value.toFixed(6) : entry.value}</strong>
</div>
))}
</div>
);
} }
export default function SimResultsPage() { export default function SimResultsPage() {
const [selectedVars, setSelectedVars] = useState(new Set()); const [selectedVars, setSelectedVars] = useState(new Set());
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
const chartRef = useRef(null);
const uplotRef = useRef(null);
// 从 sessionStorage 读取 CSV const csvRaw = useMemo(() => sessionStorage.getItem('sim_csv_data') || '', []);
const csvRaw = useMemo(() => { const modelName = useMemo(() => sessionStorage.getItem('sim_model_name') || '未知模型', []);
return sessionStorage.getItem('sim_csv_data') || ''; const { headers, columns } = useMemo(() => parseCSV(csvRaw), [csvRaw]);
}, []);
const modelName = useMemo(() => {
return sessionStorage.getItem('sim_model_name') || '未知模型';
}, []);
const { headers, data } = useMemo(() => parseCSV(csvRaw), [csvRaw]);
// 变量列表(排除 time 列)
const variables = useMemo(() => {
const timeKey = headers.find(h => h.toLowerCase() === 'time');
return headers.filter(h => h !== timeKey);
}, [headers]);
// 第一个 header 作为 X 轴(通常是 time)
const xKey = useMemo(() => { const xKey = useMemo(() => {
return headers.find(h => h.toLowerCase() === 'time') || headers[0] || 'time'; const idx = headers.findIndex(h => h.toLowerCase() === 'time');
return idx >= 0 ? idx : 0;
}, [headers]); }, [headers]);
// 初始化:默认选中前 3 个变量 const variables = useMemo(() =>
headers.map((h, i) => ({ name: h, index: i })).filter((_, i) => i !== xKey),
[headers, xKey]
);
// 初始化默认选中前 3 个
useMemo(() => { useMemo(() => {
if (!initialized && variables.length > 0) { if (!initialized && variables.length > 0) {
setSelectedVars(new Set(variables.slice(0, Math.min(3, variables.length)))); setSelectedVars(new Set(variables.slice(0, Math.min(3, variables.length)).map(v => v.name)));
setInitialized(true); setInitialized(true);
} }
}, [variables, initialized]); }, [variables, initialized]);
...@@ -105,15 +77,84 @@ export default function SimResultsPage() { ...@@ -105,15 +77,84 @@ export default function SimResultsPage() {
}); });
}, []); }, []);
const selectAll = useCallback(() => { const selectAll = useCallback(() => setSelectedVars(new Set(variables.map(v => v.name))), [variables]);
setSelectedVars(new Set(variables)); const selectNone = useCallback(() => setSelectedVars(new Set()), []);
}, [variables]);
const selectNone = useCallback(() => { const selectedArr = useMemo(() => [...selectedVars], [selectedVars]);
setSelectedVars(new Set());
}, []); // uPlot 渲染
useEffect(() => {
const el = chartRef.current;
if (!el || !columns.length || !selectedArr.length) {
if (uplotRef.current) { uplotRef.current.destroy(); uplotRef.current = null; }
return;
}
// 构建 uPlot 数据: [timeArr, series1Arr, series2Arr, ...]
const timeData = columns[xKey];
const seriesData = [timeData];
const series = [{ label: '时间(s)' }];
for (const varName of selectedArr) {
const vi = headers.indexOf(varName);
if (vi < 0) continue;
seriesData.push(columns[vi]);
const ci = variables.findIndex(v => v.name === varName);
series.push({
label: varName,
stroke: COLORS[ci % COLORS.length],
width: 2,
});
}
const rect = el.getBoundingClientRect();
const opts = {
width: rect.width || 600,
height: rect.height || 300,
cursor: {
drag: { x: true, y: false, setScale: true },
},
scales: {
x: { time: false },
},
axes: [
{
stroke: '#888',
grid: { stroke: 'rgba(79,138,255,0.08)', width: 1 },
ticks: { stroke: '#333' },
font: '10px system-ui',
values: (u, vals) => vals.map(v => typeof v === 'number' ? v.toFixed(2) : v),
},
{
stroke: '#888',
grid: { stroke: 'rgba(79,138,255,0.08)', width: 1 },
ticks: { stroke: '#333' },
font: '10px system-ui',
values: (u, vals) => vals.map(v => typeof v === 'number' ? v.toFixed(2) : v),
},
],
series,
};
if (uplotRef.current) { uplotRef.current.destroy(); }
uplotRef.current = new uPlot(opts, seriesData, el);
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
if (uplotRef.current && width > 0 && height > 0) {
uplotRef.current.setSize({ width, height });
}
}
});
ro.observe(el);
return () => {
ro.disconnect();
if (uplotRef.current) { uplotRef.current.destroy(); uplotRef.current = null; }
};
}, [columns, selectedArr, headers, variables, xKey]);
// 无数据状态
if (!csvRaw) { if (!csvRaw) {
return ( return (
<div className={styles.page}> <div className={styles.page}>
...@@ -129,21 +170,15 @@ export default function SimResultsPage() { ...@@ -129,21 +170,15 @@ export default function SimResultsPage() {
); );
} }
const selectedArr = [...selectedVars];
return ( return (
<div className={styles.page}> <div className={styles.page}>
{/* 顶部栏 */}
<div className={styles.topBar}> <div className={styles.topBar}>
<button className={styles.backBtn} onClick={() => window.close()}> <button className={styles.backBtn} onClick={() => window.close()}>← 关闭</button>
← 关闭
</button>
<span className={styles.pageTitle}>📊 仿真结果</span> <span className={styles.pageTitle}>📊 仿真结果</span>
<span className={styles.pageSubtitle}>{modelName}</span> <span className={styles.pageSubtitle}>{modelName}</span>
</div> </div>
<div className={styles.main}> <div className={styles.main}>
{/* 左侧变量面板 */}
<div className={styles.varPanel}> <div className={styles.varPanel}>
<div className={styles.varHeader}> <div className={styles.varHeader}>
<span className={styles.varTitle}>变量 ({variables.length})</span> <span className={styles.varTitle}>变量 ({variables.length})</span>
...@@ -155,56 +190,38 @@ export default function SimResultsPage() { ...@@ -155,56 +190,38 @@ export default function SimResultsPage() {
<div className={styles.varList}> <div className={styles.varList}>
{variables.map((v, i) => { {variables.map((v, i) => {
const color = COLORS[i % COLORS.length]; const color = COLORS[i % COLORS.length];
const checked = selectedVars.has(v); const checked = selectedVars.has(v.name);
return ( return (
<div <div key={v.name} className={styles.varItem} onClick={() => toggleVar(v.name)}>
key={v}
className={styles.varItem}
onClick={() => toggleVar(v)}
>
<div <div
className={`${styles.varCheckbox} ${checked ? styles.checked : ''}`} className={`${styles.varCheckbox} ${checked ? styles.checked : ''}`}
style={checked ? { background: color, borderColor: color } : {}} style={checked ? { background: color, borderColor: color } : {}}
> >
{checked && ( {checked && <div className={styles.varCheckboxDot} style={{ background: '#fff', borderRadius: 1, width: 6, height: 6 }} />}
<div className={styles.varCheckboxDot} style={{ background: '#fff', borderRadius: 1, width: 6, height: 6 }} />
)}
</div> </div>
<span className={styles.varColorDot} style={{ background: color }} /> <span className={styles.varColorDot} style={{ background: color }} />
<span className={styles.varLabel} title={v}>{v}</span> <span className={styles.varLabel} title={v.name}>{v.name}</span>
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
{/* 图表区域 */}
<div className={styles.chartArea}> <div className={styles.chartArea}>
{/* 统计栏 */}
<div className={styles.statsBar}> <div className={styles.statsBar}>
<div className={styles.statCard}> <div className={styles.statCard}><div className={styles.statLabel}>数据点</div><div className={styles.statValue}>{columns[0]?.length || 0}</div></div>
<div className={styles.statLabel}>数据点</div> <div className={styles.statCard}><div className={styles.statLabel}>变量数</div><div className={styles.statValue}>{variables.length}</div></div>
<div className={styles.statValue}>{data.length}</div> <div className={styles.statCard}><div className={styles.statLabel}>已选变量</div><div className={styles.statValue}>{selectedArr.length}</div></div>
</div> {columns[xKey]?.length > 0 && (
<div className={styles.statCard}>
<div className={styles.statLabel}>变量数</div>
<div className={styles.statValue}>{variables.length}</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>已选变量</div>
<div className={styles.statValue}>{selectedArr.length}</div>
</div>
{data.length > 0 && (
<div className={styles.statCard}> <div className={styles.statCard}>
<div className={styles.statLabel}>时间范围</div> <div className={styles.statLabel}>时间范围</div>
<div className={styles.statValue}> <div className={styles.statValue}>
{data[0][xKey]?.toFixed(2)} ~ {data[data.length - 1][xKey]?.toFixed(2)} {columns[xKey][0]?.toFixed(2)} ~ {columns[xKey][columns[xKey].length - 1]?.toFixed(2)}
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* 图表 */}
<div className={styles.chartContainer}> <div className={styles.chartContainer}>
<div className={styles.chartTitle}>变量随时间变化曲线</div> <div className={styles.chartTitle}>变量随时间变化曲线</div>
{selectedArr.length === 0 ? ( {selectedArr.length === 0 ? (
...@@ -214,47 +231,7 @@ export default function SimResultsPage() { ...@@ -214,47 +231,7 @@ export default function SimResultsPage() {
<div className={styles.emptyHint}>勾选变量后,此处将绘制折线图</div> <div className={styles.emptyHint}>勾选变量后,此处将绘制折线图</div>
</div> </div>
) : ( ) : (
<div className={styles.chartWrapper}> <div className={styles.chartWrapper} ref={chartRef} />
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 10, right: 30, left: 10, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#2a2a3a" />
<XAxis
dataKey={xKey}
stroke="#555"
tick={{ fill: '#888', fontSize: 10 }}
tickFormatter={(v) => typeof v === 'number' ? v.toFixed(2) : v}
/>
<YAxis
stroke="#555"
tick={{ fill: '#888', fontSize: 10 }}
tickFormatter={(v) => typeof v === 'number' ? v.toFixed(2) : v}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{ fontSize: 11, color: '#888' }}
/>
<Brush
dataKey={xKey}
height={24}
stroke="#6366f1"
fill="#16161e"
tickFormatter={(v) => typeof v === 'number' ? v.toFixed(2) : v}
/>
{selectedArr.map((varName, i) => (
<Line
key={varName}
type="monotone"
dataKey={varName}
stroke={COLORS[variables.indexOf(varName) % COLORS.length]}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, strokeWidth: 0 }}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
)} )}
</div> </div>
</div> </div>
......
...@@ -283,18 +283,3 @@ ...@@ -283,18 +283,3 @@
font-size: 12px; font-size: 12px;
color: #666; color: #666;
} }
/* Recharts 样式覆盖 */
.chartWrapper :global(.recharts-cartesian-grid-horizontal line),
.chartWrapper :global(.recharts-cartesian-grid-vertical line) {
stroke: #2a2a3a;
}
.chartWrapper :global(.recharts-text) {
fill: #888;
font-size: 10px;
}
.chartWrapper :global(.recharts-tooltip-wrapper) {
outline: none;
}
/**
* useUPlot — 封装 uPlot 实例生命周期
*
* 用法:
* const { containerRef, uplotRef } = useUPlot(opts, data);
* return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
*
* 特性:
* - 自动 ResizeObserver 响应式
* - opts/data 变化时重建图表
* - 组件卸载时销毁实例
*/
import { useRef, useEffect, useCallback } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
/** 暗色主题默认配置(可被 opts 覆盖) */
export const DARK_THEME = {
axes: [
{
stroke: '#555',
grid: { stroke: 'rgba(79,138,255,0.08)', width: 1 },
ticks: { stroke: '#333', width: 1 },
font: '10px system-ui, sans-serif',
},
{
stroke: '#555',
grid: { stroke: 'rgba(79,138,255,0.08)', width: 1 },
ticks: { stroke: '#333', width: 1 },
font: '10px system-ui, sans-serif',
},
],
};
export default function useUPlot(opts, data) {
const containerRef = useRef(null);
const uplotRef = useRef(null);
// 销毁旧实例
const destroy = useCallback(() => {
if (uplotRef.current) {
uplotRef.current.destroy();
uplotRef.current = null;
}
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el || !opts || !data || data.length === 0) return;
destroy();
const rect = el.getBoundingClientRect();
const mergedOpts = {
width: rect.width || 600,
height: rect.height || 300,
...opts,
};
uplotRef.current = new uPlot(mergedOpts, data, el);
// ResizeObserver
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
if (uplotRef.current && width > 0 && height > 0) {
uplotRef.current.setSize({ width, height });
}
}
});
ro.observe(el);
return () => {
ro.disconnect();
destroy();
};
}, [opts, data, destroy]);
return { containerRef, uplotRef };
}
/**
* uPlot 悬浮 Tooltip 插件
*
* 在鼠标位置显示一个暗色主题浮窗,包含当前时间和各变量的值。
* 用法: 在 uPlot opts.plugins 中添加 tooltipPlugin()
*/
/** 创建 tooltip 插件 */
export default function tooltipPlugin() {
let tooltip;
function init(u) {
tooltip = document.createElement('div');
tooltip.className = 'u-tooltip-float';
tooltip.style.cssText = `
display: none;
position: absolute;
z-index: 9999;
pointer-events: none;
background: #1a1a2eee;
border: 1px solid rgba(79,138,255,0.3);
border-radius: 10px;
padding: 10px 14px;
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
font-family: system-ui, sans-serif;
font-size: 11px;
color: #e0e0e0;
white-space: nowrap;
max-width: 350px;
`;
u.over.appendChild(tooltip);
}
function setCursor(u) {
const { idx } = u.cursor;
if (idx == null || idx < 0) {
tooltip.style.display = 'none';
return;
}
// 构造 tooltip 内容
const xVal = u.data[0][idx];
const xLabel = u.series[0].label || 'x';
let html = `<div style="color:#9ca3af;margin-bottom:5px;font-size:10px">${xLabel} = ${typeof xVal === 'number' ? xVal.toFixed(6) : xVal}</div>`;
for (let i = 1; i < u.series.length; i++) {
const s = u.series[i];
if (!s.show) continue;
const val = u.data[i][idx];
const color = s._stroke || s.stroke || '#888';
const colorStr = typeof color === 'function' ? '#888' : color;
html += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px">`;
html += `<span style="width:10px;height:3px;border-radius:1px;background:${colorStr};flex-shrink:0"></span>`;
html += `<span style="color:${colorStr}">${s.label}:</span>`;
html += `<strong>${typeof val === 'number' ? val.toFixed(6) : val ?? '—'}</strong>`;
html += `</div>`;
}
tooltip.innerHTML = html;
tooltip.style.display = 'block';
// 定位 — 跟随鼠标,在右侧偏移;超出边界时翻转到左侧
const cx = u.cursor.left;
const cy = u.cursor.top;
const ow = u.over.clientWidth;
const tw = tooltip.offsetWidth;
const th = tooltip.offsetHeight;
const gapX = 16;
const gapY = -th / 2;
let tx = cx + gapX;
let ty = cy + gapY;
// 右侧溢出 → 翻转到左侧
if (tx + tw > ow) {
tx = cx - tw - gapX;
}
// 上边界
if (ty < 0) ty = 0;
// 下边界
const oh = u.over.clientHeight;
if (ty + th > oh) ty = oh - th;
tooltip.style.left = tx + 'px';
tooltip.style.top = ty + 'px';
}
return {
hooks: {
init,
setCursor,
},
};
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment