Commit 7a5f4fdf authored by Developer's avatar Developer

feat(hil): 实时推送仿真数据 — WebSocket + rosbridge

- 新增 useRosBridge hook: 原生 WebSocket 连接 rosbridge_server
  - 订阅 /hil/sim_data (JSON 数据帧) + /hil/done (完成信号)
  - requestAnimationFrame 节流, MAX_POINTS=5000
- 新增 LiveChart 组件: Recharts 实时折线图, 滚动窗口 500 点
- ProjectPanel: HIL 运行中显示「📈 实时数据」按钮
- useProjectStore: 移除 HTTP 轮询, 新增 hilDone() 方法
  通过 WebSocket /hil/done 信号自动检测仿真完成
parent 6f2731f4
......@@ -6,6 +6,7 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import useProjectStore from '../../hooks/useProjectStore';
import useFlowStore from '../../hooks/useFlowStore';
import SimResultsModal from '../SimResults/SimResultsModal';
import LiveChart from '../SimResults/LiveChart';
import styles from './ProjectPanel.module.css';
function formatTime(ts) {
......@@ -45,6 +46,7 @@ export default function ProjectPanel() {
executeProject,
startHil,
stopHil,
hilDone,
fetchHilResults,
} = useProjectStore();
......@@ -55,7 +57,21 @@ export default function ProjectPanel() {
const [showExecuteLog, setShowExecuteLog] = useState(false);
const [showResultsMode, setShowResultsMode] = useState(null); // null | 'sim' | 'hil'
const [simDuration, setSimDuration] = useState(1);
const [hilDuration, setHilDuration] = useState(10);
const [hilDuration, setHilDuration] = useState(1);
const [toastMsg, setToastMsg] = useState(null);
const [toastFading, setToastFading] = useState(false);
const [showLiveChart, setShowLiveChart] = useState(false);
const toastTimerRef = useRef(null);
const showToast = useCallback((msg) => {
setToastMsg(msg);
setToastFading(false);
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
toastTimerRef.current = setTimeout(() => {
setToastFading(true);
setTimeout(() => { setToastMsg(null); setToastFading(false); }, 400);
}, 2000);
}, []);
const activeProject = projects.find(p => p.id === activeProjectId) || null;
......@@ -77,7 +93,7 @@ export default function ProjectPanel() {
const curr = activeProject?.hilStatus;
prevHilStatusRef.current = curr;
if (prev === 'running' && curr === 'done') {
alert('✅ 半实物仿真已完成!点击「📊 查看 HIL 结果」可查看数据。');
showToast('✅ 半实物仿真已完成!点击「📊 查看 HIL 结果」可查看数据。');
}
}, [activeProject?.hilStatus]);
......@@ -134,6 +150,14 @@ export default function ProjectPanel() {
return (
<div className={styles.panel}>
{/* 悬浮 Toast 通知 */}
{toastMsg && (
<div className={`${styles.toast} ${toastFading ? styles.toastFading : ''}`}>
<span className={styles.toastText}>{toastMsg}</span>
<button className={styles.toastClose} onClick={() => { setToastMsg(null); setToastFading(false); }}></button>
</div>
)}
{/* 头部 */}
<div className={styles.header}>
<span className={styles.headerTitle}>📁 项目管理</span>
......@@ -348,16 +372,28 @@ export default function ProjectPanel() {
)}
{/* 停止按钮 */}
{p.hilStatus === 'running' && (
<div style={{ display: 'flex', gap: 4, width: '100%', marginTop: 4 }}>
<button
className={`${styles.simBtn}`}
style={{
flex: 1,
background: 'rgba(79,138,255,0.08)',
borderColor: 'rgba(79,138,255,0.3)',
color: '#4f8aff',
}}
onClick={(e) => { e.stopPropagation(); setShowLiveChart(true); }}
>📈 实时数据</button>
<button
className={`${styles.simBtn}`}
style={{
width: '100%', marginTop: 4,
flex: 1,
background: 'rgba(239,68,68,0.08)',
borderColor: 'rgba(239,68,68,0.3)',
color: '#ef4444',
}}
onClick={(e) => { e.stopPropagation(); stopHil(p.id); }}
>⏹ 停止仿真</button>
</div>
)}
</div>
)}
......@@ -436,6 +472,18 @@ export default function ProjectPanel() {
onClose={() => setShowResultsMode(null)}
/>
)}
{/* 实时图表弹窗 */}
{showLiveChart && (
<LiveChart
rosBridgeUrl={`ws://${window.location.hostname}:9090`}
onClose={() => setShowLiveChart(false)}
onDone={() => {
const ap = activeProject;
if (ap) hilDone(ap.id);
}}
/>
)}
</div>
);
}
/**
* LiveChart — 实时仿真数据图表
*
* 通过 useRosBridge hook 接收 /hil/sim_data 话题的实时帧,
* 动态追加数据点并实时绘制。
*
* Props:
* rosBridgeUrl — rosbridge WebSocket 地址 (默认 ws://localhost:9090)
* onClose — 关闭回调
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, ResponsiveContainer,
} from 'recharts';
import useRosBridge from '../../hooks/useRosBridge';
import styles from './LiveChart.module.css';
const COLORS = [
'#4f8aff', '#22c55e', '#f59e0b', '#ef4444', '#00d4ff',
'#a855f7', '#ec4899', '#14b8a6', '#f97316', '#64748b',
];
// 简易中文映射 (复用 SimResultsModal 的逻辑)
const COMP_CN = {
capacitor: '电容', resistor: '电阻', inductor: '电感',
voltagesource: '电压源', currentsource: '电流源', ground: '接地',
diode: '二极管', switch: '开关',
};
const QTY_CN = { v: '电压(V)', i: '电流(A)' };
function toChinese(name) {
if (!name) return name;
if (name.toLowerCase() === 'time') return '时间(s)';
const parts = name.split('.');
const cm = parts[0].match(/^(.+?)_(\d+)$/);
let cn = parts[0], num = '';
if (cm) { cn = COMP_CN[cm[1].toLowerCase()] || cm[1]; num = cm[2]; }
if (parts.length === 1) return cn + num;
if (parts.length === 2) return cn + num + ' ' + (QTY_CN[parts[1]] || parts[1]);
return cn + num + ' ' + parts.slice(1).join('.');
}
function StatusDot({ status }) {
const colorMap = {
disconnected: '#666',
connecting: '#fbbf24',
connected: '#22c55e',
error: '#ef4444',
};
const labelMap = {
disconnected: '未连接',
connecting: '连接中…',
connected: '实时接收中',
error: '连接失败',
};
return (
<span className={styles.statusDot}>
<span className={styles.dot} style={{ background: colorMap[status] || '#666' }} />
<span style={{ color: colorMap[status] }}>{labelMap[status] || status}</span>
</span>
);
}
export default function LiveChart({ rosBridgeUrl, onClose, onDone }) {
const { status, headers, data, connect, disconnect } = useRosBridge({ onDone });
const [selectedVars, setSelectedVars] = useState(null);
// 自动连接
useEffect(() => {
connect(rosBridgeUrl || 'ws://localhost:9090');
return () => disconnect();
}, [rosBridgeUrl]); // eslint-disable-line react-hooks/exhaustive-deps
// 识别变量列 (排除 time)
const xKey = useMemo(
() => headers.find(h => h.toLowerCase() === 'time') || headers[0] || 'time',
[headers]
);
const variables = useMemo(() => headers.filter(h => h !== xKey), [headers, xKey]);
// 自动选择前 5 个变量
useEffect(() => {
if (variables.length > 0 && selectedVars === null) {
setSelectedVars(new Set(variables.slice(0, Math.min(5, variables.length))));
}
}, [variables]); // eslint-disable-line react-hooks/exhaustive-deps
const selected = selectedVars || new Set();
const selectedArr = useMemo(() => [...selected], [selected]);
const toggleVar = useCallback((v) => setSelectedVars(prev => {
const s = new Set(prev); s.has(v) ? s.delete(v) : s.add(v); return s;
}), []);
// 使用最近 500 个点渲染 (避免 SVG 性能问题)
const displayData = useMemo(() => {
if (data.length <= 500) return data;
return data.slice(-500);
}, [data]);
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
{/* 头部 */}
<div className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.headerIcon}>📈</span>
<span className={styles.headerTitle}>实时仿真数据</span>
<StatusDot status={status} />
</div>
<div className={styles.headerRight}>
<span className={styles.dataCount}>{data.length}</span>
<button className={styles.closeBtn} onClick={onClose}></button>
</div>
</div>
<div className={styles.body}>
{/* 变量选择 */}
{variables.length > 0 && (
<div className={styles.varPanel}>
<div className={styles.varTitle}>变量</div>
<div className={styles.varList}>
{variables.map((v, i) => {
const color = COLORS[i % COLORS.length];
const checked = selected.has(v);
return (
<div key={v} className={styles.varItem} onClick={() => toggleVar(v)}>
<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>
</div>
);
})}
</div>
</div>
)}
{/* 图表区 */}
<div className={styles.chartArea}>
{!selectedArr.length || !displayData.length ? (
<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>
</div>
</div>
</div>
);
}
/* ===== LiveChart 实时图表 ===== */
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
width: 90vw;
height: 80vh;
max-width: 1200px;
background: #0f0f1a;
border: 1px solid rgba(79, 138, 255, 0.15);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
/* 头部 */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-bottom: 1px solid rgba(79, 138, 255, 0.1);
background: rgba(79, 138, 255, 0.03);
flex-shrink: 0;
}
.headerLeft {
display: flex;
align-items: center;
gap: 10px;
}
.headerIcon { font-size: 20px; }
.headerTitle {
font-size: 15px;
font-weight: 700;
color: #e0e0e0;
}
.headerRight {
display: flex;
align-items: center;
gap: 12px;
}
.dataCount {
font-size: 11px;
color: #666;
padding: 2px 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.closeBtn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #888;
font-size: 16px;
width: 32px;
height: 32px;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.closeBtn:hover {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.3);
color: #ef4444;
}
/* 状态指示 */
.statusDot {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* 主体 */
.body {
flex: 1;
display: flex;
overflow: hidden;
}
/* 变量面板 */
.varPanel {
width: 180px;
border-right: 1px solid rgba(79, 138, 255, 0.08);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.varTitle {
padding: 10px 12px;
font-size: 11px;
font-weight: 700;
color: #666;
text-transform: uppercase;
letter-spacing: 1px;
border-bottom: 1px solid rgba(79, 138, 255, 0.08);
}
.varList {
flex: 1;
overflow-y: auto;
padding: 4px;
scrollbar-width: thin;
scrollbar-color: #333 transparent;
}
.varItem {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.1s;
}
.varItem:hover {
background: rgba(255, 255, 255, 0.04);
}
.varCheck {
width: 14px;
height: 14px;
border: 1.5px solid #444;
border-radius: 3px;
flex-shrink: 0;
transition: all 0.15s;
}
.varCheck.checked {
border-color: transparent;
}
.varDot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.varName {
font-size: 11px;
color: #bbb;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 图表区 */
.chartArea {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
min-width: 0;
}
.emptyChart {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #555;
font-size: 13px;
}
......@@ -255,8 +255,7 @@ const useProjectStore = create((set, get) => ({
}
},
/** 半实物仿真 - 轮询定时器 */
_hilPollingTimer: null,
/** 启动半实物仿真 */
startHil: async (id, { duration = 10, stepSize = 0.001 } = {}) => {
......@@ -320,27 +319,8 @@ const useProjectStore = create((set, get) => ({
},
});
// 启动状态轮询 (每 3s 检查一次)
if (isOk && resp.data?.session_id) {
const sessionId = resp.data.session_id;
// 清除旧的轮询
if (get()._hilPollingTimer) clearInterval(get()._hilPollingTimer);
const timer = setInterval(async () => {
try {
const statusResp = await getHilStatus(sessionId);
if (statusResp.code !== 0) return;
const st = statusResp.data?.status;
// 检查 CSV 文件是否就绪
if (st === 'done' || st === 'stopped' || st === 'error') {
clearInterval(timer);
set({ _hilPollingTimer: null });
// 自动获取结果
await get().fetchHilResults(id);
}
} catch (e) { /* 静默 */ }
}, 3000);
set({ _hilPollingTimer: timer });
}
// HIL 启动成功 — 完成检测通过 WebSocket /hil/done 话题
// 不再使用 HTTP 轮询
} catch (err) {
setStatus({
hilStatus: 'error',
......@@ -349,6 +329,16 @@ const useProjectStore = create((set, get) => ({
}
},
/** WebSocket 收到 /hil/done 信号后调用 */
hilDone: async (id) => {
const { projects } = get();
const project = projects.find(p => p.id === id);
if (!project) return;
// 延迟 1s 等待 CSV 写入完成,然后获取结果
setTimeout(() => get().fetchHilResults(id), 1000);
},
stopHil: async (id) => {
const { projects } = get();
const project = projects.find(p => p.id === id);
......@@ -368,11 +358,6 @@ const useProjectStore = create((set, get) => ({
hilStatus: 'stopped',
hilResult: { ...project.hilResult, message: '仿真已停止' },
});
// 清除轮询
if (get()._hilPollingTimer) {
clearInterval(get()._hilPollingTimer);
set({ _hilPollingTimer: null });
}
// 延迟 2s 后获取已有数据
setTimeout(() => get().fetchHilResults(id), 2000);
} catch (err) {
......
/**
* useRosBridge — 原生 WebSocket 连接 rosbridge_server
*
* rosbridge 协议:
* 订阅: { "op": "subscribe", "topic": "...", "type": "..." }
* 消息: { "op": "publish", "topic": "...", "msg": { "data": "..." } }
*
* 订阅两个话题:
* /hil/sim_data — 实时仿真数据帧 (JSON)
* /hil/done — 仿真完成信号
*/
import { useState, useRef, useCallback, useEffect } from 'react';
const MAX_POINTS = 5000;
export default function useRosBridge({ onDone } = {}) {
const [status, setStatus] = useState('disconnected');
const [headers, setHeaders] = useState([]);
const [data, setData] = useState([]);
const wsRef = useRef(null);
const headersRef = useRef([]);
const dataBufferRef = useRef([]);
const rafPendingRef = useRef(false);
const onDoneRef = useRef(onDone);
onDoneRef.current = onDone;
const flushToState = useCallback(() => {
if (rafPendingRef.current) return;
rafPendingRef.current = true;
requestAnimationFrame(() => {
rafPendingRef.current = false;
setData([...dataBufferRef.current]);
});
}, []);
const connect = useCallback((url = 'ws://localhost:9090') => {
if (wsRef.current) return;
setStatus('connecting');
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
setStatus('connected');
// 订阅仿真数据
ws.send(JSON.stringify({
op: 'subscribe',
topic: '/hil/sim_data',
type: 'std_msgs/String',
}));
// 订阅完成信号
ws.send(JSON.stringify({
op: 'subscribe',
topic: '/hil/done',
type: 'std_msgs/String',
}));
};
ws.onmessage = (event) => {
try {
const frame = JSON.parse(event.data);
if (frame.op !== 'publish' || !frame.msg) return;
// 完成信号
if (frame.topic === '/hil/done') {
if (onDoneRef.current) onDoneRef.current();
return;
}
// 仿真数据
if (frame.topic === '/hil/sim_data' && frame.msg.data) {
const row = JSON.parse(frame.msg.data);
if (!headersRef.current.length) {
const cols = Object.keys(row);
headersRef.current = cols;
setHeaders(cols);
}
dataBufferRef.current.push(row);
if (dataBufferRef.current.length > MAX_POINTS) {
dataBufferRef.current = dataBufferRef.current.slice(-MAX_POINTS);
}
flushToState();
}
} catch (e) {
// 忽略
}
};
ws.onerror = () => setStatus('error');
ws.onclose = () => {
setStatus('disconnected');
wsRef.current = null;
};
}, [flushToState]);
const disconnect = useCallback(() => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
headersRef.current = [];
dataBufferRef.current = [];
setStatus('disconnected');
}, []);
useEffect(() => {
return () => { if (wsRef.current) wsRef.current.close(); };
}, []);
return { status, headers, data, connect, disconnect };
}
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