feat(ui): telemetry dashboard panels (issue #126) #130
9
ui/social-bot/package-lock.json
generated
9
ui/social-bot/package-lock.json
generated
@ -10,7 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"roslib": "^1.4.1"
|
"roslib": "^1.4.1",
|
||||||
|
"three": "^0.170.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
@ -2695,6 +2696,12 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.170.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
|
||||||
|
"integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
|
|||||||
@ -12,7 +12,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"roslib": "^1.4.1"
|
"roslib": "^1.4.1",
|
||||||
|
"three": "^0.170.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
|||||||
@ -1,26 +1,55 @@
|
|||||||
/**
|
/**
|
||||||
* App.jsx — Saltybot Social Dashboard root component.
|
* App.jsx — Saltybot Social + Telemetry Dashboard root component.
|
||||||
*
|
*
|
||||||
* Layout:
|
* Social tabs (issue #107):
|
||||||
* [TopBar: connection config + pipeline state badge]
|
* Status | Faces | Conversation | Personality | Navigation
|
||||||
* [TabNav: Status | Faces | Conversation | Personality | Navigation]
|
*
|
||||||
* [TabContent]
|
* Telemetry tabs (issue #126):
|
||||||
|
* IMU | Battery | Motors | Map | Control | Health
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useRosbridge } from './hooks/useRosbridge.js';
|
import { useRosbridge } from './hooks/useRosbridge.js';
|
||||||
import { StatusPanel } from './components/StatusPanel.jsx';
|
|
||||||
import { FaceGallery } from './components/FaceGallery.jsx';
|
|
||||||
import { ConversationLog } from './components/ConversationLog.jsx';
|
|
||||||
import { PersonalityTuner } from './components/PersonalityTuner.jsx';
|
|
||||||
import { NavModeSelector } from './components/NavModeSelector.jsx';
|
|
||||||
|
|
||||||
const TABS = [
|
// Social panels
|
||||||
{ id: 'status', label: 'Status', icon: '⬤' },
|
import { StatusPanel } from './components/StatusPanel.jsx';
|
||||||
{ id: 'faces', label: 'Faces', icon: '◉' },
|
import { FaceGallery } from './components/FaceGallery.jsx';
|
||||||
{ id: 'conversation', label: 'Conversation', icon: '◌' },
|
import { ConversationLog } from './components/ConversationLog.jsx';
|
||||||
{ id: 'personality', label: 'Personality', icon: '◈' },
|
import { PersonalityTuner } from './components/PersonalityTuner.jsx';
|
||||||
{ id: 'navigation', label: 'Navigation', icon: '◫' },
|
import { NavModeSelector } from './components/NavModeSelector.jsx';
|
||||||
|
|
||||||
|
// Telemetry panels
|
||||||
|
import { ImuPanel } from './components/ImuPanel.jsx';
|
||||||
|
import { BatteryPanel } from './components/BatteryPanel.jsx';
|
||||||
|
import { MotorPanel } from './components/MotorPanel.jsx';
|
||||||
|
import { MapViewer } from './components/MapViewer.jsx';
|
||||||
|
import { ControlMode } from './components/ControlMode.jsx';
|
||||||
|
import { SystemHealth } from './components/SystemHealth.jsx';
|
||||||
|
|
||||||
|
const TAB_GROUPS = [
|
||||||
|
{
|
||||||
|
label: 'SOCIAL',
|
||||||
|
color: 'text-cyan-600',
|
||||||
|
tabs: [
|
||||||
|
{ id: 'status', label: 'Status', },
|
||||||
|
{ id: 'faces', label: 'Faces', },
|
||||||
|
{ id: 'conversation', label: 'Convo', },
|
||||||
|
{ id: 'personality', label: 'Personality', },
|
||||||
|
{ id: 'navigation', label: 'Nav Mode', },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'TELEMETRY',
|
||||||
|
color: 'text-amber-600',
|
||||||
|
tabs: [
|
||||||
|
{ id: 'imu', label: 'IMU', },
|
||||||
|
{ id: 'battery', label: 'Battery', },
|
||||||
|
{ id: 'motors', label: 'Motors', },
|
||||||
|
{ id: 'map', label: 'Map', },
|
||||||
|
{ id: 'control', label: 'Control', },
|
||||||
|
{ id: 'health', label: 'Health', },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_WS_URL = 'ws://localhost:9090';
|
const DEFAULT_WS_URL = 'ws://localhost:9090';
|
||||||
@ -29,42 +58,47 @@ function ConnectionBar({ url, setUrl, connected, error }) {
|
|||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(url);
|
const [draft, setDraft] = useState(url);
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => { setUrl(draft); setEditing(false); };
|
||||||
setUrl(draft);
|
|
||||||
setEditing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
{/* Connection dot */}
|
|
||||||
<div className={`w-2 h-2 rounded-full shrink-0 ${
|
<div className={`w-2 h-2 rounded-full shrink-0 ${
|
||||||
connected ? 'bg-green-400' : error ? 'bg-red-500' : 'bg-gray-600'
|
connected ? 'bg-green-400' : error ? 'bg-red-500' : 'bg-gray-600'
|
||||||
}`} />
|
}`} />
|
||||||
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleApply(); if (e.key === 'Escape') setEditing(false); }}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleApply();
|
||||||
|
if (e.key === 'Escape') setEditing(false);
|
||||||
|
}}
|
||||||
autoFocus
|
autoFocus
|
||||||
className="bg-gray-900 border border-cyan-800 rounded px-2 py-0.5 text-cyan-300 w-52 focus:outline-none"
|
className="bg-gray-900 border border-cyan-800 rounded px-2 py-0.5 text-cyan-300 w-52 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<button onClick={handleApply} className="px-2 py-0.5 rounded bg-cyan-950 border border-cyan-700 text-cyan-400 hover:bg-cyan-900">Connect</button>
|
<button
|
||||||
<button onClick={() => setEditing(false)} className="text-gray-600 hover:text-gray-400 px-1">✕</button>
|
onClick={handleApply}
|
||||||
|
className="px-2 py-0.5 rounded bg-cyan-950 border border-cyan-700 text-cyan-400 hover:bg-cyan-900 text-xs"
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setEditing(false)} className="text-gray-600 hover:text-gray-400 px-1">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setDraft(url); setEditing(true); }}
|
onClick={() => { setDraft(url); setEditing(true); }}
|
||||||
className="text-gray-500 hover:text-cyan-400 transition-colors truncate max-w-40"
|
className="text-gray-500 hover:text-cyan-400 transition-colors truncate max-w-52"
|
||||||
title={url}
|
title={url}
|
||||||
>
|
>
|
||||||
{connected ? (
|
{connected ? (
|
||||||
<span className="text-green-400">rosbridge: {url}</span>
|
<span className="text-green-400">{url}</span>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<span className="text-red-400" title={error}>⚠ {url}</span>
|
<span className="text-red-400">⚠ {url}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-500">{url} (connecting…)</span>
|
<span className="text-gray-500">{url}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -73,84 +107,71 @@ function ConnectionBar({ url, setUrl, connected, error }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [wsUrl, setWsUrl] = useState(DEFAULT_WS_URL);
|
const [wsUrl, setWsUrl] = useState(DEFAULT_WS_URL);
|
||||||
const [activeTab, setActiveTab] = useState('status');
|
const [activeTab, setActiveTab] = useState('status');
|
||||||
|
|
||||||
const { connected, error, subscribe, publish, callService, setParam } = useRosbridge(wsUrl);
|
const { connected, error, subscribe, publish, callService, setParam } = useRosbridge(wsUrl);
|
||||||
|
const publishFn = useCallback((name, type, data) => publish(name, type, data), [publish]);
|
||||||
// Memoized publish for NavModeSelector (avoids recreating on every render)
|
|
||||||
const publishFn = useCallback(
|
|
||||||
(name, type, data) => publish(name, type, data),
|
|
||||||
[publish]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-[#050510] text-gray-300 font-mono">
|
<div className="min-h-screen flex flex-col bg-[#050510] text-gray-300 font-mono">
|
||||||
|
|
||||||
{/* ── Top Bar ── */}
|
{/* ── Top Bar ── */}
|
||||||
<header className="flex items-center justify-between px-4 py-2 bg-[#070712] border-b border-cyan-950 shrink-0 gap-2 flex-wrap">
|
<header className="flex items-center justify-between px-4 py-2 bg-[#070712] border-b border-cyan-950 shrink-0 gap-2 flex-wrap">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-orange-500 font-bold tracking-widest text-sm">⚡ SALTYBOT</span>
|
<span className="text-orange-500 font-bold tracking-widest text-sm">⚡ SALTYBOT</span>
|
||||||
<span className="text-cyan-800 text-xs hidden sm:inline">SOCIAL DASHBOARD</span>
|
<span className="text-cyan-800 text-xs hidden sm:inline">DASHBOARD</span>
|
||||||
</div>
|
</div>
|
||||||
|
<ConnectionBar url={wsUrl} setUrl={setWsUrl} connected={connected} error={error} />
|
||||||
<ConnectionBar
|
|
||||||
url={wsUrl}
|
|
||||||
setUrl={setWsUrl}
|
|
||||||
connected={connected}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── Tab Nav ── */}
|
{/* ── Tab Navigation ── */}
|
||||||
<nav className="bg-[#070712] border-b border-cyan-950 shrink-0">
|
<nav className="bg-[#070712] border-b border-cyan-950 shrink-0 overflow-x-auto">
|
||||||
<div className="flex overflow-x-auto">
|
<div className="flex min-w-max">
|
||||||
{TABS.map((tab) => (
|
{TAB_GROUPS.map((group, gi) => (
|
||||||
<button
|
<div key={group.label} className="flex items-stretch">
|
||||||
key={tab.id}
|
{gi > 0 && (
|
||||||
onClick={() => setActiveTab(tab.id)}
|
<div className="flex items-center px-1">
|
||||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-xs font-bold tracking-widest whitespace-nowrap border-b-2 transition-colors ${
|
<div className="w-px h-4 bg-cyan-950" />
|
||||||
activeTab === tab.id
|
</div>
|
||||||
? 'border-cyan-500 text-cyan-300 bg-cyan-950 bg-opacity-30'
|
)}
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-300 hover:border-gray-700'
|
<div className="flex items-center px-1.5 shrink-0">
|
||||||
}`}
|
<span className={`text-xs font-bold tracking-widest ${group.color} opacity-60`}>
|
||||||
>
|
{group.label}
|
||||||
<span className="hidden sm:inline text-base leading-none">{tab.icon}</span>
|
</span>
|
||||||
{tab.label.toUpperCase()}
|
</div>
|
||||||
</button>
|
{group.tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`px-3 py-2.5 text-xs font-bold tracking-wider whitespace-nowrap border-b-2 transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'border-cyan-500 text-cyan-300 bg-cyan-950 bg-opacity-30'
|
||||||
|
: 'border-transparent text-gray-600 hover:text-gray-300 hover:border-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* ── Content ── */}
|
{/* ── Content ── */}
|
||||||
<main className="flex-1 overflow-y-auto p-4">
|
<main className="flex-1 overflow-y-auto p-4">
|
||||||
{activeTab === 'status' && (
|
{activeTab === 'status' && <StatusPanel subscribe={subscribe} />}
|
||||||
<StatusPanel subscribe={subscribe} />
|
{activeTab === 'faces' && <FaceGallery subscribe={subscribe} callService={callService} />}
|
||||||
)}
|
{activeTab === 'conversation' && <ConversationLog subscribe={subscribe} />}
|
||||||
|
{activeTab === 'personality' && <PersonalityTuner subscribe={subscribe} setParam={setParam} />}
|
||||||
|
{activeTab === 'navigation' && <NavModeSelector subscribe={subscribe} publish={publishFn} />}
|
||||||
|
|
||||||
{activeTab === 'faces' && (
|
{activeTab === 'imu' && <ImuPanel subscribe={subscribe} />}
|
||||||
<FaceGallery
|
{activeTab === 'battery' && <BatteryPanel subscribe={subscribe} />}
|
||||||
subscribe={subscribe}
|
{activeTab === 'motors' && <MotorPanel subscribe={subscribe} />}
|
||||||
callService={callService}
|
{activeTab === 'map' && <MapViewer subscribe={subscribe} />}
|
||||||
/>
|
{activeTab === 'control' && <ControlMode subscribe={subscribe} />}
|
||||||
)}
|
{activeTab === 'health' && <SystemHealth subscribe={subscribe} />}
|
||||||
|
|
||||||
{activeTab === 'conversation' && (
|
|
||||||
<ConversationLog subscribe={subscribe} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'personality' && (
|
|
||||||
<PersonalityTuner
|
|
||||||
subscribe={subscribe}
|
|
||||||
setParam={setParam}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'navigation' && (
|
|
||||||
<NavModeSelector
|
|
||||||
subscribe={subscribe}
|
|
||||||
publish={publishFn}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* ── Footer ── */}
|
{/* ── Footer ── */}
|
||||||
|
|||||||
234
ui/social-bot/src/components/BatteryPanel.jsx
Normal file
234
ui/social-bot/src/components/BatteryPanel.jsx
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* BatteryPanel.jsx — Battery state monitoring.
|
||||||
|
*
|
||||||
|
* Topics:
|
||||||
|
* /saltybot/balance_state (std_msgs/String JSON)
|
||||||
|
* Fields: motor_cmd, state, mode (no direct voltage yet)
|
||||||
|
* /diagnostics (diagnostic_msgs/DiagnosticArray)
|
||||||
|
* KeyValues: battery_voltage_v, battery_current_a, battery_soc_pct
|
||||||
|
*
|
||||||
|
* NOTE: Dedicated /saltybot/battery (sensor_msgs/BatteryState) can be added
|
||||||
|
* to cmd_vel_bridge_node once firmware sends voltage/current over USB.
|
||||||
|
* The panel will pick it up automatically from /diagnostics KeyValues.
|
||||||
|
*
|
||||||
|
* 4S LiPo range: 12.0 V (empty) → 16.8 V (full)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const LIPO_4S_MIN = 12.0;
|
||||||
|
const LIPO_4S_MAX = 16.8;
|
||||||
|
const HISTORY_MAX = 120; // 2 min at 1 Hz
|
||||||
|
|
||||||
|
function socFromVoltage(v) {
|
||||||
|
if (v <= 0) return null;
|
||||||
|
return Math.max(0, Math.min(100, ((v - LIPO_4S_MIN) / (LIPO_4S_MAX - LIPO_4S_MIN)) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function voltColor(v) {
|
||||||
|
const soc = socFromVoltage(v);
|
||||||
|
if (soc === null) return '#6b7280';
|
||||||
|
if (soc > 50) return '#22c55e';
|
||||||
|
if (soc > 20) return '#f59e0b';
|
||||||
|
return '#ef4444';
|
||||||
|
}
|
||||||
|
|
||||||
|
function SparklineCanvas({ data, width = 280, height = 60 }) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = ref.current;
|
||||||
|
if (!canvas || data.length < 2) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const W = canvas.width, H = canvas.height;
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Background
|
||||||
|
ctx.fillStyle = '#020208';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
// Grid lines at 25% / 50% / 75%
|
||||||
|
[25, 50, 75].forEach(pct => {
|
||||||
|
const y = H - (pct / 100) * H;
|
||||||
|
ctx.strokeStyle = 'rgba(0,255,255,0.05)';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Voltage range for y-axis
|
||||||
|
const minV = Math.min(LIPO_4S_MIN, ...data);
|
||||||
|
const maxV = Math.max(LIPO_4S_MAX, ...data);
|
||||||
|
const rangeV = maxV - minV || 1;
|
||||||
|
|
||||||
|
// Line
|
||||||
|
ctx.strokeStyle = '#06b6d4';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
data.forEach((v, i) => {
|
||||||
|
const x = (i / (data.length - 1)) * W;
|
||||||
|
const y = H - ((v - minV) / rangeV) * H * 0.9 - H * 0.05;
|
||||||
|
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Fill under
|
||||||
|
ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath();
|
||||||
|
ctx.fillStyle = 'rgba(6,182,212,0.08)';
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Latest value label
|
||||||
|
const last = data[data.length - 1];
|
||||||
|
ctx.fillStyle = '#06b6d4';
|
||||||
|
ctx.font = 'bold 10px monospace';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText(`${last.toFixed(2)}V`, W - 3, 12);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={ref}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
className="w-full rounded border border-gray-900 block"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BatteryPanel({ subscribe }) {
|
||||||
|
const [voltage, setVoltage] = useState(0);
|
||||||
|
const [current, setCurrent] = useState(null);
|
||||||
|
const [soc, setSoc] = useState(null);
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
const [lastTs, setLastTs] = useState(null);
|
||||||
|
const lastHistoryPush = useRef(0);
|
||||||
|
|
||||||
|
// /diagnostics — look for battery KeyValues
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe(
|
||||||
|
'/diagnostics',
|
||||||
|
'diagnostic_msgs/DiagnosticArray',
|
||||||
|
(msg) => {
|
||||||
|
for (const status of msg.status ?? []) {
|
||||||
|
const kv = {};
|
||||||
|
for (const pair of status.values ?? []) {
|
||||||
|
kv[pair.key] = pair.value;
|
||||||
|
}
|
||||||
|
if (kv.battery_voltage_v !== undefined) {
|
||||||
|
const v = parseFloat(kv.battery_voltage_v);
|
||||||
|
setVoltage(v);
|
||||||
|
setSoc(socFromVoltage(v));
|
||||||
|
setLastTs(Date.now());
|
||||||
|
// Throttle history to ~1 Hz
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastHistoryPush.current >= 1000) {
|
||||||
|
lastHistoryPush.current = now;
|
||||||
|
setHistory(h => [...h, v].slice(-HISTORY_MAX));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (kv.battery_current_a !== undefined) {
|
||||||
|
setCurrent(parseFloat(kv.battery_current_a));
|
||||||
|
}
|
||||||
|
if (kv.battery_soc_pct !== undefined) {
|
||||||
|
setSoc(parseFloat(kv.battery_soc_pct));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
// Also listen to balance_state for rough motor-current proxy
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe('/saltybot/balance_state', 'std_msgs/String', (msg) => {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(msg.data);
|
||||||
|
// motor_cmd ∈ [-1000..1000] → rough current proxy
|
||||||
|
if (d.motor_cmd !== undefined && current === null) {
|
||||||
|
setCurrent(Math.abs(d.motor_cmd) / 1000 * 20); // rough max 20A
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe, current]);
|
||||||
|
|
||||||
|
const socPct = soc ?? socFromVoltage(voltage) ?? 0;
|
||||||
|
const col = voltColor(voltage);
|
||||||
|
const stale = lastTs && Date.now() - lastTs > 10000;
|
||||||
|
const runtime = (voltage > 0 && current && current > 0.1)
|
||||||
|
? ((socPct / 100) * 16000 / current / 60).toFixed(0) // rough Wh/V/A estimate
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Main gauges */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 col-span-2 sm:col-span-1">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-1">VOLTAGE</div>
|
||||||
|
<div className="text-3xl font-bold" style={{ color: col }}>
|
||||||
|
{voltage > 0 ? voltage.toFixed(2) : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 text-xs">V {stale ? '(stale)' : ''}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-1">SOC</div>
|
||||||
|
<div className="text-3xl font-bold" style={{ color: col }}>
|
||||||
|
{socPct > 0 ? Math.round(socPct) : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 text-xs">%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-1">CURRENT</div>
|
||||||
|
<div className="text-3xl font-bold text-orange-400">
|
||||||
|
{current != null ? current.toFixed(1) : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 text-xs">A {current === null ? '' : '(est.)'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-1">EST. RUN</div>
|
||||||
|
<div className="text-3xl font-bold text-purple-400">
|
||||||
|
{runtime ?? '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 text-xs">min</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SoC bar */}
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
||||||
|
<div className="flex justify-between text-xs mb-2">
|
||||||
|
<span className="text-cyan-700 font-bold tracking-widest">STATE OF CHARGE</span>
|
||||||
|
<span style={{ color: col }}>{Math.round(socPct)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-4 bg-gray-900 rounded overflow-hidden border border-gray-800">
|
||||||
|
<div
|
||||||
|
className="h-full transition-all duration-1000 rounded"
|
||||||
|
style={{ width: `${socPct}%`, background: col }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs mt-1 text-gray-700">
|
||||||
|
<span>{LIPO_4S_MIN}V empty</span>
|
||||||
|
<span>{LIPO_4S_MAX}V full</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Voltage history sparkline */}
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-2">
|
||||||
|
VOLTAGE HISTORY (2 min)
|
||||||
|
</div>
|
||||||
|
{history.length >= 2 ? (
|
||||||
|
<SparklineCanvas data={history} height={60} />
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-600 text-xs text-center py-4 border border-dashed border-gray-800 rounded">
|
||||||
|
Waiting for /diagnostics data…
|
||||||
|
<div className="mt-1 text-gray-700 text-xs">
|
||||||
|
Requires battery_voltage_v KeyValue in DiagnosticArray
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
ui/social-bot/src/components/ControlMode.jsx
Normal file
198
ui/social-bot/src/components/ControlMode.jsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* ControlMode.jsx — RC / Autonomous control mode display.
|
||||||
|
*
|
||||||
|
* Topics:
|
||||||
|
* /saltybot/control_mode (std_msgs/String JSON)
|
||||||
|
* {
|
||||||
|
* mode: "RC" | "RAMP_TO_AUTO" | "AUTO" | "RAMP_TO_RC",
|
||||||
|
* blend_alpha: 0.0..1.0,
|
||||||
|
* slam_ok: bool,
|
||||||
|
* rc_link_ok: bool,
|
||||||
|
* override_active: bool
|
||||||
|
* }
|
||||||
|
* /saltybot/balance_state (std_msgs/String JSON) — robot state, mode label
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const MODE_CONFIG = {
|
||||||
|
RC: {
|
||||||
|
label: 'RC MANUAL',
|
||||||
|
color: 'text-blue-300',
|
||||||
|
bg: 'bg-blue-950',
|
||||||
|
border: 'border-blue-600',
|
||||||
|
description: 'Pilot has full control via CRSF/ELRS RC link',
|
||||||
|
},
|
||||||
|
RAMP_TO_AUTO: {
|
||||||
|
label: 'RAMP → AUTO',
|
||||||
|
color: 'text-amber-300',
|
||||||
|
bg: 'bg-amber-950',
|
||||||
|
border: 'border-amber-600',
|
||||||
|
description: 'Transitioning from RC to autonomous (500 ms blend)',
|
||||||
|
},
|
||||||
|
AUTO: {
|
||||||
|
label: 'AUTONOMOUS',
|
||||||
|
color: 'text-green-300',
|
||||||
|
bg: 'bg-green-950',
|
||||||
|
border: 'border-green-600',
|
||||||
|
description: 'Jetson AI/Nav2 in full control',
|
||||||
|
},
|
||||||
|
RAMP_TO_RC: {
|
||||||
|
label: 'RAMP → RC',
|
||||||
|
color: 'text-orange-300',
|
||||||
|
bg: 'bg-orange-950',
|
||||||
|
border: 'border-orange-600',
|
||||||
|
description: 'Returning control to pilot (500 ms blend)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function SafetyFlag({ label, ok, invert }) {
|
||||||
|
const isOk = invert ? !ok : ok;
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 rounded px-3 py-2 border ${
|
||||||
|
isOk ? 'bg-green-950 border-green-800' : 'bg-red-950 border-red-800'
|
||||||
|
}`}>
|
||||||
|
<div className={`w-2 h-2 rounded-full ${isOk ? 'bg-green-400' : 'bg-red-400 animate-pulse'}`} />
|
||||||
|
<span className="text-xs">
|
||||||
|
<span className={isOk ? 'text-green-300' : 'text-red-300'}>{label}</span>
|
||||||
|
</span>
|
||||||
|
<span className={`ml-auto text-xs font-bold ${isOk ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
|
{isOk ? 'OK' : 'FAULT'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ControlMode({ subscribe }) {
|
||||||
|
const [ctrlMode, setCtrlMode] = useState(null);
|
||||||
|
const [balState, setBalState] = useState(null);
|
||||||
|
const [lastTs, setLastTs] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe('/saltybot/control_mode', 'std_msgs/String', (msg) => {
|
||||||
|
try {
|
||||||
|
setCtrlMode(JSON.parse(msg.data));
|
||||||
|
setLastTs(Date.now());
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe('/saltybot/balance_state', 'std_msgs/String', (msg) => {
|
||||||
|
try { setBalState(JSON.parse(msg.data)); }
|
||||||
|
catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
const mode = ctrlMode?.mode ?? 'RC';
|
||||||
|
const cfg = MODE_CONFIG[mode] ?? MODE_CONFIG.RC;
|
||||||
|
const alpha = ctrlMode?.blend_alpha ?? 0;
|
||||||
|
const stale = lastTs && Date.now() - lastTs > 5000;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Main mode badge */}
|
||||||
|
<div className={`${cfg.bg} border ${cfg.border} rounded-xl p-5`}>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500 text-xs font-bold tracking-widest mb-1">CONTROL MODE</div>
|
||||||
|
<div className={`text-3xl font-bold tracking-widest ${cfg.color}`}>{cfg.label}</div>
|
||||||
|
<div className="text-gray-500 text-xs mt-1">{cfg.description}</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<div className="text-gray-600 text-xs">Blend α</div>
|
||||||
|
<div className={`text-2xl font-bold ${alpha > 0.5 ? 'text-green-400' : 'text-blue-400'}`}>
|
||||||
|
{alpha.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
{stale && <div className="text-red-500 text-xs mt-1">STALE</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Blend alpha bar */}
|
||||||
|
{(mode === 'RAMP_TO_AUTO' || mode === 'RAMP_TO_RC' || alpha > 0) && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||||
|
<span>RC</span>
|
||||||
|
<span>AUTO</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-3 bg-gray-950 rounded overflow-hidden border border-gray-800">
|
||||||
|
<div
|
||||||
|
className="h-full transition-all duration-200 rounded"
|
||||||
|
style={{
|
||||||
|
width: `${alpha * 100}%`,
|
||||||
|
background: `linear-gradient(to right, #2563eb, #16a34a)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Safety flags */}
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-3">SAFETY INTERLOCKS</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<SafetyFlag label="SLAM Fix" ok={ctrlMode?.slam_ok ?? false} />
|
||||||
|
<SafetyFlag label="RC Link" ok={ctrlMode?.rc_link_ok ?? false} />
|
||||||
|
<SafetyFlag label="Stick Override" ok={ctrlMode?.override_active ?? false} invert />
|
||||||
|
</div>
|
||||||
|
{!ctrlMode && (
|
||||||
|
<div className="text-gray-600 text-xs mt-2">
|
||||||
|
Waiting for /saltybot/control_mode…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance state detail */}
|
||||||
|
{balState && (
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-3">BALANCE STATE</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">STM32 State: </span>
|
||||||
|
<span className={`font-bold ${
|
||||||
|
balState.state === 'ARMED' ? 'text-green-400' :
|
||||||
|
balState.state === 'TILT FAULT' ? 'text-red-400' : 'text-gray-400'
|
||||||
|
}`}>{balState.state}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">STM32 Mode: </span>
|
||||||
|
<span className="text-cyan-400">{balState.mode}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">Pitch: </span>
|
||||||
|
<span className="text-amber-400">{balState.pitch_deg}°</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">Motor CMD: </span>
|
||||||
|
<span className="text-orange-400">{balState.motor_cmd}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mode transition guide */}
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-2">STATE MACHINE</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs flex-wrap">
|
||||||
|
{['RC', 'RAMP_TO_AUTO', 'AUTO', 'RAMP_TO_RC'].map((m, i) => (
|
||||||
|
<div key={m} className="flex items-center gap-1">
|
||||||
|
<span className={`px-2 py-0.5 rounded border ${
|
||||||
|
mode === m
|
||||||
|
? `${MODE_CONFIG[m].bg} ${MODE_CONFIG[m].border} ${MODE_CONFIG[m].color} font-bold`
|
||||||
|
: 'border-gray-800 text-gray-600'
|
||||||
|
}`}>{m}</span>
|
||||||
|
{i < 3 && <span className="text-gray-700">→</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-gray-700 text-xs space-y-0.5">
|
||||||
|
<div>AUX2 switch → RC⇄AUTO | Stick > 10% while AUTO → instant RC</div>
|
||||||
|
<div>SLAM fix lost while AUTO → RAMP_TO_RC | RC link lost → instant RC</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
379
ui/social-bot/src/components/ImuPanel.jsx
Normal file
379
ui/social-bot/src/components/ImuPanel.jsx
Normal file
@ -0,0 +1,379 @@
|
|||||||
|
/**
|
||||||
|
* ImuPanel.jsx — IMU attitude visualization.
|
||||||
|
*
|
||||||
|
* Topics:
|
||||||
|
* /saltybot/imu (sensor_msgs/Imu) — quaternion orientation
|
||||||
|
* /saltybot/balance_state (std_msgs/String JSON) — pitch/roll/yaw deg,
|
||||||
|
* motor_cmd, state, mode
|
||||||
|
*
|
||||||
|
* Displays:
|
||||||
|
* - Artificial horizon canvas (pitch / roll)
|
||||||
|
* - Compass tape (yaw)
|
||||||
|
* - Three.js 3D robot orientation cube
|
||||||
|
* - Numeric readouts + angular velocity
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// ── 2D canvas helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function drawHorizon(ctx, W, H, pitch, roll) {
|
||||||
|
const cx = W / 2, cy = H / 2;
|
||||||
|
const rollRad = roll * Math.PI / 180;
|
||||||
|
const pitchPx = pitch * (H / 60);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
ctx.fillStyle = '#051a30';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(cx, cy);
|
||||||
|
ctx.rotate(-rollRad);
|
||||||
|
|
||||||
|
// Ground
|
||||||
|
ctx.fillStyle = '#1a0f00';
|
||||||
|
ctx.fillRect(-W, pitchPx, W * 2, H * 2);
|
||||||
|
|
||||||
|
// Horizon
|
||||||
|
ctx.strokeStyle = '#00ffff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-W, pitchPx);
|
||||||
|
ctx.lineTo(W, pitchPx);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Pitch ladder
|
||||||
|
for (let d = -30; d <= 30; d += 10) {
|
||||||
|
if (d === 0) continue;
|
||||||
|
const y = pitchPx + d * (H / 60);
|
||||||
|
const lw = Math.abs(d) % 20 === 0 ? 22 : 14;
|
||||||
|
ctx.strokeStyle = 'rgba(0,210,210,0.4)';
|
||||||
|
ctx.lineWidth = 0.7;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(-lw, y); ctx.lineTo(lw, y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = 'rgba(0,210,210,0.5)';
|
||||||
|
ctx.font = '7px monospace';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText((-d).toString(), lw + 2, y + 3);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Reticle
|
||||||
|
ctx.strokeStyle = '#f97316';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx - 28, cy); ctx.lineTo(cx - 8, cy);
|
||||||
|
ctx.moveTo(cx + 8, cy); ctx.lineTo(cx + 28, cy);
|
||||||
|
ctx.moveTo(cx, cy - 4); ctx.lineTo(cx, cy + 4);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCompass(ctx, W, H, yaw) {
|
||||||
|
const cx = W / 2;
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
ctx.fillStyle = '#050510';
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
const degPerPx = W / 70;
|
||||||
|
const cardinals = { 0:'N', 45:'NE', 90:'E', 135:'SE', 180:'S', 225:'SW', 270:'W', 315:'NW' };
|
||||||
|
|
||||||
|
for (let i = -35; i <= 35; i++) {
|
||||||
|
const deg = ((Math.round(yaw) + i) % 360 + 360) % 360;
|
||||||
|
const x = cx + i * degPerPx;
|
||||||
|
const isMaj = deg % 45 === 0;
|
||||||
|
const isMed = deg % 15 === 0;
|
||||||
|
if (!isMed && !isMaj) continue;
|
||||||
|
|
||||||
|
ctx.strokeStyle = isMaj ? '#00cccc' : 'rgba(0,200,200,0.3)';
|
||||||
|
ctx.lineWidth = isMaj ? 1.5 : 0.5;
|
||||||
|
const tH = isMaj ? 16 : 7;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, 0); ctx.lineTo(x, tH);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
if (isMaj && cardinals[deg] !== undefined) {
|
||||||
|
ctx.fillStyle = deg === 0 ? '#ff4444' : '#00cccc';
|
||||||
|
ctx.font = 'bold 9px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(cardinals[deg], x, 28);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hdg = ((Math.round(yaw) % 360) + 360) % 360;
|
||||||
|
ctx.fillStyle = '#00ffff';
|
||||||
|
ctx.font = 'bold 11px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(hdg + '°', cx, H - 4);
|
||||||
|
|
||||||
|
// Pointer
|
||||||
|
ctx.strokeStyle = '#f97316';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, 0); ctx.lineTo(cx, 10);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Three.js hook ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function useThreeOrientation(containerRef) {
|
||||||
|
const sceneRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const camera = new THREE.PerspectiveCamera(45, el.clientWidth / el.clientHeight, 0.1, 100);
|
||||||
|
camera.position.set(2.5, 2, 3);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
renderer.setClearColor(0x070712, 1);
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
renderer.setSize(el.clientWidth, el.clientHeight);
|
||||||
|
el.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
// Lighting
|
||||||
|
scene.add(new THREE.AmbientLight(0x404060, 3));
|
||||||
|
const dir = new THREE.DirectionalLight(0xffffff, 4);
|
||||||
|
dir.position.set(5, 8, 6);
|
||||||
|
scene.add(dir);
|
||||||
|
|
||||||
|
// Robot body group
|
||||||
|
const group = new THREE.Group();
|
||||||
|
|
||||||
|
// Body
|
||||||
|
const body = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(0.8, 1.4, 0.4),
|
||||||
|
new THREE.MeshPhongMaterial({ color: 0x1a2a4a, specular: 0x334466 })
|
||||||
|
);
|
||||||
|
body.position.y = 0.5;
|
||||||
|
group.add(body);
|
||||||
|
|
||||||
|
// Wheels
|
||||||
|
const wheelGeo = new THREE.CylinderGeometry(0.35, 0.35, 0.12, 18);
|
||||||
|
const wheelMat = new THREE.MeshPhongMaterial({ color: 0x1a1a1a, specular: 0x333333 });
|
||||||
|
[-0.5, 0.5].forEach(x => {
|
||||||
|
const pivot = new THREE.Group();
|
||||||
|
pivot.position.set(x, -0.2, 0);
|
||||||
|
pivot.rotation.z = Math.PI / 2;
|
||||||
|
pivot.add(new THREE.Mesh(wheelGeo, wheelMat));
|
||||||
|
group.add(pivot);
|
||||||
|
|
||||||
|
const rim = new THREE.Mesh(
|
||||||
|
new THREE.TorusGeometry(0.3, 0.02, 8, 16),
|
||||||
|
new THREE.MeshPhongMaterial({ color: 0x0055cc })
|
||||||
|
);
|
||||||
|
rim.rotation.z = Math.PI / 2;
|
||||||
|
rim.position.set(x * 1.07, -0.2, 0);
|
||||||
|
group.add(rim);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward indicator arrow (red tip at +Z front)
|
||||||
|
const arrow = new THREE.Mesh(
|
||||||
|
new THREE.ConeGeometry(0.08, 0.25, 8),
|
||||||
|
new THREE.MeshBasicMaterial({ color: 0xff3030 })
|
||||||
|
);
|
||||||
|
arrow.rotation.x = -Math.PI / 2;
|
||||||
|
arrow.position.set(0, 0.5, 0.35);
|
||||||
|
group.add(arrow);
|
||||||
|
|
||||||
|
// Sensor head
|
||||||
|
const head = new THREE.Mesh(
|
||||||
|
new THREE.BoxGeometry(0.32, 0.18, 0.32),
|
||||||
|
new THREE.MeshPhongMaterial({ color: 0x111122 })
|
||||||
|
);
|
||||||
|
head.position.set(0, 1.35, 0);
|
||||||
|
group.add(head);
|
||||||
|
|
||||||
|
// Axis helper
|
||||||
|
scene.add(new THREE.AxesHelper(1.4));
|
||||||
|
scene.add(group);
|
||||||
|
|
||||||
|
const q = new THREE.Quaternion();
|
||||||
|
let curQ = new THREE.Quaternion();
|
||||||
|
|
||||||
|
sceneRef.current = { group, q, curQ };
|
||||||
|
|
||||||
|
let animId;
|
||||||
|
const animate = () => {
|
||||||
|
animId = requestAnimationFrame(animate);
|
||||||
|
curQ.slerp(q, 0.12);
|
||||||
|
group.quaternion.copy(curQ);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
animate();
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
camera.aspect = el.clientWidth / el.clientHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(el.clientWidth, el.clientHeight);
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animId);
|
||||||
|
ro.disconnect();
|
||||||
|
renderer.dispose();
|
||||||
|
el.removeChild(renderer.domElement);
|
||||||
|
sceneRef.current = null;
|
||||||
|
};
|
||||||
|
}, [containerRef]);
|
||||||
|
|
||||||
|
const updateOrientation = useCallback((qx, qy, qz, qw) => {
|
||||||
|
if (!sceneRef.current) return;
|
||||||
|
sceneRef.current.q.set(qx, qy, qz, qw);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return updateOrientation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Readout({ label, value, unit, warn }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-950 rounded p-2 text-center">
|
||||||
|
<div className="text-gray-600 text-xs">{label}</div>
|
||||||
|
<div className={`text-lg font-bold ${warn ? 'text-amber-400' : 'text-cyan-400'}`}>{value}</div>
|
||||||
|
<div className="text-gray-700 text-xs">{unit}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImuPanel({ subscribe }) {
|
||||||
|
const horizonRef = useRef(null);
|
||||||
|
const compassRef = useRef(null);
|
||||||
|
const threeRef = useRef(null);
|
||||||
|
|
||||||
|
const [attitude, setAttitude] = useState({ pitch: 0, roll: 0, yaw: 0 });
|
||||||
|
const [angVel, setAngVel] = useState({ x: 0, y: 0, z: 0 });
|
||||||
|
const [balState, setBalState] = useState(null);
|
||||||
|
const [pktHz, setPktHz] = useState(0);
|
||||||
|
|
||||||
|
const threeUpdate = useThreeOrientation(threeRef);
|
||||||
|
const pktRef = useRef({ count: 0, last: Date.now() });
|
||||||
|
|
||||||
|
// Draw canvases on attitude change
|
||||||
|
useEffect(() => {
|
||||||
|
const hc = horizonRef.current;
|
||||||
|
const cc = compassRef.current;
|
||||||
|
if (hc) {
|
||||||
|
const ctx = hc.getContext('2d');
|
||||||
|
drawHorizon(ctx, hc.width, hc.height, attitude.pitch, attitude.roll);
|
||||||
|
}
|
||||||
|
if (cc) {
|
||||||
|
const ctx = cc.getContext('2d');
|
||||||
|
drawCompass(ctx, cc.width, cc.height, attitude.yaw);
|
||||||
|
}
|
||||||
|
}, [attitude]);
|
||||||
|
|
||||||
|
// Subscribe to Imu
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe('/saltybot/imu', 'sensor_msgs/Imu', (msg) => {
|
||||||
|
const o = msg.orientation;
|
||||||
|
if (o) {
|
||||||
|
// Convert quaternion to Euler (simple ZYX)
|
||||||
|
const { x, y, z, w } = o;
|
||||||
|
const pitchRad = Math.asin(2 * (w * y - z * x));
|
||||||
|
const rollRad = Math.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y));
|
||||||
|
const yawRad = Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z));
|
||||||
|
setAttitude({
|
||||||
|
pitch: pitchRad * 180 / Math.PI,
|
||||||
|
roll: rollRad * 180 / Math.PI,
|
||||||
|
yaw: yawRad * 180 / Math.PI,
|
||||||
|
});
|
||||||
|
threeUpdate(x, y, z, w);
|
||||||
|
}
|
||||||
|
const av = msg.angular_velocity;
|
||||||
|
if (av) {
|
||||||
|
setAngVel({
|
||||||
|
x: av.x * 180 / Math.PI,
|
||||||
|
y: av.y * 180 / Math.PI,
|
||||||
|
z: av.z * 180 / Math.PI,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Hz counter
|
||||||
|
pktRef.current.count++;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - pktRef.current.last >= 1000) {
|
||||||
|
setPktHz(pktRef.current.count);
|
||||||
|
pktRef.current.count = 0;
|
||||||
|
pktRef.current.last = now;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe, threeUpdate]);
|
||||||
|
|
||||||
|
// Subscribe to balance_state for motor/state info
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe('/saltybot/balance_state', 'std_msgs/String', (msg) => {
|
||||||
|
try {
|
||||||
|
setBalState(JSON.parse(msg.data));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
const pitchWarn = Math.abs(attitude.pitch) > 20;
|
||||||
|
const rollWarn = Math.abs(attitude.roll) > 20;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Numeric readouts */}
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Readout label="PITCH" value={attitude.pitch.toFixed(1)} unit="°" warn={pitchWarn} />
|
||||||
|
<Readout label="ROLL" value={attitude.roll.toFixed(1)} unit="°" warn={rollWarn} />
|
||||||
|
<Readout label="YAW" value={((attitude.yaw + 360) % 360).toFixed(1)} unit="°" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Angular velocity */}
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Readout label="ω PITCH" value={angVel.x.toFixed(1)} unit="°/s" />
|
||||||
|
<Readout label="ω ROLL" value={angVel.y.toFixed(1)} unit="°/s" />
|
||||||
|
<Readout label="ω YAW" value={angVel.z.toFixed(1)} unit="°/s" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2D gauges */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-2">ARTIFICIAL HORIZON</div>
|
||||||
|
<canvas
|
||||||
|
ref={horizonRef}
|
||||||
|
width={280}
|
||||||
|
height={120}
|
||||||
|
className="w-full rounded border border-gray-900 block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-2">COMPASS</div>
|
||||||
|
<canvas
|
||||||
|
ref={compassRef}
|
||||||
|
width={280}
|
||||||
|
height={56}
|
||||||
|
className="w-full rounded border border-gray-900 block"
|
||||||
|
/>
|
||||||
|
{/* Balance state info */}
|
||||||
|
{balState && (
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-1 text-xs">
|
||||||
|
<div><span className="text-gray-600">State: </span><span className="text-cyan-400">{balState.state}</span></div>
|
||||||
|
<div><span className="text-gray-600">Mode: </span><span className="text-amber-400">{balState.mode}</span></div>
|
||||||
|
<div><span className="text-gray-600">Motor: </span><span className="text-orange-400">{balState.motor_cmd}</span></div>
|
||||||
|
<div><span className="text-gray-600">Error: </span><span className="text-red-400">{balState.pid_error_deg}°</span></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Three.js 3D orientation */}
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest">3D ORIENTATION</div>
|
||||||
|
<div className="text-gray-600 text-xs">{pktHz} Hz</div>
|
||||||
|
</div>
|
||||||
|
<div ref={threeRef} className="w-full rounded border border-gray-900" style={{ height: '200px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
298
ui/social-bot/src/components/MapViewer.jsx
Normal file
298
ui/social-bot/src/components/MapViewer.jsx
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
/**
|
||||||
|
* MapViewer.jsx — 2D occupancy grid + robot pose + Nav2 path overlay.
|
||||||
|
*
|
||||||
|
* Topics:
|
||||||
|
* /map (nav_msgs/OccupancyGrid) — SLAM/static map
|
||||||
|
* /odom (nav_msgs/Odometry) — robot position & heading
|
||||||
|
* /outdoor/route (nav_msgs/Path) — Nav2 / OSM route path
|
||||||
|
*
|
||||||
|
* NOTE: OccupancyGrid data can be large (384×384 = 150K cells).
|
||||||
|
* We decode on a worker-free canvas; map refreshes at topic rate
|
||||||
|
* (typically 0.1–1 Hz from SLAM), odom at ~10 Hz.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
const CELL_COLORS = {
|
||||||
|
unknown: '#1a1a2e',
|
||||||
|
free: '#0a1020',
|
||||||
|
occ: '#00ffff33',
|
||||||
|
occFull: '#00b8d9',
|
||||||
|
};
|
||||||
|
|
||||||
|
function quatToYaw(o) {
|
||||||
|
return Math.atan2(2 * (o.w * o.z + o.x * o.y), 1 - 2 * (o.y * o.y + o.z * o.z));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapViewer({ subscribe }) {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const mapRef = useRef(null); // last OccupancyGrid info
|
||||||
|
const odomRef = useRef(null); // last robot pose {x,y,yaw}
|
||||||
|
const pathRef = useRef([]); // [{x,y}] path points in map coords
|
||||||
|
const [mapInfo, setMapInfo] = useState(null);
|
||||||
|
const [odomPose, setOdomPose] = useState(null);
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||||
|
const dragging = useRef(null);
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
const render = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!canvas || !map) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const W = canvas.width, H = canvas.height;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, W, H);
|
||||||
|
ctx.fillStyle = CELL_COLORS.unknown;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
const { data, info } = map;
|
||||||
|
const mW = info.width, mH = info.height;
|
||||||
|
const res = info.resolution; // m/cell
|
||||||
|
const cellPx = zoom; // 1 map cell = zoom pixels
|
||||||
|
|
||||||
|
// Canvas centre
|
||||||
|
const cx = W / 2 + pan.x;
|
||||||
|
const cy = H / 2 + pan.y;
|
||||||
|
|
||||||
|
// Map origin (bottom-left in world) → we flip y for canvas
|
||||||
|
const ox = info.origin.position.x;
|
||||||
|
const oy = info.origin.position.y;
|
||||||
|
|
||||||
|
// Draw map cells in chunks
|
||||||
|
const img = ctx.createImageData(W, H);
|
||||||
|
const imgData = img.data;
|
||||||
|
|
||||||
|
for (let r = 0; r < mH; r++) {
|
||||||
|
for (let c = 0; c < mW; c++) {
|
||||||
|
const val = data[r * mW + c];
|
||||||
|
// World coords of this cell centre
|
||||||
|
const wx = ox + (c + 0.5) * res;
|
||||||
|
const wy = oy + (r + 0.5) * res;
|
||||||
|
// Canvas coords (flip y)
|
||||||
|
const px = Math.round(cx + wx * cellPx / res);
|
||||||
|
const py = Math.round(cy - wy * cellPx / res);
|
||||||
|
|
||||||
|
if (px < 0 || px >= W || py < 0 || py >= H) continue;
|
||||||
|
|
||||||
|
let ro, go, bo, ao;
|
||||||
|
if (val < 0) { ro=26; go=26; bo=46; ao=255; } // unknown
|
||||||
|
else if (val === 0) { ro=10; go=16; bo=32; ao=255; } // free
|
||||||
|
else if (val < 60) { ro=0; go=100; bo=120; ao=120; } // low occ
|
||||||
|
else { ro=0; go=184; bo=217; ao=220; } // occupied
|
||||||
|
|
||||||
|
const i = (py * W + px) * 4;
|
||||||
|
imgData[i] = ro; imgData[i+1] = go;
|
||||||
|
imgData[i+2] = bo; imgData[i+3] = ao;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.putImageData(img, 0, 0);
|
||||||
|
|
||||||
|
// ── Nav2 path ─────────────────────────────────────────────────────────
|
||||||
|
const path = pathRef.current;
|
||||||
|
if (path.length >= 2) {
|
||||||
|
ctx.strokeStyle = '#f59e0b';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.beginPath();
|
||||||
|
path.forEach(({ x, y }, i) => {
|
||||||
|
const px = cx + x * cellPx / res;
|
||||||
|
const py = cy - y * cellPx / res;
|
||||||
|
i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
|
||||||
|
});
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Robot ────────────────────────────────────────────────────────────
|
||||||
|
const odom = odomRef.current;
|
||||||
|
if (odom) {
|
||||||
|
const rx = cx + odom.x * cellPx / res;
|
||||||
|
const ry = cy - odom.y * cellPx / res;
|
||||||
|
|
||||||
|
// Heading arrow
|
||||||
|
const arrowLen = Math.max(12, cellPx * 1.5);
|
||||||
|
ctx.strokeStyle = '#f97316';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(rx, ry);
|
||||||
|
ctx.lineTo(rx + Math.cos(odom.yaw) * arrowLen, ry - Math.sin(odom.yaw) * arrowLen);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Robot circle
|
||||||
|
ctx.fillStyle = '#f97316';
|
||||||
|
ctx.shadowBlur = 8;
|
||||||
|
ctx.shadowColor = '#f97316';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(rx, ry, 6, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// Heading dot
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(rx + Math.cos(odom.yaw) * arrowLen, ry - Math.sin(odom.yaw) * arrowLen, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scale bar ─────────────────────────────────────────────────────────
|
||||||
|
if (mapInfo) {
|
||||||
|
const scaleM = 2; // 2-metre scale bar
|
||||||
|
const scalePx = scaleM * cellPx / res;
|
||||||
|
const bx = 12, by = H - 12;
|
||||||
|
ctx.strokeStyle = '#06b6d4';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(bx, by); ctx.lineTo(bx + scalePx, by);
|
||||||
|
ctx.moveTo(bx, by - 4); ctx.lineTo(bx, by + 4);
|
||||||
|
ctx.moveTo(bx + scalePx, by - 4); ctx.lineTo(bx + scalePx, by + 4);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = '#06b6d4';
|
||||||
|
ctx.font = '9px monospace';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText(`${scaleM}m`, bx, by - 6);
|
||||||
|
}
|
||||||
|
}, [zoom, pan, mapInfo]);
|
||||||
|
|
||||||
|
// ── Subscribe /map ────────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe('/map', 'nav_msgs/OccupancyGrid', (msg) => {
|
||||||
|
mapRef.current = msg;
|
||||||
|
setMapInfo(msg.info);
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe, render]);
|
||||||
|
|
||||||
|
// ── Subscribe /odom ───────────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe('/odom', 'nav_msgs/Odometry', (msg) => {
|
||||||
|
const p = msg.pose.pose.position;
|
||||||
|
const o = msg.pose.pose.orientation;
|
||||||
|
const pose = { x: p.x, y: p.y, yaw: quatToYaw(o) };
|
||||||
|
odomRef.current = pose;
|
||||||
|
setOdomPose(pose);
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe, render]);
|
||||||
|
|
||||||
|
// ── Subscribe /outdoor/route (Nav2 / OSM path) ────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe('/outdoor/route', 'nav_msgs/Path', (msg) => {
|
||||||
|
pathRef.current = (msg.poses ?? []).map(p => ({
|
||||||
|
x: p.pose.position.x,
|
||||||
|
y: p.pose.position.y,
|
||||||
|
}));
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe, render]);
|
||||||
|
|
||||||
|
// Re-render when zoom/pan changes
|
||||||
|
useEffect(() => { render(); }, [zoom, pan, render]);
|
||||||
|
|
||||||
|
// ── Mouse pan ─────────────────────────────────────────────────────────────
|
||||||
|
const onMouseDown = (e) => { dragging.current = { x: e.clientX - pan.x, y: e.clientY - pan.y }; };
|
||||||
|
const onMouseMove = (e) => {
|
||||||
|
if (!dragging.current) return;
|
||||||
|
setPan({ x: e.clientX - dragging.current.x, y: e.clientY - dragging.current.y });
|
||||||
|
};
|
||||||
|
const onMouseUp = () => { dragging.current = null; };
|
||||||
|
|
||||||
|
// Touch pan
|
||||||
|
const touchRef = useRef(null);
|
||||||
|
const onTouchStart = (e) => {
|
||||||
|
const t = e.touches[0];
|
||||||
|
touchRef.current = { x: t.clientX - pan.x, y: t.clientY - pan.y };
|
||||||
|
};
|
||||||
|
const onTouchMove = (e) => {
|
||||||
|
if (!touchRef.current) return;
|
||||||
|
const t = e.touches[0];
|
||||||
|
setPan({ x: t.clientX - touchRef.current.x, y: t.clientY - touchRef.current.y });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest">MAP VIEWER</div>
|
||||||
|
<div className="flex items-center gap-1 ml-auto">
|
||||||
|
<button onClick={() => setZoom(z => Math.min(z * 1.5, 20))}
|
||||||
|
className="px-2 py-1 rounded border border-gray-700 text-gray-300 hover:border-cyan-700 text-sm">+</button>
|
||||||
|
<span className="text-gray-500 text-xs w-10 text-center">{zoom.toFixed(1)}x</span>
|
||||||
|
<button onClick={() => setZoom(z => Math.max(z / 1.5, 0.2))}
|
||||||
|
className="px-2 py-1 rounded border border-gray-700 text-gray-300 hover:border-cyan-700 text-sm">−</button>
|
||||||
|
<button onClick={() => { setZoom(1); setPan({ x: 0, y: 0 }); }}
|
||||||
|
className="px-2 py-1 rounded border border-gray-700 text-gray-400 hover:text-gray-200 text-xs ml-2">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Canvas */}
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 overflow-hidden">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={600}
|
||||||
|
height={400}
|
||||||
|
className="w-full cursor-grab active:cursor-grabbing block"
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
onMouseMove={onMouseMove}
|
||||||
|
onMouseUp={onMouseUp}
|
||||||
|
onMouseLeave={onMouseUp}
|
||||||
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
|
onTouchEnd={() => { touchRef.current = null; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info bar */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||||
|
<div className="bg-gray-950 rounded border border-gray-800 p-2">
|
||||||
|
<div className="text-gray-600">MAP SIZE</div>
|
||||||
|
<div className="text-cyan-400 font-bold">
|
||||||
|
{mapInfo ? `${mapInfo.width}×${mapInfo.height}` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-950 rounded border border-gray-800 p-2">
|
||||||
|
<div className="text-gray-600">RESOLUTION</div>
|
||||||
|
<div className="text-cyan-400 font-bold">
|
||||||
|
{mapInfo ? `${mapInfo.resolution.toFixed(2)}m/cell` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-950 rounded border border-gray-800 p-2">
|
||||||
|
<div className="text-gray-600">ROBOT POS</div>
|
||||||
|
<div className="text-orange-400 font-bold">
|
||||||
|
{odomPose ? `${odomPose.x.toFixed(2)}, ${odomPose.y.toFixed(2)}` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-950 rounded border border-gray-800 p-2">
|
||||||
|
<div className="text-gray-600">HEADING</div>
|
||||||
|
<div className="text-orange-400 font-bold">
|
||||||
|
{odomPose ? `${(odomPose.yaw * 180 / Math.PI).toFixed(1)}°` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex gap-4 text-xs text-gray-600">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded-sm" style={{ background: '#1a1a2e' }} />Unknown
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded-sm" style={{ background: '#0a1020' }} />Free
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded-sm" style={{ background: '#00b8d9' }} />Occupied
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded-sm" style={{ background: '#f59e0b' }} />Path
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ background: '#f97316' }} />Robot
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
ui/social-bot/src/components/MotorPanel.jsx
Normal file
210
ui/social-bot/src/components/MotorPanel.jsx
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* MotorPanel.jsx — Motor telemetry display.
|
||||||
|
*
|
||||||
|
* Balance bot: /saltybot/balance_state (std_msgs/String JSON)
|
||||||
|
* motor_cmd [-1000..1000], bridge_speed, bridge_steer
|
||||||
|
*
|
||||||
|
* Rover mode: /saltybot/rover_pwm (std_msgs/String JSON)
|
||||||
|
* ch1_us, ch2_us, ch3_us, ch4_us [1000..2000]
|
||||||
|
*
|
||||||
|
* Temperatures: /diagnostics (diagnostic_msgs/DiagnosticArray)
|
||||||
|
* motor_temp_l_c, motor_temp_r_c
|
||||||
|
*
|
||||||
|
* Displays PWM-bar for each motor, RPM proxy, temperature.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const PWM_MIN = 1000;
|
||||||
|
const PWM_MID = 1500;
|
||||||
|
const PWM_MAX = 2000;
|
||||||
|
|
||||||
|
/** Map [-1..1] normalised value to bar width and color. */
|
||||||
|
function dutyBar(norm) {
|
||||||
|
const pct = Math.abs(norm) * 50;
|
||||||
|
const color = norm > 0 ? '#f97316' : '#3b82f6';
|
||||||
|
const left = norm >= 0 ? '50%' : `${50 - pct}%`;
|
||||||
|
return { pct, color, left };
|
||||||
|
}
|
||||||
|
|
||||||
|
function MotorGauge({ label, norm, pwmUs, tempC }) {
|
||||||
|
const { pct, color, left } = dutyBar(norm);
|
||||||
|
const tempWarn = tempC != null && tempC > 70;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-cyan-700 text-xs font-bold tracking-widest">{label}</span>
|
||||||
|
{tempC != null && (
|
||||||
|
<span className={`text-xs font-bold ${tempWarn ? 'text-red-400' : 'text-gray-400'}`}>
|
||||||
|
{tempC.toFixed(0)}°C
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PWM value */}
|
||||||
|
<div className="text-2xl font-bold text-orange-400 mb-2">
|
||||||
|
{pwmUs != null ? `${pwmUs} µs` : `${Math.round(norm * 1000)}`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bidirectional duty bar */}
|
||||||
|
<div className="relative h-3 bg-gray-900 rounded overflow-hidden border border-gray-800 mb-1">
|
||||||
|
<div className="absolute inset-y-0 left-1/2 w-px bg-gray-700" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 transition-all duration-100 rounded"
|
||||||
|
style={{ left, width: `${pct}%`, background: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-gray-700">
|
||||||
|
<span>REV</span>
|
||||||
|
<span>{(norm * 100).toFixed(0)}%</span>
|
||||||
|
<span>FWD</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MotorPanel({ subscribe }) {
|
||||||
|
const [balState, setBalState] = useState(null);
|
||||||
|
const [roverPwm, setRoverPwm] = useState(null);
|
||||||
|
const [temps, setTemps] = useState({ l: null, r: null });
|
||||||
|
|
||||||
|
// Balance state (2-wheel robot)
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe('/saltybot/balance_state', 'std_msgs/String', (msg) => {
|
||||||
|
try { setBalState(JSON.parse(msg.data)); }
|
||||||
|
catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
// Rover PWM (4-wheel rover)
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe('/saltybot/rover_pwm', 'std_msgs/String', (msg) => {
|
||||||
|
try { setRoverPwm(JSON.parse(msg.data)); }
|
||||||
|
catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
// Motor temperatures from diagnostics
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe('/diagnostics', 'diagnostic_msgs/DiagnosticArray', (msg) => {
|
||||||
|
for (const status of msg.status ?? []) {
|
||||||
|
const kv = {};
|
||||||
|
for (const pair of status.values ?? []) kv[pair.key] = pair.value;
|
||||||
|
if (kv.motor_temp_l_c !== undefined || kv.motor_temp_r_c !== undefined) {
|
||||||
|
setTemps({
|
||||||
|
l: kv.motor_temp_l_c != null ? parseFloat(kv.motor_temp_l_c) : null,
|
||||||
|
r: kv.motor_temp_r_c != null ? parseFloat(kv.motor_temp_r_c) : null,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
// Determine display mode: rover if rover_pwm active, else balance
|
||||||
|
const isRover = roverPwm != null;
|
||||||
|
|
||||||
|
let leftNorm = 0, rightNorm = 0;
|
||||||
|
let leftPwm = null, rightPwm = null;
|
||||||
|
|
||||||
|
if (isRover) {
|
||||||
|
// ch1=left-front, ch2=right-front, ch3=left-rear, ch4=right-rear
|
||||||
|
leftPwm = Math.round((roverPwm.ch1_us + (roverPwm.ch3_us ?? roverPwm.ch1_us)) / 2);
|
||||||
|
rightPwm = Math.round((roverPwm.ch2_us + (roverPwm.ch4_us ?? roverPwm.ch2_us)) / 2);
|
||||||
|
leftNorm = (leftPwm - PWM_MID) / (PWM_MAX - PWM_MID);
|
||||||
|
rightNorm = (rightPwm - PWM_MID) / (PWM_MAX - PWM_MID);
|
||||||
|
} else if (balState) {
|
||||||
|
const cmd = (balState.motor_cmd ?? 0) / 1000;
|
||||||
|
const steer = (balState.bridge_steer ?? 0) / 1000;
|
||||||
|
leftNorm = cmd + steer;
|
||||||
|
rightNorm = cmd - steer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clip to ±1
|
||||||
|
leftNorm = Math.max(-1, Math.min(1, leftNorm));
|
||||||
|
rightNorm = Math.max(-1, Math.min(1, rightNorm));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Mode indicator */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`px-2 py-0.5 rounded text-xs font-bold border ${
|
||||||
|
isRover
|
||||||
|
? 'bg-purple-950 border-purple-700 text-purple-300'
|
||||||
|
: 'bg-blue-950 border-blue-700 text-blue-300'
|
||||||
|
}`}>
|
||||||
|
{isRover ? 'ROVER (4WD)' : 'BALANCE (2WD)'}
|
||||||
|
</div>
|
||||||
|
{!balState && !roverPwm && (
|
||||||
|
<span className="text-gray-600 text-xs">No motor data</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Motor gauges */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<MotorGauge
|
||||||
|
label="LEFT MOTOR"
|
||||||
|
norm={leftNorm}
|
||||||
|
pwmUs={isRover ? leftPwm : null}
|
||||||
|
tempC={temps.l}
|
||||||
|
/>
|
||||||
|
<MotorGauge
|
||||||
|
label="RIGHT MOTOR"
|
||||||
|
norm={rightNorm}
|
||||||
|
pwmUs={isRover ? rightPwm : null}
|
||||||
|
tempC={temps.r}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance-specific details */}
|
||||||
|
{balState && !isRover && (
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-3">PID DETAILS</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||||
|
<div className="bg-gray-900 rounded p-2 text-center">
|
||||||
|
<div className="text-gray-600">CMD</div>
|
||||||
|
<div className="text-orange-400 font-bold">{balState.motor_cmd}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded p-2 text-center">
|
||||||
|
<div className="text-gray-600">ERROR</div>
|
||||||
|
<div className="text-red-400 font-bold">{balState.pid_error_deg}°</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded p-2 text-center">
|
||||||
|
<div className="text-gray-600">INTEGRAL</div>
|
||||||
|
<div className="text-purple-400 font-bold">{balState.integral}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 rounded p-2 text-center">
|
||||||
|
<div className="text-gray-600">FRAMES</div>
|
||||||
|
<div className="text-gray-400 font-bold">{balState.frames}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rover PWM details */}
|
||||||
|
{roverPwm && (
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest mb-3">CHANNEL PWM (µs)</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2 text-xs">
|
||||||
|
{[1,2,3,4].map(ch => {
|
||||||
|
const us = roverPwm[`ch${ch}_us`] ?? PWM_MID;
|
||||||
|
const norm = (us - PWM_MID) / (PWM_MAX - PWM_MID);
|
||||||
|
return (
|
||||||
|
<div key={ch} className="bg-gray-900 rounded p-2 text-center">
|
||||||
|
<div className="text-gray-600">CH{ch}</div>
|
||||||
|
<div className="font-bold" style={{ color: norm > 0.05 ? '#f97316' : norm < -0.05 ? '#3b82f6' : '#6b7280' }}>
|
||||||
|
{us}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
ui/social-bot/src/components/SystemHealth.jsx
Normal file
196
ui/social-bot/src/components/SystemHealth.jsx
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* SystemHealth.jsx — System resource monitoring and ROS2 node status.
|
||||||
|
*
|
||||||
|
* Topics:
|
||||||
|
* /diagnostics (diagnostic_msgs/DiagnosticArray)
|
||||||
|
* Each DiagnosticStatus has: name, level (0=OK,1=WARN,2=ERROR,3=STALE)
|
||||||
|
* message, hardware_id, values: [{key,value}]
|
||||||
|
* Expected KeyValue sources (from tegrastats bridge / custom nodes):
|
||||||
|
* cpu_temp_c, gpu_temp_c, ram_used_mb, ram_total_mb,
|
||||||
|
* disk_used_gb, disk_total_gb, gpu_used_mb, gpu_total_mb
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const LEVEL_CONFIG = {
|
||||||
|
0: { label: 'OK', color: 'text-green-400', bg: 'bg-green-950', border: 'border-green-800', dot: 'bg-green-400' },
|
||||||
|
1: { label: 'WARN', color: 'text-amber-400', bg: 'bg-amber-950', border: 'border-amber-800', dot: 'bg-amber-400 animate-pulse' },
|
||||||
|
2: { label: 'ERROR', color: 'text-red-400', bg: 'bg-red-950', border: 'border-red-800', dot: 'bg-red-400 animate-pulse' },
|
||||||
|
3: { label: 'STALE', color: 'text-gray-500', bg: 'bg-gray-900', border: 'border-gray-700', dot: 'bg-gray-500' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function ResourceBar({ label, used, total, unit, warnPct = 80 }) {
|
||||||
|
if (total == null || total === 0) return null;
|
||||||
|
const pct = Math.round((used / total) * 100);
|
||||||
|
const warn = pct >= warnPct;
|
||||||
|
const crit = pct >= 95;
|
||||||
|
const color = crit ? '#ef4444' : warn ? '#f59e0b' : '#06b6d4';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-gray-500">{label}</span>
|
||||||
|
<span className={warn ? 'text-amber-400' : 'text-gray-400'}>
|
||||||
|
{typeof used === 'number' ? used.toFixed(1) : used}/{typeof total === 'number' ? total.toFixed(1) : total} {unit}
|
||||||
|
<span className="text-gray-600 ml-1">({pct}%)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2.5 bg-gray-900 rounded overflow-hidden border border-gray-800">
|
||||||
|
<div
|
||||||
|
className="h-full transition-all duration-500 rounded"
|
||||||
|
style={{ width: `${pct}%`, background: color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TempGauge({ label, tempC }) {
|
||||||
|
if (tempC == null) return null;
|
||||||
|
const warn = tempC > 75;
|
||||||
|
const crit = tempC > 90;
|
||||||
|
const pct = Math.min(100, (tempC / 100) * 100);
|
||||||
|
const color = crit ? '#ef4444' : warn ? '#f59e0b' : '#22c55e';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 text-center">
|
||||||
|
<div className="text-gray-600 text-xs">{label}</div>
|
||||||
|
<div className="text-2xl font-bold mt-1" style={{ color }}>
|
||||||
|
{tempC.toFixed(0)}°C
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 bg-gray-900 rounded overflow-hidden mt-1.5">
|
||||||
|
<div className="h-full rounded" style={{ width: `${pct}%`, background: color }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeRow({ status }) {
|
||||||
|
const cfg = LEVEL_CONFIG[status.level] ?? LEVEL_CONFIG[3];
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 rounded px-3 py-1.5 border text-xs ${cfg.bg} ${cfg.border}`}>
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${cfg.dot}`} />
|
||||||
|
<span className="text-gray-300 font-bold truncate flex-1" title={status.name}>{status.name}</span>
|
||||||
|
<span className={`${cfg.color} shrink-0`}>{status.message || cfg.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemHealth({ subscribe }) {
|
||||||
|
const [resources, setResources] = useState({
|
||||||
|
cpuTemp: null, gpuTemp: null,
|
||||||
|
ramUsed: null, ramTotal: null,
|
||||||
|
diskUsed: null, diskTotal: null,
|
||||||
|
gpuUsed: null, gpuTotal: null,
|
||||||
|
});
|
||||||
|
const [nodes, setNodes] = useState([]);
|
||||||
|
const [lastTs, setLastTs] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = subscribe('/diagnostics', 'diagnostic_msgs/DiagnosticArray', (msg) => {
|
||||||
|
setLastTs(Date.now());
|
||||||
|
|
||||||
|
// Parse each DiagnosticStatus
|
||||||
|
const nodeList = [];
|
||||||
|
|
||||||
|
for (const status of msg.status ?? []) {
|
||||||
|
const kv = {};
|
||||||
|
for (const pair of status.values ?? []) kv[pair.key] = pair.value;
|
||||||
|
|
||||||
|
// Resource metrics
|
||||||
|
setResources(prev => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (kv.cpu_temp_c !== undefined) next.cpuTemp = parseFloat(kv.cpu_temp_c);
|
||||||
|
if (kv.gpu_temp_c !== undefined) next.gpuTemp = parseFloat(kv.gpu_temp_c);
|
||||||
|
if (kv.ram_used_mb !== undefined) next.ramUsed = parseFloat(kv.ram_used_mb) / 1024;
|
||||||
|
if (kv.ram_total_mb !== undefined) next.ramTotal = parseFloat(kv.ram_total_mb) / 1024;
|
||||||
|
if (kv.disk_used_gb !== undefined) next.diskUsed = parseFloat(kv.disk_used_gb);
|
||||||
|
if (kv.disk_total_gb!== undefined) next.diskTotal = parseFloat(kv.disk_total_gb);
|
||||||
|
if (kv.gpu_used_mb !== undefined) next.gpuUsed = parseFloat(kv.gpu_used_mb);
|
||||||
|
if (kv.gpu_total_mb !== undefined) next.gpuTotal = parseFloat(kv.gpu_total_mb);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect all statuses as node rows
|
||||||
|
nodeList.push({ name: status.name, level: status.level, message: status.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeList.length > 0) {
|
||||||
|
setNodes(nodeList);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
const stale = lastTs && Date.now() - lastTs > 10000;
|
||||||
|
const errorCount = nodes.filter(n => n.level === 2).length;
|
||||||
|
const warnCount = nodes.filter(n => n.level === 1).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Summary banner */}
|
||||||
|
{nodes.length > 0 && (
|
||||||
|
<div className={`flex items-center gap-3 rounded-lg border p-3 ${
|
||||||
|
errorCount > 0 ? 'bg-red-950 border-red-800' :
|
||||||
|
warnCount > 0 ? 'bg-amber-950 border-amber-800' :
|
||||||
|
'bg-green-950 border-green-800'
|
||||||
|
}`}>
|
||||||
|
<div className={`w-3 h-3 rounded-full ${
|
||||||
|
errorCount > 0 ? 'bg-red-400 animate-pulse' :
|
||||||
|
warnCount > 0 ? 'bg-amber-400 animate-pulse' : 'bg-green-400'
|
||||||
|
}`} />
|
||||||
|
<div className="text-sm font-bold">
|
||||||
|
{errorCount > 0 ? `${errorCount} ERROR${errorCount > 1 ? 'S' : ''}` :
|
||||||
|
warnCount > 0 ? `${warnCount} WARNING${warnCount > 1 ? 'S' : ''}` :
|
||||||
|
'All systems nominal'}
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto text-xs text-gray-500">{nodes.length} nodes</div>
|
||||||
|
{stale && <div className="text-red-400 text-xs">STALE</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Temperatures */}
|
||||||
|
{(resources.cpuTemp != null || resources.gpuTemp != null) && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<TempGauge label="CPU TEMP" tempC={resources.cpuTemp} />
|
||||||
|
<TempGauge label="GPU TEMP" tempC={resources.gpuTemp} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resource bars */}
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4 space-y-3">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest">SYSTEM RESOURCES</div>
|
||||||
|
<ResourceBar label="RAM" used={resources.ramUsed} total={resources.ramTotal} unit="GB" />
|
||||||
|
<ResourceBar label="GPU MEM" used={resources.gpuUsed} total={resources.gpuTotal} unit="MB" />
|
||||||
|
<ResourceBar label="DISK" used={resources.diskUsed} total={resources.diskTotal} unit="GB" warnPct={85} />
|
||||||
|
{resources.ramUsed == null && resources.gpuUsed == null && resources.diskUsed == null && (
|
||||||
|
<div className="text-gray-600 text-xs">
|
||||||
|
Waiting for resource metrics from /diagnostics…
|
||||||
|
<div className="mt-1 text-gray-700">Expected keys: cpu_temp_c, gpu_temp_c, ram_used_mb, disk_used_gb</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Node list */}
|
||||||
|
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="text-cyan-700 text-xs font-bold tracking-widest">ROS2 NODE HEALTH</div>
|
||||||
|
<div className="text-gray-600 text-xs">{nodes.length} statuses</div>
|
||||||
|
</div>
|
||||||
|
{nodes.length === 0 ? (
|
||||||
|
<div className="text-gray-600 text-xs text-center py-4 border border-dashed border-gray-800 rounded">
|
||||||
|
Waiting for /diagnostics…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 max-h-80 overflow-y-auto">
|
||||||
|
{/* Sort: errors first, then warns, then OK */}
|
||||||
|
{[...nodes]
|
||||||
|
.sort((a, b) => (b.level ?? 0) - (a.level ?? 0))
|
||||||
|
.map((n, i) => <NodeRow key={i} status={n} />)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,4 +11,16 @@ export default defineConfig({
|
|||||||
port: 8080,
|
port: 8080,
|
||||||
host: true,
|
host: true,
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
chunkSizeWarningLimit: 800,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'vendor-three': ['three'],
|
||||||
|
'vendor-roslib': ['roslib'],
|
||||||
|
'vendor-react': ['react', 'react-dom'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user