Commit cb2c87cb authored by Developer's avatar Developer

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

parent e65e953b
This diff is collapsed.
/**
* 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>
......
......@@ -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 {
......
......@@ -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);
}
......@@ -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;
}
/**
* 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