feat: WebUI gimbal control panel (Issue #551) #557

Merged
sl-jetson merged 1 commits from sl-webui/issue-551-gimbal-webui into main 2026-03-14 11:36:50 -04:00
5 changed files with 1173 additions and 1 deletions
Showing only changes of commit cc3a65f4a4 - Show all commits

210
ui/gimbal_panel.css Normal file
View File

@ -0,0 +1,210 @@
/* gimbal_panel.css — Saltybot Gimbal Control Panel (Issue #551) */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
background: #050510;
color: #d1d5db;
height: 100dvh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ── Header ── */
#header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 16px;
background: #070712;
border-bottom: 1px solid #083344;
flex-shrink: 0;
flex-wrap: wrap;
gap: 6px;
}
#header .logo { color: #f97316; font-weight: bold; letter-spacing: 0.15em; font-size: 13px; }
#conn-bar { display: flex; align-items: center; gap: 6px; }
#conn-dot { width: 8px; height: 8px; border-radius: 50%; background: #374151; flex-shrink: 0; }
#conn-dot.connected { background: #4ade80; }
#conn-dot.error { background: #f87171; }
#ws-input {
background: #111827; border: 1px solid #1e3a5f; border-radius: 4px;
color: #67e8f9; padding: 2px 8px; font-family: monospace; font-size: 11px; width: 200px;
}
#ws-input:focus { outline: none; border-color: #0891b2; }
.btn {
padding: 3px 10px; border-radius: 4px; border: 1px solid; cursor: pointer;
font-family: monospace; font-size: 10px; font-weight: bold; letter-spacing: 0.05em;
transition: background 0.15s;
}
.btn-cyan { background: #083344; border-color: #155e75; color: #67e8f9; }
.btn-cyan:hover { background: #0e4f69; }
.btn-green { background: #052e16; border-color: #166534; color: #4ade80; }
.btn-green:hover { background: #0a4a24; }
.btn-amber { background: #451a03; border-color: #92400e; color: #fcd34d; }
.btn-amber:hover { background: #6b2b04; }
.btn-red { background: #450a0a; border-color: #991b1b; color: #f87171; }
.btn-red:hover { background: #6b1010; }
.btn-red.active { background: #7f1d1d; border-color: #dc2626; color: #fca5a5; animation: pulse 1.5s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}
/* ── Main layout ── */
#main {
flex: 1;
display: grid;
grid-template-columns: 1fr 280px;
gap: 12px;
padding: 12px;
min-height: 0;
}
@media (max-width: 720px) {
#main { grid-template-columns: 1fr; grid-template-rows: 1fr auto; }
body { font-size: 11px; overflow-y: auto; height: auto; }
}
/* ── Camera feed ── */
#camera-section {
display: flex; flex-direction: column; gap: 8px; min-height: 0;
}
#cam-toolbar {
display: flex; gap: 6px; align-items: center; flex-shrink: 0; flex-wrap: wrap;
}
#cam-toolbar span { color: #6b7280; font-size: 10px; margin-left: auto; }
#camera-frame {
flex: 1;
background: #000;
border-radius: 8px;
border: 1px solid #083344;
display: flex; align-items: center; justify-content: center;
position: relative;
overflow: hidden;
min-height: 160px;
}
#camera-img {
max-width: 100%; max-height: 100%;
object-fit: contain;
display: none;
}
#camera-img.visible { display: block; }
#no-signal {
display: flex; flex-direction: column; align-items: center; gap: 6px;
color: #374151; user-select: none;
}
#no-signal .icon { font-size: 36px; }
#fps-badge {
position: absolute; top: 8px; right: 8px;
background: rgba(0,0,0,0.7); color: #4ade80;
padding: 2px 6px; border-radius: 4px; font-size: 10px;
display: none;
}
#fps-badge.visible { display: block; }
/* Angle overlay on camera */
#angle-overlay {
position: absolute; bottom: 8px; left: 8px;
background: rgba(0,0,0,0.7);
padding: 4px 8px; border-radius: 4px;
color: #67e8f9; font-size: 10px; font-family: monospace;
display: none;
}
#angle-overlay.visible { display: block; }
/* ── Controls sidebar ── */
#controls {
display: flex; flex-direction: column; gap: 10px; overflow-y: auto; min-height: 0;
}
.card {
background: #070712; border: 1px solid #083344;
border-radius: 8px; padding: 10px;
}
.card-title {
font-size: 9px; font-weight: bold; letter-spacing: 0.15em;
color: #0891b2; margin-bottom: 8px; text-transform: uppercase;
}
/* ── Pan/Tilt Joystick ── */
#pad-wrap {
display: flex; flex-direction: column; align-items: center; gap: 6px;
}
#gimbal-pad {
cursor: crosshair;
border-radius: 6px;
border: 1px solid #1e3a5f;
touch-action: none;
user-select: none;
}
.pad-labels {
display: grid; grid-template-columns: 1fr auto 1fr;
width: 200px; font-size: 9px; color: #4b5563; text-align: center; align-items: center;
}
/* ── Angle display ── */
#angle-display {
display: grid; grid-template-columns: 1fr 1fr; gap: 6px;
}
.angle-box {
background: #0a0a1a; border: 1px solid #1e3a5f; border-radius: 6px;
padding: 6px; display: flex; flex-direction: column; gap: 2px;
}
.angle-label { font-size: 9px; color: #6b7280; }
.angle-value { font-size: 18px; color: #67e8f9; font-family: monospace; }
.angle-unit { font-size: 9px; color: #374151; }
/* ── Presets ── */
#presets {
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px;
}
#presets button { padding: 6px 4px; text-align: center; line-height: 1.3; }
#presets button .preset-name { display: block; font-size: 9px; font-weight: bold; letter-spacing: 0.05em; }
#presets button .preset-val { display: block; font-size: 9px; color: #6b7280; }
/* Home button */
#btn-home {
width: 100%; padding: 8px;
background: #1c1c2e; border: 1px solid #374151; border-radius: 6px;
color: #9ca3af; font-family: monospace; font-size: 10px; font-weight: bold;
letter-spacing: 0.1em; cursor: pointer; transition: background 0.15s;
}
#btn-home:hover { background: #2d2d44; color: #d1d5db; }
/* Tracking toggle */
#tracking-row {
display: flex; align-items: center; justify-content: space-between;
}
.toggle-label { font-size: 10px; color: #9ca3af; }
.toggle-switch {
position: relative; width: 40px; height: 20px;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0;
background: #1f2937; border-radius: 10px; cursor: pointer;
transition: background 0.2s;
}
.toggle-slider::before {
content: ''; position: absolute;
width: 14px; height: 14px; border-radius: 50%;
background: #6b7280; top: 3px; left: 3px;
transition: transform 0.2s, background 0.2s;
}
.toggle-switch input:checked + .toggle-slider { background: #0c4a6e; }
.toggle-switch input:checked + .toggle-slider::before {
background: #38bdf8; transform: translateX(20px);
}
/* ── Footer ── */
#footer {
background: #070712; border-top: 1px solid #083344;
padding: 4px 16px;
display: flex; align-items: center; justify-content: space-between;
flex-shrink: 0; font-size: 10px; color: #374151;
}

183
ui/gimbal_panel.html Normal file
View File

@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>Saltybot — Gimbal Control</title>
<link rel="stylesheet" href="gimbal_panel.css">
<!-- roslib from CDN -->
<script src="https://cdn.jsdelivr.net/npm/roslib@1.3.0/build/roslib.min.js"></script>
<style>
/* Cam button active state (can't use CSS-only with JS-toggled class without Tailwind) */
.cam-btn { padding: 3px 10px; border-radius: 4px; border: 1px solid #1e3a5f;
background: #070712; color: #4b5563; font-family: monospace;
font-size: 10px; font-weight: bold; cursor: pointer; transition: all 0.15s; }
.cam-btn:hover { color: #d1d5db; }
.cam-btn.active { background: #083344; border-color: #155e75; color: #67e8f9; }
</style>
</head>
<body>
<!-- ── Header ── -->
<div id="header">
<div class="logo">⚡ SALTYBOT — GIMBAL</div>
<div id="conn-bar">
<div id="conn-dot"></div>
<input id="ws-input" type="text" value="ws://localhost:9090" placeholder="ws://robot-ip:9090" />
<button id="btn-connect" class="btn btn-cyan">CONNECT</button>
<span id="conn-status" style="color:#4b5563;font-size:10px;">Not connected</span>
</div>
</div>
<!-- ── Main ── -->
<div id="main">
<!-- ── Left: Camera feed ── -->
<section id="camera-section">
<!-- Camera selector toolbar -->
<div id="cam-toolbar">
<button class="cam-btn active" data-cam="front">FRONT</button>
<button class="cam-btn" data-cam="rear" >REAR</button>
<button class="cam-btn" data-cam="left" >LEFT</button>
<button class="cam-btn" data-cam="right">RIGHT</button>
<span id="cam-topic">/camera/front/image_raw/compressed</span>
</div>
<!-- Video frame -->
<div id="camera-frame">
<img id="camera-img" alt="gimbal camera feed" />
<div id="no-signal">
<div class="icon">📷</div>
<div>NO SIGNAL</div>
</div>
<div id="fps-badge">— fps</div>
<div id="angle-overlay">PAN 0.0° TILT 0.0°</div>
</div>
</section>
<!-- ── Right: Controls ── -->
<aside id="controls">
<!-- Pan/Tilt pad -->
<div class="card">
<div class="card-title">PAN / TILT CONTROL</div>
<div id="pad-wrap">
<div class="pad-labels">
<span>← PAN</span>
<span style="color:#155e75">DRAG</span>
<span>PAN →</span>
</div>
<canvas id="gimbal-pad" width="200" height="150"></canvas>
<div style="font-size:9px;color:#374151;text-align:center">
↑ TILT UP &nbsp;|&nbsp; ↓ TILT DOWN
</div>
</div>
</div>
<!-- Current angles -->
<div class="card">
<div class="card-title">CURRENT ANGLES (feedback)</div>
<div id="angle-display">
<div class="angle-box">
<div class="angle-label">PAN</div>
<div class="angle-value" id="pan-val">0.0</div>
<div class="angle-unit">degrees</div>
</div>
<div class="angle-box">
<div class="angle-label">TILT</div>
<div class="angle-value" id="tilt-val">0.0</div>
<div class="angle-unit">degrees</div>
</div>
</div>
</div>
<!-- Preset positions -->
<div class="card">
<div class="card-title">PRESET POSITIONS</div>
<div id="presets">
<button class="btn btn-cyan" data-preset="0">
<span class="preset-name">CENTER</span>
<span class="preset-val">0° / 0°</span>
</button>
<button class="btn btn-cyan" data-preset="1">
<span class="preset-name">LEFT</span>
<span class="preset-val">-45° / 0°</span>
</button>
<button class="btn btn-cyan" data-preset="2">
<span class="preset-name">RIGHT</span>
<span class="preset-val">+45° / 0°</span>
</button>
<button class="btn btn-cyan" data-preset="3">
<span class="preset-name">UP</span>
<span class="preset-val">0° / +45°</span>
</button>
<button class="btn btn-cyan" data-preset="4">
<span class="preset-name">DOWN</span>
<span class="preset-val">0° / -30°</span>
</button>
</div>
<button id="btn-home" style="margin-top:8px">⌂ HOME (0° / 0°)</button>
</div>
<!-- Person tracking -->
<div class="card">
<div class="card-title">PERSON TRACKING</div>
<div id="tracking-row">
<span class="toggle-label" id="tracking-label">OFF — manual control</span>
<label class="toggle-switch">
<input type="checkbox" id="tracking-toggle">
<span class="toggle-slider"></span>
</label>
</div>
<div style="font-size:9px;color:#4b5563;margin-top:6px">
Publishes to <code>/gimbal/tracking_enabled</code>
</div>
</div>
<!-- Topic info -->
<div class="card" style="font-size:9px;color:#4b5563;line-height:1.8">
<div class="card-title">ROS TOPICS</div>
<div>PUB <code style="color:#374151">/gimbal/cmd</code> → geometry_msgs/Vector3</div>
<div>SUB <code style="color:#374151">/gimbal/state</code> → geometry_msgs/Vector3</div>
<div>PUB <code style="color:#374151">/gimbal/tracking_enabled</code> → std_msgs/Bool</div>
<div>SUB <code style="color:#374151">/camera/*/image_raw/compressed</code></div>
</div>
</aside>
</div>
<!-- ── Footer ── -->
<div id="footer">
<span>rosbridge: <code id="footer-ws">ws://localhost:9090</code></span>
<span>gimbal control panel — issue #551</span>
</div>
<script src="gimbal_panel.js"></script>
<script>
// Sync footer ws URL
document.getElementById('ws-input').addEventListener('input', (e) => {
document.getElementById('footer-ws').textContent = e.target.value;
});
// Tracking label update
document.getElementById('tracking-toggle').addEventListener('change', (e) => {
document.getElementById('tracking-label').textContent =
e.target.checked ? 'ON — following person' : 'OFF — manual control';
});
// Camera topic label update
document.querySelectorAll('.cam-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const cam = btn.dataset.cam;
document.getElementById('cam-topic').textContent =
`/camera/${cam}/image_raw/compressed`;
document.getElementById('footer-ws').textContent =
document.getElementById('ws-input').value;
});
});
</script>
</body>
</html>

392
ui/gimbal_panel.js Normal file
View File

@ -0,0 +1,392 @@
/**
* gimbal_panel.js Saltybot Gimbal Control Panel (Issue #551)
*
* Connects to rosbridge via WebSocket (roslib.js).
* Publishes pan/tilt commands, subscribes to gimbal state + camera.
*
* ROS topics:
* /gimbal/cmd geometry_msgs/Vector3 publish (x=pan°, y=tilt°)
* /gimbal/state geometry_msgs/Vector3 subscribe (x=pan°, y=tilt°)
* /gimbal/tracking_enabled std_msgs/Bool publish
* /camera/front/image_raw/compressed sensor_msgs/CompressedImage subscribe
*/
'use strict';
// ── Config ────────────────────────────────────────────────────────────────────
const PAN_MIN = -180; // degrees
const PAN_MAX = 180;
const TILT_MIN = -45; // degrees
const TILT_MAX = 90;
const CMD_TOPIC = '/gimbal/cmd';
const STATE_TOPIC = '/gimbal/state';
const TRACKING_TOPIC = '/gimbal/tracking_enabled';
const CAM_TOPIC = '/camera/front/image_raw/compressed';
const CAM_TOPICS = {
front: '/camera/front/image_raw/compressed',
rear: '/camera/rear/image_raw/compressed',
left: '/camera/left/image_raw/compressed',
right: '/camera/right/image_raw/compressed',
};
const PRESETS = [
{ name: 'CENTER', pan: 0, tilt: 0 },
{ name: 'LEFT', pan: -45, tilt: 0 },
{ name: 'RIGHT', pan: 45, tilt: 0 },
{ name: 'UP', pan: 0, tilt: 45 },
{ name: 'DOWN', pan: 0, tilt:-30 },
{ name: 'HOME', pan: 0, tilt: 0 },
];
// ── State ─────────────────────────────────────────────────────────────────────
let ros = null;
let cmdTopic = null;
let trackingTopic = null;
let stateSub = null;
let camSub = null;
let currentPan = 0;
let currentTilt = 0;
let targetPan = 0;
let targetTilt = 0;
let trackingOn = false;
let selectedCam = 'front';
// Camera FPS tracking
let fpsCount = 0;
let fpsTime = Date.now();
let fps = 0;
// Pad dragging state
let padDragging = false;
// ── DOM refs ──────────────────────────────────────────────────────────────────
const connDot = document.getElementById('conn-dot');
const connStatus = document.getElementById('conn-status');
const wsInput = document.getElementById('ws-input');
const padCanvas = document.getElementById('gimbal-pad');
const panVal = document.getElementById('pan-val');
const tiltVal = document.getElementById('tilt-val');
const camImg = document.getElementById('camera-img');
const noSignal = document.getElementById('no-signal');
const fpsBadge = document.getElementById('fps-badge');
const angleOvl = document.getElementById('angle-overlay');
// ── Connection ────────────────────────────────────────────────────────────────
function connect() {
const url = wsInput.value.trim();
if (!url) return;
if (ros) { ros.close(); }
ros = new ROSLIB.Ros({ url });
ros.on('connection', () => {
connDot.className = 'connected';
connStatus.textContent = url;
document.getElementById('btn-connect').textContent = 'RECONNECT';
setupTopics();
});
ros.on('error', (err) => {
connDot.className = 'error';
connStatus.textContent = 'ERROR: ' + (err?.message || err);
});
ros.on('close', () => {
connDot.className = '';
connStatus.textContent = 'DISCONNECTED';
teardownTopics();
});
}
function setupTopics() {
// Publish: /gimbal/cmd
cmdTopic = new ROSLIB.Topic({
ros, name: CMD_TOPIC,
messageType: 'geometry_msgs/Vector3',
});
// Publish: /gimbal/tracking_enabled
trackingTopic = new ROSLIB.Topic({
ros, name: TRACKING_TOPIC,
messageType: 'std_msgs/Bool',
});
// Subscribe: /gimbal/state
stateSub = new ROSLIB.Topic({
ros, name: STATE_TOPIC,
messageType: 'geometry_msgs/Vector3',
throttle_rate: 100,
});
stateSub.subscribe((msg) => {
currentPan = msg.x ?? 0;
currentTilt = msg.y ?? 0;
updateAngleDisplay();
drawPad();
});
// Subscribe: camera
subscribeCamera(selectedCam);
}
function teardownTopics() {
if (stateSub) { stateSub.unsubscribe(); stateSub = null; }
if (camSub) { camSub.unsubscribe(); camSub = null; }
cmdTopic = trackingTopic = null;
}
function subscribeCamera(camId) {
if (camSub) { camSub.unsubscribe(); camSub = null; }
const topic = CAM_TOPICS[camId] ?? CAM_TOPICS.front;
if (!ros) return;
camSub = new ROSLIB.Topic({
ros, name: topic,
messageType: 'sensor_msgs/CompressedImage',
throttle_rate: 50,
});
camSub.subscribe((msg) => {
const fmt = msg.format?.includes('png') ? 'png' : 'jpeg';
camImg.src = `data:image/${fmt};base64,${msg.data}`;
camImg.classList.add('visible');
noSignal.style.display = 'none';
fpsBadge.classList.add('visible');
angleOvl.classList.add('visible');
// FPS
fpsCount++;
const now = Date.now();
if (now - fpsTime >= 1000) {
fps = Math.round(fpsCount * 1000 / (now - fpsTime));
fpsCount = 0; fpsTime = now;
fpsBadge.textContent = fps + ' fps';
}
});
}
// ── Publish helpers ───────────────────────────────────────────────────────────
function publishCmd(pan, tilt) {
if (!cmdTopic) return;
cmdTopic.publish(new ROSLIB.Message({ x: pan, y: tilt, z: 0.0 }));
}
function publishTracking(enabled) {
if (!trackingTopic) return;
trackingTopic.publish(new ROSLIB.Message({ data: enabled }));
}
// ── Gimbal pad drawing ────────────────────────────────────────────────────────
function drawPad() {
const canvas = padCanvas;
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
ctx.clearRect(0, 0, W, H);
// Background
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, W, H);
// Grid lines
ctx.strokeStyle = '#1e3a5f';
ctx.lineWidth = 1;
// Vertical lines
for (let i = 1; i < 4; i++) {
ctx.beginPath();
ctx.moveTo(W * i / 4, 0); ctx.lineTo(W * i / 4, H);
ctx.stroke();
}
// Horizontal lines
for (let i = 1; i < 4; i++) {
ctx.beginPath();
ctx.moveTo(0, H * i / 4); ctx.lineTo(W, H * i / 4);
ctx.stroke();
}
// Center cross
ctx.strokeStyle = '#374151';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(W / 2, 0); ctx.lineTo(W / 2, H);
ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2);
ctx.stroke();
// Helpers to map degrees ↔ canvas coords
const panToX = (p) => ((p - PAN_MIN) / (PAN_MAX - PAN_MIN)) * W;
const tiltToY = (t) => (1 - (t - TILT_MIN) / (TILT_MAX - TILT_MIN)) * H;
// Target position (what we're commanding)
const tx = panToX(targetPan);
const ty = tiltToY(targetTilt);
// Line from center to target
ctx.strokeStyle = 'rgba(6,182,212,0.3)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(W / 2, H / 2);
ctx.lineTo(tx, ty);
ctx.stroke();
// Target ring
ctx.strokeStyle = '#0891b2';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(tx, ty, 10, 0, Math.PI * 2);
ctx.stroke();
// Current position (feedback)
const cx_ = panToX(currentPan);
const cy_ = tiltToY(currentTilt);
ctx.fillStyle = '#22d3ee';
ctx.beginPath();
ctx.arc(cx_, cy_, 6, 0, Math.PI * 2);
ctx.fill();
// Labels: axis limits
ctx.fillStyle = '#374151';
ctx.font = '9px Courier New';
ctx.fillText(`${PAN_MIN}°`, 2, H / 2 - 3);
ctx.fillText(`${PAN_MAX}°`, W - 24, H / 2 - 3);
ctx.fillText(`${TILT_MAX}°`, W / 2 + 3, 11);
ctx.fillText(`${TILT_MIN}°`, W / 2 + 3, H - 3);
// Current angle text
ctx.fillStyle = '#0891b2';
ctx.font = 'bold 9px Courier New';
ctx.fillText(`PAN ${targetPan.toFixed(0)}° TILT ${targetTilt.toFixed(0)}°`, 4, H - 3);
}
// ── Pad interaction ───────────────────────────────────────────────────────────
function padCoordsToAngles(clientX, clientY) {
const rect = padCanvas.getBoundingClientRect();
const rx = (clientX - rect.left) / rect.width;
const ry = (clientY - rect.top) / rect.height;
const pan = PAN_MIN + rx * (PAN_MAX - PAN_MIN);
const tilt = TILT_MAX - ry * (TILT_MAX - TILT_MIN);
return {
pan: Math.max(PAN_MIN, Math.min(PAN_MAX, pan)),
tilt: Math.max(TILT_MIN, Math.min(TILT_MAX, tilt)),
};
}
function padPointerDown(e) {
padDragging = true;
padCanvas.setPointerCapture(e.pointerId);
const { pan, tilt } = padCoordsToAngles(e.clientX, e.clientY);
setTarget(pan, tilt);
}
function padPointerMove(e) {
if (!padDragging) return;
const { pan, tilt } = padCoordsToAngles(e.clientX, e.clientY);
setTarget(pan, tilt);
}
function padPointerUp() {
padDragging = false;
}
padCanvas.addEventListener('pointerdown', padPointerDown);
padCanvas.addEventListener('pointermove', padPointerMove);
padCanvas.addEventListener('pointerup', padPointerUp);
padCanvas.addEventListener('pointercancel', padPointerUp);
// Touch passthrough
padCanvas.addEventListener('touchstart', (e) => e.preventDefault(), { passive: false });
padCanvas.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
// ── Target update ─────────────────────────────────────────────────────────────
function setTarget(pan, tilt) {
targetPan = Math.round(pan * 10) / 10;
targetTilt = Math.round(tilt * 10) / 10;
updateAngleDisplay();
drawPad();
publishCmd(targetPan, targetTilt);
}
function updateAngleDisplay() {
panVal.textContent = currentPan.toFixed(1);
tiltVal.textContent = currentTilt.toFixed(1);
angleOvl.textContent = `PAN ${currentPan.toFixed(1)}° TILT ${currentTilt.toFixed(1)}°`;
}
// ── Presets ───────────────────────────────────────────────────────────────────
function applyPreset(pan, tilt) {
setTarget(pan, tilt);
}
function goHome() {
setTarget(0, 0);
}
// ── Tracking toggle ───────────────────────────────────────────────────────────
document.getElementById('tracking-toggle').addEventListener('change', (e) => {
trackingOn = e.target.checked;
publishTracking(trackingOn);
});
// ── Camera selector ───────────────────────────────────────────────────────────
document.querySelectorAll('.cam-btn').forEach((btn) => {
btn.addEventListener('click', () => {
selectedCam = btn.dataset.cam;
document.querySelectorAll('.cam-btn').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
subscribeCamera(selectedCam);
// Reset fps display
camImg.classList.remove('visible');
noSignal.style.display = '';
fpsBadge.classList.remove('visible');
angleOvl.classList.remove('visible');
fpsCount = 0; fpsTime = Date.now();
});
});
// ── Connect button ────────────────────────────────────────────────────────────
document.getElementById('btn-connect').addEventListener('click', connect);
wsInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') connect(); });
// ── Preset buttons ────────────────────────────────────────────────────────────
document.querySelectorAll('[data-preset]').forEach((btn) => {
const idx = parseInt(btn.dataset.preset, 10);
const p = PRESETS[idx];
btn.addEventListener('click', () => applyPreset(p.pan, p.tilt));
});
document.getElementById('btn-home').addEventListener('click', goHome);
// ── Init ──────────────────────────────────────────────────────────────────────
// Size canvas to fill its container initially
function resizePad() {
const w = padCanvas.offsetWidth || 200;
padCanvas.width = w;
padCanvas.height = Math.round(w * 0.75); // 4:3 aspect
drawPad();
}
window.addEventListener('resize', resizePad);
resizePad();
// Auto-connect on load using stored URL or default
const storedUrl = localStorage.getItem('gimbal_ws_url');
if (storedUrl) wsInput.value = storedUrl;
wsInput.addEventListener('change', () => {
localStorage.setItem('gimbal_ws_url', wsInput.value);
});
// Initial draw
drawPad();
updateAngleDisplay();

View File

@ -94,12 +94,16 @@ import { ParameterServer } from './components/ParameterServer.jsx';
// Teleop web interface (issue #534)
import { TeleopWebUI } from './components/TeleopWebUI.jsx';
// Gimbal control panel (issue #551)
import { GimbalPanel } from './components/GimbalPanel.jsx';
const TAB_GROUPS = [
{
label: 'TELEOP',
color: 'text-orange-600',
tabs: [
{ id: 'teleop-webui', label: 'Drive' },
{ id: 'gimbal', label: 'Gimbal' },
],
},
{
@ -296,9 +300,10 @@ export default function App() {
{/* ── Content ── */}
<main className={`flex-1 ${
activeTab === 'salty-face' ? '' :
['eventlog', 'control', 'imu', 'teleop-webui'].includes(activeTab) ? 'flex flex-col' : 'overflow-y-auto'
['eventlog', 'control', 'imu', 'teleop-webui', 'gimbal'].includes(activeTab) ? 'flex flex-col' : 'overflow-y-auto'
} ${activeTab === 'salty-face' ? '' : 'p-4'}`}>
{activeTab === 'teleop-webui' && <TeleopWebUI subscribe={subscribe} publish={publishFn} />}
{activeTab === 'gimbal' && <GimbalPanel subscribe={subscribe} publish={publishFn} />}
{activeTab === 'salty-face' && <SaltyFace subscribe={subscribe} />}

View File

@ -0,0 +1,382 @@
/**
* GimbalPanel.jsx Gimbal control panel with live camera preview (Issue #551).
*
* Features:
* - Interactive 2-D pan/tilt joystick pad (click-drag + touch)
* - Live camera stream via rosbridge (sensor_msgs/CompressedImage)
* - Camera selector: front / rear / left / right
* - Preset positions: CENTER / LEFT / RIGHT / UP / DOWN
* - Home button (0°, 0°)
* - Person-tracking toggle (publishes std_msgs/Bool)
* - Current angle display from /gimbal/state feedback
* - Mobile-responsive two-column single-column layout
*
* ROS topics:
* /gimbal/cmd geometry_msgs/Vector3 publish (x=pan°, y=tilt°)
* /gimbal/state geometry_msgs/Vector3 subscribe
* /gimbal/tracking_enabled std_msgs/Bool publish
* /camera/<name>/image_raw/compressed sensor_msgs/CompressedImage subscribe
*/
import { useEffect, useRef, useState, useCallback } from 'react';
// Constants
const PAN_MIN = -180;
const PAN_MAX = 180;
const TILT_MIN = -45;
const TILT_MAX = 90;
const CAMERAS = [
{ id: 'front', label: 'Front', topic: '/camera/front/image_raw/compressed' },
{ id: 'rear', label: 'Rear', topic: '/camera/rear/image_raw/compressed' },
{ id: 'left', label: 'Left', topic: '/camera/left/image_raw/compressed' },
{ id: 'right', label: 'Right', topic: '/camera/right/image_raw/compressed' },
];
const PRESETS = [
{ label: 'CENTER', pan: 0, tilt: 0 },
{ label: 'LEFT', pan: -45, tilt: 0 },
{ label: 'RIGHT', pan: 45, tilt: 0 },
{ label: 'UP', pan: 0, tilt: 45 },
{ label: 'DOWN', pan: 0, tilt:-30 },
];
// Pan/Tilt pad
function GimbalPad({ targetPan, targetTilt, currentPan, currentTilt, onMove }) {
const canvasRef = useRef(null);
const dragging = useRef(false);
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, W, H);
// Grid
ctx.strokeStyle = '#1e3a5f';
ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) {
ctx.beginPath(); ctx.moveTo(W * i / 4, 0); ctx.lineTo(W * i / 4, H); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, H * i / 4); ctx.lineTo(W, H * i / 4); ctx.stroke();
}
// Center cross
ctx.strokeStyle = '#374151';
ctx.beginPath();
ctx.moveTo(W / 2, 0); ctx.lineTo(W / 2, H);
ctx.moveTo(0, H / 2); ctx.lineTo(W, H / 2);
ctx.stroke();
const panToX = (p) => ((p - PAN_MIN) / (PAN_MAX - PAN_MIN)) * W;
const tiltToY = (t) => (1 - (t - TILT_MIN) / (TILT_MAX - TILT_MIN)) * H;
// Targeting line
const tx = panToX(targetPan);
const ty = tiltToY(targetTilt);
ctx.strokeStyle = 'rgba(6,182,212,0.25)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(W / 2, H / 2); ctx.lineTo(tx, ty); ctx.stroke();
// Target ring
ctx.strokeStyle = '#0891b2';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(tx, ty, 10, 0, Math.PI * 2); ctx.stroke();
// Cross-hair inside ring
ctx.strokeStyle = '#0e7490';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(tx - 6, ty); ctx.lineTo(tx + 6, ty);
ctx.moveTo(tx, ty - 6); ctx.lineTo(tx, ty + 6);
ctx.stroke();
// Current position dot (feedback)
const cx_ = panToX(currentPan);
const cy_ = tiltToY(currentTilt);
ctx.fillStyle = '#22d3ee';
ctx.beginPath(); ctx.arc(cx_, cy_, 5, 0, Math.PI * 2); ctx.fill();
// Axis labels
ctx.fillStyle = '#374151';
ctx.font = '9px Courier New';
ctx.fillText(`${PAN_MIN}°`, 3, H / 2 - 3);
ctx.fillText(`${PAN_MAX}°`, W - 28, H / 2 - 3);
ctx.fillText(`+${TILT_MAX}°`, W / 2 + 3, 11);
ctx.fillText(`${TILT_MIN}°`, W / 2 + 3, H - 3);
// Live readout
ctx.fillStyle = '#0891b2';
ctx.font = 'bold 9px Courier New';
ctx.fillText(`${targetPan.toFixed(0)}° / ${targetTilt.toFixed(0)}°`, 4, H - 3);
}, [targetPan, targetTilt, currentPan, currentTilt]);
useEffect(() => { draw(); }, [draw]);
const toAngles = (clientX, clientY) => {
const rect = canvasRef.current.getBoundingClientRect();
const rx = (clientX - rect.left) / rect.width;
const ry = (clientY - rect.top) / rect.height;
const pan = PAN_MIN + rx * (PAN_MAX - PAN_MIN);
const tilt = TILT_MAX - ry * (TILT_MAX - TILT_MIN);
return {
pan: Math.max(PAN_MIN, Math.min(PAN_MAX, pan)),
tilt: Math.max(TILT_MIN, Math.min(TILT_MAX, tilt)),
};
};
const onPD = useCallback((e) => {
dragging.current = true;
canvasRef.current.setPointerCapture(e.pointerId);
const { pan, tilt } = toAngles(e.clientX, e.clientY);
onMove(pan, tilt);
}, [onMove]);
const onPM = useCallback((e) => {
if (!dragging.current) return;
const { pan, tilt } = toAngles(e.clientX, e.clientY);
onMove(pan, tilt);
}, [onMove]);
const onPU = useCallback(() => { dragging.current = false; }, []);
return (
<canvas
ref={canvasRef}
width={240}
height={160}
className="rounded border border-cyan-950 cursor-crosshair w-full"
style={{ touchAction: 'none' }}
onPointerDown={onPD}
onPointerMove={onPM}
onPointerUp={onPU}
onPointerCancel={onPU}
/>
);
}
// Camera feed
function CameraFeed({ subscribe, topic }) {
const [src, setSrc] = useState(null);
const [fps, setFps] = useState(0);
const cntRef = useRef(0);
const timeRef = useRef(Date.now());
useEffect(() => {
setSrc(null);
cntRef.current = 0;
timeRef.current = Date.now();
const unsub = subscribe(topic, 'sensor_msgs/CompressedImage', (msg) => {
const fmt = msg.format?.includes('png') ? 'png' : 'jpeg';
setSrc(`data:image/${fmt};base64,${msg.data}`);
cntRef.current++;
const now = Date.now();
const dt = now - timeRef.current;
if (dt >= 1000) {
setFps(Math.round(cntRef.current * 1000 / dt));
cntRef.current = 0;
timeRef.current = now;
}
});
return unsub;
}, [subscribe, topic]);
return (
<div className="relative flex-1 min-h-0 bg-black rounded-lg border border-cyan-950 flex items-center justify-center overflow-hidden">
{src ? (
<img src={src} alt="gimbal camera" className="max-w-full max-h-full object-contain" draggable={false} />
) : (
<div className="flex flex-col items-center gap-2 text-gray-700">
<div className="text-4xl">📷</div>
<div className="text-xs tracking-widest">NO SIGNAL</div>
</div>
)}
{src && (
<div className="absolute top-2 right-2 bg-black bg-opacity-60 text-green-400 text-xs font-mono px-1.5 py-0.5 rounded">
{fps} fps
</div>
)}
</div>
);
}
// Main component
export function GimbalPanel({ subscribe, publish }) {
const [selectedCam, setSelectedCam] = useState('front');
const [targetPan, setTargetPan] = useState(0);
const [targetTilt, setTargetTilt] = useState(0);
const [currentPan, setCurrentPan] = useState(0);
const [currentTilt, setCurrentTilt] = useState(0);
const [tracking, setTracking] = useState(false);
const currentCam = CAMERAS.find(c => c.id === selectedCam);
// Subscribe to gimbal state feedback
useEffect(() => {
const unsub = subscribe('/gimbal/state', 'geometry_msgs/Vector3', (msg) => {
setCurrentPan(msg.x ?? 0);
setCurrentTilt(msg.y ?? 0);
});
return unsub;
}, [subscribe]);
// Publish gimbal command
const sendCmd = useCallback((pan, tilt) => {
const p = Math.round(pan * 10) / 10;
const t = Math.round(tilt * 10) / 10;
setTargetPan(p);
setTargetTilt(t);
if (publish) {
publish('/gimbal/cmd', 'geometry_msgs/Vector3', { x: p, y: t, z: 0.0 });
}
}, [publish]);
// Preset handler
const applyPreset = useCallback((pan, tilt) => {
sendCmd(pan, tilt);
}, [sendCmd]);
// Tracking toggle
const toggleTracking = () => {
const next = !tracking;
setTracking(next);
if (publish) {
publish('/gimbal/tracking_enabled', 'std_msgs/Bool', { data: next });
}
};
return (
<div className="flex flex-col lg:flex-row h-full gap-3">
{/* ── Left: Camera + pad ── */}
<div className="flex flex-col gap-2 flex-1 min-h-0">
{/* Camera selector */}
<div className="flex gap-1 shrink-0 flex-wrap">
{CAMERAS.map((cam) => (
<button
key={cam.id}
onClick={() => setSelectedCam(cam.id)}
className={`px-3 py-1 text-xs font-bold rounded border tracking-widest transition-colors ${
selectedCam === cam.id
? 'bg-cyan-950 border-cyan-700 text-cyan-300'
: 'bg-gray-950 border-gray-800 text-gray-600 hover:text-gray-300'
}`}
>
{cam.label.toUpperCase()}
</button>
))}
<div className="flex-1" />
<div className="text-xs text-gray-700 self-center font-mono truncate max-w-xs">
{currentCam?.topic}
</div>
</div>
{/* Camera feed */}
<CameraFeed subscribe={subscribe} topic={currentCam?.topic} />
</div>
{/* ── Right: Controls ── */}
<div className="flex flex-col gap-3 w-full lg:w-72 shrink-0">
{/* Pan/Tilt pad */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 space-y-2">
<div className="text-xs font-bold tracking-widest text-cyan-700">PAN / TILT</div>
<GimbalPad
targetPan={targetPan}
targetTilt={targetTilt}
currentPan={currentPan}
currentTilt={currentTilt}
onMove={sendCmd}
/>
<div className="text-xs text-gray-700 text-center">click or drag to aim</div>
</div>
{/* Angle readout */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 grid grid-cols-2 gap-2">
<div>
<div className="text-gray-600 text-xs mb-1">TARGET PAN</div>
<div className="text-xl font-mono text-cyan-300">{targetPan.toFixed(1)}°</div>
</div>
<div>
<div className="text-gray-600 text-xs mb-1">TARGET TILT</div>
<div className="text-xl font-mono text-amber-300">{targetTilt.toFixed(1)}°</div>
</div>
<div>
<div className="text-gray-600 text-xs mb-1">ACTUAL PAN</div>
<div className="text-sm font-mono text-cyan-600">{currentPan.toFixed(1)}°</div>
</div>
<div>
<div className="text-gray-600 text-xs mb-1">ACTUAL TILT</div>
<div className="text-sm font-mono text-amber-600">{currentTilt.toFixed(1)}°</div>
</div>
</div>
{/* Presets */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 space-y-2">
<div className="text-xs font-bold tracking-widest text-cyan-700">PRESETS</div>
<div className="grid grid-cols-3 gap-1.5">
{PRESETS.map((p) => (
<button
key={p.label}
onClick={() => applyPreset(p.pan, p.tilt)}
className="py-1.5 text-xs font-bold rounded border border-cyan-950 bg-gray-900 text-gray-500 hover:bg-cyan-950 hover:text-cyan-300 hover:border-cyan-800 transition-colors"
>
<div>{p.label}</div>
<div className="text-gray-700 font-normal text-xs">{p.pan}°/{p.tilt}°</div>
</button>
))}
<button
onClick={() => applyPreset(0, 0)}
className="py-1.5 col-span-3 text-xs font-bold rounded border border-gray-800 bg-gray-900 text-gray-600 hover:bg-gray-800 hover:text-gray-300 transition-colors"
>
HOME (0° / 0°)
</button>
</div>
</div>
{/* Person tracking */}
<div className="bg-gray-950 rounded-lg border border-cyan-950 p-3 space-y-2">
<div className="text-xs font-bold tracking-widest text-cyan-700">PERSON TRACKING</div>
<div className="flex items-center justify-between">
<span className={`text-xs ${tracking ? 'text-cyan-400' : 'text-gray-600'}`}>
{tracking ? 'ON — following person' : 'OFF — manual control'}
</span>
<button
onClick={toggleTracking}
className={`relative w-10 h-5 rounded-full border transition-colors ${
tracking
? 'bg-cyan-950 border-cyan-700'
: 'bg-gray-900 border-gray-700'
}`}
>
<span className={`absolute top-0.5 w-4 h-4 rounded-full transition-transform ${
tracking
? 'translate-x-5 bg-cyan-400'
: 'translate-x-0.5 bg-gray-600'
}`} />
</button>
</div>
<div className="text-xs text-gray-700 font-mono">/gimbal/tracking_enabled</div>
</div>
{/* Topic reference */}
<div className="bg-gray-950 rounded border border-gray-800 p-2 text-xs text-gray-700 space-y-1">
<div className="font-bold text-gray-600 mb-1">ROS TOPICS</div>
<div><span className="text-gray-600">PUB</span> /gimbal/cmd (Vector3)</div>
<div><span className="text-gray-600">SUB</span> /gimbal/state (Vector3)</div>
<div><span className="text-gray-600">PUB</span> /gimbal/tracking_enabled (Bool)</div>
</div>
</div>
</div>
);
}