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
Expand all
Show 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
This diff is collapsed.
Click to expand it.
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
This diff is collapsed.
Click to expand it.
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
This diff is collapsed.
Click to expand it.
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