Adds 6 telemetry tabs to the social-bot dashboard extending PR #112. IMU Panel (/saltybot/imu, /saltybot/balance_state): - Canvas artificial horizon + compass tape - Three.js 3D robot orientation cube with quaternion SLERP - Angular velocity readouts, balance state detail Battery Panel (/diagnostics): - Voltage, SoC, estimated current, runtime estimate - 120-point voltage history sparkline (2 min) - Reads battery_voltage_v, battery_soc_pct KeyValues from DiagnosticArray Motor Panel (/saltybot/balance_state, /saltybot/rover_pwm): - Auto-detects balance vs rover mode - Bidirectional duty bars for left/right motors - Motor temp from /diagnostics, PID detail for balance bot Map Viewer (/map, /odom, /outdoor/route): - OccupancyGrid canvas renderer (unknown/free/occupied colour-coded) - Robot position + heading arrow, Nav2/OSM path overlay (dashed) - Pan (mouse/touch) + zoom, 2 m scale bar Control Mode (/saltybot/control_mode): - RC / RAMP_TO_AUTO / AUTO / RAMP_TO_RC state badge - Blend alpha progress bar - Safety flags: SLAM ok, RC link ok, stick override active - State machine diagram System Health (/diagnostics): - CPU/GPU temperature gauges with colour-coded bars - RAM, GPU memory, disk resource bars - ROS2 node status list sorted by severity (ERROR → WARN → OK) Also: - Three.js vendor chunk split (471 kB separate lazy chunk) - Updated App.jsx with grouped SOCIAL + TELEMETRY tab nav Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
380 lines
12 KiB
JavaScript
380 lines
12 KiB
JavaScript
/**
|
|
* 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>
|
|
);
|
|
}
|