Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
E
EPlanVisualizer
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
嵇洲
EPlanVisualizer
Commits
cb2c87cb
Commit
cb2c87cb
authored
Mar 18, 2026
by
Developer
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
refactor: 图表库 Recharts→uPlot 迁移+悬浮tooltip+框选高亮
parent
e65e953b
Changes
10
Hide whitespace changes
Inline
Side-by-side
Showing
10 changed files
with
691 additions
and
851 deletions
+691
-851
package-lock.json
package-lock.json
+7
-344
package.json
package.json
+1
-1
LiveChart.jsx
src/components/SimResults/LiveChart.jsx
+109
-45
LiveChart.module.css
src/components/SimResults/LiveChart.module.css
+37
-2
SimResultsModal.jsx
src/components/SimResults/SimResultsModal.jsx
+197
-299
SimResultsModal.module.css
src/components/SimResults/SimResultsModal.module.css
+49
-6
SimResultsPage.jsx
src/components/SimResults/SimResultsPage.jsx
+116
-139
SimResultsPage.module.css
src/components/SimResults/SimResultsPage.module.css
+0
-15
useUPlot.js
src/hooks/useUPlot.js
+80
-0
uplotTooltipPlugin.js
src/utils/uplotTooltipPlugin.js
+95
-0
No files found.
package-lock.json
View file @
cb2c87cb
...
...
@@ -13,7 +13,7 @@
"react"
:
"^19.2.0"
,
"react-dom"
:
"^19.2.0"
,
"react-router-dom"
:
"^7.13.1"
,
"
recharts"
:
"^3.8.0
"
,
"
uplot"
:
"^1.6.32
"
,
"xlsx"
:
"^0.18.5"
,
"zustand"
:
"^5.0.11"
},
...
...
@@ -1012,42 +1012,6 @@
"@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"
:
{
"version"
:
"1.0.0-rc.3"
,
"resolved"
:
"https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz"
,
...
...
@@ -1405,18 +1369,6 @@
"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"
:
{
"version"
:
"7.20.5"
,
"resolved"
:
"https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz"
,
...
...
@@ -1462,12 +1414,6 @@
"@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"
:
{
"version"
:
"3.1.3"
,
"resolved"
:
"https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz"
,
...
...
@@ -1483,12 +1429,6 @@
"@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"
:
{
"version"
:
"3.0.4"
,
"resolved"
:
"https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz"
,
...
...
@@ -1498,48 +1438,12 @@
"@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"
:
{
"version"
:
"3.0.11"
,
"resolved"
:
"https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz"
,
"integrity"
:
"sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="
,
"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"
:
{
"version"
:
"3.0.9"
,
"resolved"
:
"https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz"
,
...
...
@@ -1593,12 +1497,6 @@
"@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"
:
{
"version"
:
"5.1.4"
,
"resolved"
:
"https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz"
,
...
...
@@ -1884,15 +1782,6 @@
"integrity"
:
"sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="
,
"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"
:
{
"version"
:
"1.15.0"
,
"resolved"
:
"https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz"
,
...
...
@@ -1983,18 +1872,6 @@
"devOptional"
:
true
,
"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"
:
{
"version"
:
"3.1.0"
,
"resolved"
:
"https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz"
,
...
...
@@ -2035,15 +1912,6 @@
"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"
:
{
"version"
:
"3.0.1"
,
"resolved"
:
"https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz"
,
...
...
@@ -2056,31 +1924,6 @@
"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"
:
{
"version"
:
"3.0.0"
,
"resolved"
:
"https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz"
,
...
...
@@ -2090,42 +1933,6 @@
"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"
:
{
"version"
:
"3.0.1"
,
"resolved"
:
"https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz"
,
...
...
@@ -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"
:
{
"version"
:
"0.1.4"
,
"resolved"
:
"https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz"
,
...
...
@@ -2218,16 +2019,6 @@
"dev"
:
true
,
"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"
:
{
"version"
:
"0.27.3"
,
"resolved"
:
"https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz"
,
...
...
@@ -2477,12 +2268,6 @@
"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"
:
{
"version"
:
"3.1.3"
,
"resolved"
:
"https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
,
...
...
@@ -2679,16 +2464,6 @@
"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"
:
{
"version"
:
"3.3.1"
,
"resolved"
:
"https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz"
,
...
...
@@ -2716,15 +2491,6 @@
"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"
:
{
"version"
:
"2.1.1"
,
"resolved"
:
"https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz"
,
...
...
@@ -3111,36 +2877,6 @@
"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"
:
{
"version"
:
"0.18.0"
,
"resolved"
:
"https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz"
,
...
...
@@ -3189,57 +2925,6 @@
"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"
:
{
"version"
:
"4.0.0"
,
"resolved"
:
"https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz"
,
...
...
@@ -3388,12 +3073,6 @@
"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"
:
{
"version"
:
"0.2.15"
,
"resolved"
:
"https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz"
,
...
...
@@ -3455,6 +3134,12 @@
"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"
:
{
"version"
:
"4.4.1"
,
"resolved"
:
"https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz"
,
...
...
@@ -3474,28 +3159,6 @@
"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"
:
{
"version"
:
"7.3.1"
,
"resolved"
:
"https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz"
,
...
...
package.json
View file @
cb2c87cb
...
...
@@ -15,7 +15,7 @@
"
react
"
:
"
^19.2.0
"
,
"
react-dom
"
:
"
^19.2.0
"
,
"
react-router-dom
"
:
"
^7.13.1
"
,
"
recharts
"
:
"
^3.8.0
"
,
"
uplot
"
:
"
^1.6.32
"
,
"
xlsx
"
:
"
^0.18.5
"
,
"
zustand
"
:
"
^5.0.11
"
},
...
...
src/components/SimResults/LiveChart.jsx
View file @
cb2c87cb
/**
* LiveChart — 实时仿真数据图表 + 硬件操控面板
*
* 使用 uPlot (Canvas) 渲染实时数据流。
* 通过 useRosBridge hook 接收 /hil/sim_data 话题的实时帧,
* 动态追加数据点并实时绘制。右侧嵌入硬件操控面板。
*
...
...
@@ -11,10 +12,9 @@
* onDone — 仿真完成回调
*/
import
{
useState
,
useEffect
,
useMemo
,
useCallback
,
useRef
}
from
'react'
;
import
{
LineChart
,
Line
,
XAxis
,
YAxis
,
CartesianGrid
,
Tooltip
,
ResponsiveContainer
,
}
from
'recharts'
;
import
uPlot
from
'uplot'
;
import
'uplot/dist/uPlot.min.css'
;
import
tooltipPlugin
from
'../../utils/uplotTooltipPlugin'
;
import
useRosBridge
from
'../../hooks/useRosBridge'
;
import
{
getHilPorts
}
from
'../../utils/api'
;
import
styles
from
'./LiveChart.module.css'
;
...
...
@@ -76,8 +76,10 @@ export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone })
const
[
hwPorts
,
setHwPorts
]
=
useState
([]);
const
[
hwValues
,
setHwValues
]
=
useState
({});
const
hwWsRef
=
useRef
(
null
);
const
chartRef
=
useRef
(
null
);
const
uplotRef
=
useRef
(
null
);
// 自动连接 rosbridge
(数据接收)
// 自动连接 rosbridge
useEffect
(()
=>
{
connect
(
rosBridgeUrl
||
'ws://localhost:9090'
);
return
()
=>
disconnect
();
...
...
@@ -100,23 +102,16 @@ export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone })
}).
catch
(()
=>
{});
},
[
sessionId
]);
// ── 硬件面板 WebSocket
(发布 override)
──
// ── 硬件面板 WebSocket ──
useEffect
(()
=>
{
if
(
!
hwPorts
.
length
)
return
;
const
ws
=
new
WebSocket
(
`ws://
${
window
.
location
.
hostname
}
:9090`
);
hwWsRef
.
current
=
ws
;
ws
.
onopen
=
()
=>
{
ws
.
send
(
JSON
.
stringify
({
op
:
'advertise'
,
topic
:
'/hil/user_override'
,
type
:
'std_msgs/String'
,
}));
ws
.
send
(
JSON
.
stringify
({
op
:
'advertise'
,
topic
:
'/hil/user_override'
,
type
:
'std_msgs/String'
}));
};
ws
.
onclose
=
()
=>
{
if
(
hwWsRef
.
current
===
ws
)
hwWsRef
.
current
=
null
;
};
ws
.
onerror
=
()
=>
{};
return
()
=>
{
if
(
ws
.
readyState
===
WebSocket
.
OPEN
)
{
ws
.
send
(
JSON
.
stringify
({
op
:
'unadvertise'
,
topic
:
'/hil/user_override'
}));
...
...
@@ -129,8 +124,7 @@ export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone })
const
ws
=
hwWsRef
.
current
;
if
(
!
ws
||
ws
.
readyState
!==
WebSocket
.
OPEN
)
return
;
ws
.
send
(
JSON
.
stringify
({
op
:
'publish'
,
topic
:
'/hil/user_override'
,
op
:
'publish'
,
topic
:
'/hil/user_override'
,
msg
:
{
data
:
JSON
.
stringify
({
component
,
value
})
},
}));
},
[]);
...
...
@@ -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
;
}),
[]);
// 转换为 uPlot 列式数据(限制最近 500 点)
const
displayData
=
useMemo
(()
=>
{
if
(
data
.
length
<=
500
)
return
data
;
return
data
.
slice
(
-
500
);
},
[
data
]);
const
sliced
=
data
.
length
<=
500
?
data
:
data
.
slice
(
-
500
);
if
(
!
sliced
.
length
||
!
selectedArr
.
length
)
return
null
;
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
(
<
div
className=
{
styles
.
overlay
}
onClick=
{
onClose
}
>
...
...
@@ -214,37 +302,13 @@ export default function LiveChart({ rosBridgeUrl, sessionId, onClose, onDone })
{
/* 图表区 */
}
<
div
className=
{
styles
.
chartArea
}
>
{
!
selectedArr
.
length
||
!
displayData
.
length
?
(
{
!
selectedArr
.
length
||
!
displayData
?
(
<
div
className=
{
styles
.
emptyChart
}
>
<
div
style=
{
{
fontSize
:
40
,
opacity
:
0.3
}
}
>
📡
</
div
>
<
div
>
{
status
===
'connected'
?
'等待数据…'
:
'连接 rosbridge 中…'
}
</
div
>
</
div
>
)
:
(
<
ResponsiveContainer
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
ref=
{
chartRef
}
style=
{
{
width
:
'100%'
,
height
:
'100%'
}
}
/>
)
}
</
div
>
...
...
src/components/SimResults/LiveChart.module.css
View file @
cb2c87cb
...
...
@@ -185,10 +185,45 @@
.chartArea
{
flex
:
1
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
flex-direction
:
column
;
padding
:
10px
;
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
{
...
...
src/components/SimResults/SimResultsModal.jsx
View file @
cb2c87cb
/**
* SimResultsModal - 仿真结果图表查看器(弹窗模式)
*
*
交互
:
* -
左键拖拽框选放大
*
使用 uPlot (Canvas) 渲染,支持
:
* -
拖拽框选放大(uPlot 内置)
* - 滚轮缩放 X 轴
* - 按住滚轮(中键)拖拽平移
* - 空格键自适应(重置)
* - 空格键智能裁剪
* - 工具栏: 放大/缩小/撤销/重置/导出 PNG
* - 变量名自动中文翻译
*/
import
{
useState
,
useMemo
,
useCallback
,
useRef
,
useEffect
}
from
'react'
;
import
{
LineChart
,
Line
,
XAxis
,
YAxis
,
CartesianGrid
,
Tooltip
,
Legend
,
ResponsiveContainer
,
Brush
,
ReferenceArea
,
}
from
'recharts'
;
import
uPlot
from
'uplot'
;
import
'uplot/dist/uPlot.min.css'
;
import
tooltipPlugin
from
'../../utils/uplotTooltipPlugin'
;
import
styles
from
'./SimResultsModal.module.css'
;
const
COLORS
=
[
...
...
@@ -51,46 +48,26 @@ function toChinese(name) {
return
pfx
+
cn
+
num
+
' '
+
(
PORT_CN
[
parts
[
1
]]
||
parts
[
1
])
+
(
QTY_CN
[
parts
[
2
]]
||
parts
[
2
]);
}
// ===== CSV 解析 =====
// ===== 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
());
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
data
=
[]
;
const
columns
=
headers
.
map
(()
=>
[])
;
for
(
let
i
=
1
;
i
<
lines
.
length
;
i
++
)
{
const
vals
=
lines
[
i
].
split
(
','
);
if
(
vals
.
length
!==
headers
.
length
)
continue
;
const
row
=
{};
let
ok
=
true
;
const
nums
=
[];
for
(
let
j
=
0
;
j
<
headers
.
length
;
j
++
)
{
const
n
=
parseFloat
(
vals
[
j
]);
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
};
}
// ===== 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
>
);
return
{
headers
,
columns
};
}
function
ToolBtn
({
icon
,
label
,
onClick
,
disabled
})
{
...
...
@@ -104,30 +81,28 @@ function ToolBtn({ icon, label, onClick, disabled }) {
// ===== 主组件 =====
export
default
function
SimResultsModal
({
csvData
,
modelName
,
onClose
})
{
// --- state ---
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
([]);
// --- refs ---
const
chartWrapRef
=
useRef
(
null
);
const
panRef
=
useRef
({
active
:
false
,
startX
:
0
,
startDomain
:
null
});
const
rafRef
=
useRef
(
null
);
const
xDomainRef
=
useRef
(
null
);
const
fullXRangeRef
=
useRef
([
0
,
1
]);
// --- derived data (all hooks before any conditional return) ---
const
{
headers
,
data
}
=
useMemo
(()
=>
parseCSV
(
csvData
),
[
csvData
]);
const
xKey
=
useMemo
(()
=>
headers
.
find
(
h
=>
h
.
toLowerCase
()
===
'time'
)
||
headers
[
0
]
||
'time'
,
[
headers
]);
const
variables
=
useMemo
(()
=>
headers
.
filter
(
h
=>
h
!==
xKey
),
[
headers
,
xKey
]);
const
[
isZoomed
,
setIsZoomed
]
=
useState
(
false
);
const
[
zoomRange
,
setZoomRange
]
=
useState
(
null
);
// [xMin, xMax] for display
const
chartRef
=
useRef
(
null
);
const
uplotRef
=
useRef
(
null
);
// --- derived data ---
const
{
headers
,
columns
}
=
useMemo
(()
=>
parseCSV
(
csvData
),
[
csvData
]);
const
xIdx
=
useMemo
(()
=>
{
const
idx
=
headers
.
findIndex
(
h
=>
h
.
toLowerCase
()
===
'time'
);
return
idx
>=
0
?
idx
:
0
;
},
[
headers
]);
const
xKey
=
headers
[
xIdx
]
||
'time'
;
const
variables
=
useMemo
(()
=>
headers
.
map
((
h
,
i
)
=>
({
name
:
h
,
index
:
i
})).
filter
((
_
,
i
)
=>
i
!==
xIdx
),
[
headers
,
xIdx
]);
useEffect
(()
=>
{
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
...
...
@@ -135,159 +110,178 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
const
selectedArr
=
useMemo
(()
=>
[...
selected
],
[
selected
]);
const
fullXRange
=
useMemo
(()
=>
{
if
(
data
.
length
===
0
)
return
[
0
,
1
];
return
[
data
[
0
][
xKey
],
data
[
data
.
length
-
1
][
xKey
]];
},
[
data
,
xKey
]);
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 元素引用
const
tc
=
columns
[
xIdx
];
if
(
!
tc
||
tc
.
length
===
0
)
return
[
0
,
1
];
return
[
tc
[
0
],
tc
[
tc
.
length
-
1
]];
},
[
columns
,
xIdx
]);
// --- uPlot 渲染 ---
useEffect
(()
=>
{
const
el
=
chartWrapRef
.
current
;
if
(
!
el
)
return
;
// 创建图标 DOM 元素(不走 React 渲染)
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
el
=
chartRef
.
current
;
if
(
!
el
||
!
columns
.
length
||
!
selectedArr
.
length
)
{
if
(
uplotRef
.
current
)
{
uplotRef
.
current
.
destroy
();
uplotRef
.
current
=
null
;
}
return
;
}
const
moveIcon
=
(
x
,
y
)
=>
{
if
(
panIconRef
.
current
)
{
panIconRef
.
current
.
style
.
left
=
(
x
-
16
)
+
'px'
;
panIconRef
.
current
.
style
.
top
=
(
y
-
16
)
+
'px'
;
}
};
const
timeData
=
columns
[
xIdx
];
const
uData
=
[
timeData
];
const
series
=
[{
label
:
'时间(s)'
}];
const
removeIcon
=
()
=>
{
if
(
panIconRef
.
current
)
{
panIconRef
.
current
.
remove
();
panIconRef
.
current
=
null
;
}
};
for
(
const
varName
of
selectedArr
)
{
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
)
=>
{
if
(
e
.
button
!==
1
||
!
el
.
contains
(
e
.
target
))
return
;
e
.
preventDefault
();
panRef
.
current
=
{
active
:
true
,
startX
:
e
.
clientX
,
startDomain
:
xDomainRef
.
current
||
fullXRangeRef
.
current
};
el
.
style
.
cursor
=
'grabbing'
;
createIcon
(
e
.
clientX
,
e
.
clientY
);
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
},
},
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
(
!
panRef
.
current
.
active
)
return
;
// 移动图标跟随鼠标
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
);
});
};
if
(
uplotRef
.
current
)
{
uplotRef
.
current
.
destroy
();
}
uplotRef
.
current
=
new
uPlot
(
opts
,
uData
,
el
);
const
onUp
=
(
e
)
=>
{
if
(
e
.
button
===
1
&&
panRef
.
current
.
active
)
{
panRef
.
current
.
active
=
false
;
el
.
style
.
cursor
=
''
;
removeIcon
(
);
if
(
rafRef
.
current
)
{
cancelAnimationFrame
(
rafRef
.
current
);
rafRef
.
current
=
null
;
}
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
);
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
()
=>
{
el
.
removeEventListener
(
'mousedown'
,
onDown
,
true
);
document
.
removeEventListener
(
'mousemove'
,
onMove
);
document
.
removeEventListener
(
'mouseup'
,
onUp
);
el
.
removeEventListener
(
'contextmenu'
,
onCtx
);
removeIcon
();
if
(
rafRef
.
current
)
cancelAnimationFrame
(
rafRef
.
current
);
ro
.
disconnect
();
if
(
uplotRef
.
current
)
{
uplotRef
.
current
.
destroy
();
uplotRef
.
current
=
null
;
}
};
},
[
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
(()
=>
{
if
(
!
data
.
length
||
!
selectedArr
.
length
)
return
;
// 找到数据开始变化和结束变化的时间点
let
firstChangeIdx
=
d
ata
.
length
-
1
;
if
(
!
columns
.
length
||
!
selectedArr
.
length
)
return
;
const
timeData
=
columns
[
xIdx
];
let
firstChangeIdx
=
timeD
ata
.
length
-
1
;
let
lastChangeIdx
=
0
;
for
(
const
varName
of
selectedArr
)
{
const
baseVal
=
data
[
0
][
varName
];
if
(
baseVal
===
undefined
)
continue
;
for
(
let
i
=
1
;
i
<
data
.
length
;
i
++
)
{
const
val
=
data
[
i
][
varName
];
if
(
Math
.
abs
(
val
-
baseVal
)
>
Math
.
abs
(
baseVal
)
*
0.001
+
1
e
-
10
)
{
const
vi
=
headers
.
indexOf
(
varName
);
if
(
vi
<
0
)
continue
;
const
col
=
columns
[
vi
];
const
baseVal
=
col
[
0
];
for
(
let
i
=
1
;
i
<
col
.
length
;
i
++
)
{
if
(
Math
.
abs
(
col
[
i
]
-
baseVal
)
>
Math
.
abs
(
baseVal
)
*
0.001
+
1
e
-
10
)
{
firstChangeIdx
=
Math
.
min
(
firstChangeIdx
,
Math
.
max
(
0
,
i
-
1
));
break
;
}
}
// 从后往前找最后变化点
const
endVal
=
data
[
data
.
length
-
1
][
varName
];
for
(
let
i
=
data
.
length
-
2
;
i
>=
0
;
i
--
)
{
const
val
=
data
[
i
][
varName
];
if
(
Math
.
abs
(
val
-
endVal
)
>
Math
.
abs
(
endVal
)
*
0.001
+
1
e
-
10
)
{
lastChangeIdx
=
Math
.
max
(
lastChangeIdx
,
Math
.
min
(
data
.
length
-
1
,
i
+
1
));
const
endVal
=
col
[
col
.
length
-
1
];
for
(
let
i
=
col
.
length
-
2
;
i
>=
0
;
i
--
)
{
if
(
Math
.
abs
(
col
[
i
]
-
endVal
)
>
Math
.
abs
(
endVal
)
*
0.001
+
1
e
-
10
)
{
lastChangeIdx
=
Math
.
max
(
lastChangeIdx
,
Math
.
min
(
col
.
length
-
1
,
i
+
1
));
break
;
}
}
}
const
u
=
uplotRef
.
current
;
if
(
!
u
)
return
;
if
(
firstChangeIdx
>=
lastChangeIdx
)
{
// 没有变化或整段都在变化,显示全部
setXDomain
(
null
);
setYDomain
(
null
);
u
.
setScale
(
'x'
,
{
min
:
fullXRange
[
0
],
max
:
fullXRange
[
1
]
});
}
else
{
// 留一点 padding
const
tStart
=
data
[
firstChangeIdx
][
xKey
];
const
tEnd
=
data
[
lastChangeIdx
][
xKey
];
const
tStart
=
timeData
[
firstChangeIdx
];
const
tEnd
=
timeData
[
lastChangeIdx
];
const
pad
=
(
tEnd
-
tStart
)
*
0.05
;
setZoomStack
(
prev
=>
[...
prev
,
{
xDomain
,
yDomain
}]);
setXDomain
([
Math
.
max
(
fullXRange
[
0
],
tStart
-
pad
),
Math
.
min
(
fullXRange
[
1
],
tEnd
+
pad
)]);
setYDomain
(
null
);
const
xMin
=
u
.
scales
.
x
.
min
,
xMax
=
u
.
scales
.
x
.
max
;
setZoomStack
(
prev
=>
[...
prev
,
[
xMin
,
xMax
]]);
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
);
smartFitRef
.
current
=
smartFit
;
...
...
@@ -303,97 +297,28 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
return
()
=>
window
.
removeEventListener
(
'keydown'
,
onKey
);
},
[]);
// --- Recharts 框选 (仅左键,中键被 document capture 拦截) ---
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
]);
// --- 导出 PNG ---
const
exportPNG
=
useCallback
(()
=>
{
const
svg
=
chartWrapRef
.
current
?.
querySelector
(
'svg'
);
if
(
!
svg
)
return
;
const
xml
=
new
XMLSerializer
().
serializeToString
(
svg
);
const
canvas
=
document
.
createElement
(
'canvas'
);
const
rect
=
svg
.
getBoundingClientRect
();
canvas
.
width
=
rect
.
width
*
2
;
canvas
.
height
=
rect
.
height
*
2
;
const
ctx
=
canvas
.
getContext
(
'2d'
);
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
)));
const
u
=
uplotRef
.
current
;
if
(
!
u
)
return
;
const
canvas
=
u
.
ctx
.
canvas
;
const
a
=
document
.
createElement
(
'a'
);
a
.
download
=
`
${
modelName
||
'SimResult'
}
_chart.png`
;
a
.
href
=
canvas
.
toDataURL
(
'image/png'
);
a
.
click
();
},
[
modelName
]);
// --- 变量选择 ---
const
toggleVar
=
useCallback
((
v
)
=>
setSelectedVars
(
prev
=>
{
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
()),
[]);
// ===== 条件渲染区 (所有 hooks 已定义) =====
const
hasData
=
data
.
length
>
0
&&
variables
.
length
>
0
;
const
isZoomed
=
xDomain
!==
null
;
// ===== 条件渲染 =====
const
hasData
=
columns
.
length
>
0
&&
columns
[
0
]?.
length
>
0
&&
variables
.
length
>
0
;
if
(
hasData
&&
selectedVars
===
null
)
return
null
;
// 等待 useEffect 初始化
if
(
hasData
&&
selectedVars
===
null
)
return
null
;
return
(
<
div
className=
{
styles
.
overlay
}
onClick=
{
onClose
}
>
...
...
@@ -416,13 +341,13 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
)
:
(
<
div
className=
{
styles
.
body
}
>
<
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
}
>
{
selectedArr
.
length
}
</
div
></
div
>
{
data
.
length
>
0
&&
(
{
columns
[
xIdx
]?
.
length
>
0
&&
(
<
div
className=
{
styles
.
statCard
}
>
<
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
>
...
...
@@ -434,8 +359,8 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
<
ToolBtn
icon=
"⟲"
label=
"重置"
onClick=
{
resetZoom
}
disabled=
{
!
isZoomed
}
/>
<
div
className=
{
styles
.
toolSep
}
/>
<
ToolBtn
icon=
"📷"
label=
"导出PNG"
onClick=
{
exportPNG
}
/>
{
isZoomed
&&
<
div
className=
{
styles
.
zoomBadge
}
>
已缩放:
{
xDomain
[
0
].
toFixed
(
4
)
}
~
{
xDomain
[
1
].
toFixed
(
4
)
}
</
div
>
}
<
div
className=
{
styles
.
toolTip
}
>
💡 拖拽框选放大 · 滚轮缩放 ·
按住滚轮拖动 ·
空格智能裁剪
</
div
>
{
isZoomed
&&
zoomRange
&&
<
div
className=
{
styles
.
zoomBadge
}
>
已缩放:
{
zoomRange
[
0
].
toFixed
(
4
)
}
~
{
zoomRange
[
1
].
toFixed
(
4
)
}
</
div
>
}
<
div
className=
{
styles
.
toolTip
}
>
💡 拖拽框选放大 · 滚轮缩放 · 空格智能裁剪
</
div
>
</
div
>
<
div
className=
{
styles
.
content
}
>
...
...
@@ -450,60 +375,33 @@ export default function SimResultsModal({ csvData, modelName, onClose }) {
<
div
className=
{
styles
.
varList
}
>
{
variables
.
map
((
v
,
i
)
=>
{
const
color
=
COLORS
[
i
%
COLORS
.
length
];
const
checked
=
selected
.
has
(
v
);
const
checked
=
selected
.
has
(
v
.
name
);
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 : ''}`
}
style=
{
checked
?
{
background
:
color
,
borderColor
:
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
className=
{
styles
.
chartArea
}
ref=
{
chartWrapRef
}
onWheel=
{
handleWheel
}
>
<
div
className=
{
styles
.
chartArea
}
>
{
!
selectedArr
.
length
?
(
<
div
className=
{
styles
.
emptyChart
}
>
<
div
style=
{
{
fontSize
:
40
,
opacity
:
0.3
}
}
>
📈
</
div
>
<
div
>
请在左侧选择要显示的变量
</
div
>
</
div
>
)
:
(
<
ResponsiveContainer
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
ref=
{
chartRef
}
style=
{
{
width
:
'100%'
,
height
:
'100%'
}
}
/>
)
}
</
div
>
</
div
>
</
div
>
)
}
</
div
>
</
div
>
);
}
src/components/SimResults/SimResultsModal.module.css
View file @
cb2c87cb
...
...
@@ -324,6 +324,55 @@
flex
:
1
;
min-width
:
0
;
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 @@
max-width
:
400px
;
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
);
}
src/components/SimResults/SimResultsPage.jsx
View file @
cb2c87cb
/**
* SimResultsPage - 仿真结果图表查看器
* 从 sessionStorage 读取 CSV 数据,解析并以交互式折线图展示
* 使用 uPlot (Canvas) 渲染
*/
import
{
useState
,
useMemo
,
useCallback
}
from
'react'
;
import
{
LineChart
,
Line
,
XAxis
,
YAxis
,
CartesianGrid
,
Tooltip
,
Legend
,
ResponsiveContainer
,
Brush
,
}
from
'recharts'
;
import
{
useState
,
useMemo
,
useCallback
,
useRef
,
useEffect
}
from
'react'
;
import
uPlot
from
'uplot'
;
import
'uplot/dist/uPlot.min.css'
;
import
styles
from
'./SimResultsPage.module.css'
;
/** 预设调色板 — 高对比度暗色主题友好 */
...
...
@@ -16,82 +15,55 @@ const COLORS = [
'#a855f7'
,
'#84cc16'
,
'#e879f9'
,
'#2dd4bf'
,
'#fb923c'
,
];
/** 解析 CSV 字符串 →
[{time, var1, var2, ...}, ...]
*/
/** 解析 CSV 字符串 →
{ headers, columns: [[time], [v1], [v2], ...] }
*/
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
());
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
columns
=
headers
.
map
(()
=>
[]);
const
data
=
[];
for
(
let
i
=
1
;
i
<
lines
.
length
;
i
++
)
{
const
vals
=
lines
[
i
].
split
(
','
);
if
(
vals
.
length
!==
headers
.
length
)
continue
;
const
row
=
{};
let
ok
=
true
;
const
nums
=
[];
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
};
}
/** 自定义 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
>
);
return
{
headers
,
columns
};
}
export
default
function
SimResultsPage
()
{
const
[
selectedVars
,
setSelectedVars
]
=
useState
(
new
Set
());
const
[
initialized
,
setInitialized
]
=
useState
(
false
);
const
chartRef
=
useRef
(
null
);
const
uplotRef
=
useRef
(
null
);
// 从 sessionStorage 读取 CSV
const
csvRaw
=
useMemo
(()
=>
{
return
sessionStorage
.
getItem
(
'sim_csv_data'
)
||
''
;
},
[]);
const
modelName
=
useMemo
(()
=>
{
return
sessionStorage
.
getItem
(
'sim_model_name'
)
||
'未知模型'
;
},
[]);
const
{
headers
,
data
}
=
useMemo
(()
=>
parseCSV
(
csvRaw
),
[
csvRaw
]);
const
csvRaw
=
useMemo
(()
=>
sessionStorage
.
getItem
(
'sim_csv_data'
)
||
''
,
[]);
const
modelName
=
useMemo
(()
=>
sessionStorage
.
getItem
(
'sim_model_name'
)
||
'未知模型'
,
[]);
const
{
headers
,
columns
}
=
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
(()
=>
{
return
headers
.
find
(
h
=>
h
.
toLowerCase
()
===
'time'
)
||
headers
[
0
]
||
'time'
;
const
idx
=
headers
.
findIndex
(
h
=>
h
.
toLowerCase
()
===
'time'
);
return
idx
>=
0
?
idx
:
0
;
},
[
headers
]);
// 初始化:默认选中前 3 个变量
const
variables
=
useMemo
(()
=>
headers
.
map
((
h
,
i
)
=>
({
name
:
h
,
index
:
i
})).
filter
((
_
,
i
)
=>
i
!==
xKey
),
[
headers
,
xKey
]
);
// 初始化默认选中前 3 个
useMemo
(()
=>
{
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
);
}
},
[
variables
,
initialized
]);
...
...
@@ -105,15 +77,84 @@ export default function SimResultsPage() {
});
},
[]);
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
());
},
[]);
const
selectedArr
=
useMemo
(()
=>
[...
selectedVars
],
[
selectedVars
]);
// 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
)
{
return
(
<
div
className=
{
styles
.
page
}
>
...
...
@@ -129,21 +170,15 @@ export default function SimResultsPage() {
);
}
const
selectedArr
=
[...
selectedVars
];
return
(
<
div
className=
{
styles
.
page
}
>
{
/* 顶部栏 */
}
<
div
className=
{
styles
.
topBar
}
>
<
button
className=
{
styles
.
backBtn
}
onClick=
{
()
=>
window
.
close
()
}
>
← 关闭
</
button
>
<
button
className=
{
styles
.
backBtn
}
onClick=
{
()
=>
window
.
close
()
}
>
← 关闭
</
button
>
<
span
className=
{
styles
.
pageTitle
}
>
📊 仿真结果
</
span
>
<
span
className=
{
styles
.
pageSubtitle
}
>
{
modelName
}
</
span
>
</
div
>
<
div
className=
{
styles
.
main
}
>
{
/* 左侧变量面板 */
}
<
div
className=
{
styles
.
varPanel
}
>
<
div
className=
{
styles
.
varHeader
}
>
<
span
className=
{
styles
.
varTitle
}
>
变量 (
{
variables
.
length
}
)
</
span
>
...
...
@@ -155,56 +190,38 @@ export default function SimResultsPage() {
<
div
className=
{
styles
.
varList
}
>
{
variables
.
map
((
v
,
i
)
=>
{
const
color
=
COLORS
[
i
%
COLORS
.
length
];
const
checked
=
selectedVars
.
has
(
v
);
const
checked
=
selectedVars
.
has
(
v
.
name
);
return
(
<
div
key=
{
v
}
className=
{
styles
.
varItem
}
onClick=
{
()
=>
toggleVar
(
v
)
}
>
<
div
key=
{
v
.
name
}
className=
{
styles
.
varItem
}
onClick=
{
()
=>
toggleVar
(
v
.
name
)
}
>
<
div
className=
{
`${styles.varCheckbox} ${checked ? styles.checked : ''}`
}
style=
{
checked
?
{
background
:
color
,
borderColor
:
color
}
:
{}
}
>
{
checked
&&
(
<
div
className=
{
styles
.
varCheckboxDot
}
style=
{
{
background
:
'#fff'
,
borderRadius
:
1
,
width
:
6
,
height
:
6
}
}
/>
)
}
{
checked
&&
<
div
className=
{
styles
.
varCheckboxDot
}
style=
{
{
background
:
'#fff'
,
borderRadius
:
1
,
width
:
6
,
height
:
6
}
}
/>
}
</
div
>
<
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
className=
{
styles
.
chartArea
}
>
{
/* 统计栏 */
}
<
div
className=
{
styles
.
statsBar
}
>
<
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
}
>
{
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
.
statLabel
}
>
数据点
</
div
><
div
className=
{
styles
.
statValue
}
>
{
columns
[
0
]?.
length
||
0
}
</
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
>
{
columns
[
xKey
]?.
length
>
0
&&
(
<
div
className=
{
styles
.
statCard
}
>
<
div
className=
{
styles
.
statLabel
}
>
时间范围
</
div
>
<
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
className=
{
styles
.
chartContainer
}
>
<
div
className=
{
styles
.
chartTitle
}
>
变量随时间变化曲线
</
div
>
{
selectedArr
.
length
===
0
?
(
...
...
@@ -214,47 +231,7 @@ export default function SimResultsPage() {
<
div
className=
{
styles
.
emptyHint
}
>
勾选变量后,此处将绘制折线图
</
div
>
</
div
>
)
:
(
<
div
className=
{
styles
.
chartWrapper
}
>
<
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
className=
{
styles
.
chartWrapper
}
ref=
{
chartRef
}
/>
)
}
</
div
>
</
div
>
...
...
src/components/SimResults/SimResultsPage.module.css
View file @
cb2c87cb
...
...
@@ -283,18 +283,3 @@
font-size
:
12px
;
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
;
}
src/hooks/useUPlot.js
0 → 100644
View file @
cb2c87cb
/**
* 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
};
}
src/utils/uplotTooltipPlugin.js
0 → 100644
View file @
cb2c87cb
/**
* 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
,
},
};
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment