From cc3a65f4a41fb27afca69ed6bbd80d14915461c9 Mon Sep 17 00:00:00 2001 From: sl-webui Date: Sat, 14 Mar 2026 10:29:29 -0400 Subject: [PATCH] feat: WebUI gimbal control panel (Issue #551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a full gimbal control panel with live camera preview: Standalone page (ui/gimbal_panel.html + .js + .css): - Self-contained HTML page, no build step, served directly - roslib.js via CDN, connects to rosbridge WebSocket - 2-D canvas pan/tilt pad: click-drag + touch pointer capture - Live camera stream (front/rear/left/right selector, base64 CompressedImage) - FPS badge + angle overlay on video feed - Preset positions: CENTER / LEFT / RIGHT / UP / DOWN - Home button (0° / 0°) - Person-tracking toggle → /gimbal/tracking_enabled - Current angle display from /gimbal/state feedback - WS URL persisted in localStorage React component (GimbalPanel.jsx) + App.jsx integration: - Same features in dashboard — TELEOP group → Gimbal tab - Shares rosbridge connection from parent - Mobile-responsive: stacks vertically on mobile, side-by-side on lg+ ROS topics: PUB /gimbal/cmd geometry_msgs/Vector3 SUB /gimbal/state geometry_msgs/Vector3 PUB /gimbal/tracking_enabled std_msgs/Bool SUB /camera/*/image_raw/compressed sensor_msgs/CompressedImage Co-Authored-By: Claude Sonnet 4.6 --- ui/gimbal_panel.css | 210 ++++++++++ ui/gimbal_panel.html | 183 +++++++++ ui/gimbal_panel.js | 392 +++++++++++++++++++ ui/social-bot/src/App.jsx | 7 +- ui/social-bot/src/components/GimbalPanel.jsx | 382 ++++++++++++++++++ 5 files changed, 1173 insertions(+), 1 deletion(-) create mode 100644 ui/gimbal_panel.css create mode 100644 ui/gimbal_panel.html create mode 100644 ui/gimbal_panel.js create mode 100644 ui/social-bot/src/components/GimbalPanel.jsx diff --git a/ui/gimbal_panel.css b/ui/gimbal_panel.css new file mode 100644 index 0000000..ddea1d1 --- /dev/null +++ b/ui/gimbal_panel.css @@ -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; +} diff --git a/ui/gimbal_panel.html b/ui/gimbal_panel.html new file mode 100644 index 0000000..38e9d27 --- /dev/null +++ b/ui/gimbal_panel.html @@ -0,0 +1,183 @@ + + + + + +Saltybot — Gimbal Control + + + + + + + + + + + +
+ + +
+ + +
+ + + + + /camera/front/image_raw/compressed +
+ + +
+ gimbal camera feed +
+
📷
+
NO SIGNAL
+
+
— fps
+
PAN 0.0° TILT 0.0°
+
+ +
+ + + +
+ + + + + + + + diff --git a/ui/gimbal_panel.js b/ui/gimbal_panel.js new file mode 100644 index 0000000..473195a --- /dev/null +++ b/ui/gimbal_panel.js @@ -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(); diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx index e3eea94..0906b90 100644 --- a/ui/social-bot/src/App.jsx +++ b/ui/social-bot/src/App.jsx @@ -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 ── */}
{activeTab === 'teleop-webui' && } + {activeTab === 'gimbal' && } {activeTab === 'salty-face' && } diff --git a/ui/social-bot/src/components/GimbalPanel.jsx b/ui/social-bot/src/components/GimbalPanel.jsx new file mode 100644 index 0000000..aca94bb --- /dev/null +++ b/ui/social-bot/src/components/GimbalPanel.jsx @@ -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//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 ( + + ); +} + +// ── 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 ( +
+ {src ? ( + gimbal camera + ) : ( +
+
📷
+
NO SIGNAL
+
+ )} + {src && ( +
+ {fps} fps +
+ )} +
+ ); +} + +// ── 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 ( +
+ + {/* ── Left: Camera + pad ── */} +
+ + {/* Camera selector */} +
+ {CAMERAS.map((cam) => ( + + ))} +
+
+ {currentCam?.topic} +
+
+ + {/* Camera feed */} + +
+ + {/* ── Right: Controls ── */} +
+ + {/* Pan/Tilt pad */} +
+
PAN / TILT
+ +
click or drag to aim
+
+ + {/* Angle readout */} +
+
+
TARGET PAN
+
{targetPan.toFixed(1)}°
+
+
+
TARGET TILT
+
{targetTilt.toFixed(1)}°
+
+
+
ACTUAL PAN
+
{currentPan.toFixed(1)}°
+
+
+
ACTUAL TILT
+
{currentTilt.toFixed(1)}°
+
+
+ + {/* Presets */} +
+
PRESETS
+
+ {PRESETS.map((p) => ( + + ))} + +
+
+ + {/* Person tracking */} +
+
PERSON TRACKING
+
+ + {tracking ? 'ON — following person' : 'OFF — manual control'} + + +
+
/gimbal/tracking_enabled
+
+ + {/* Topic reference */} +
+
ROS TOPICS
+
PUB /gimbal/cmd (Vector3)
+
SUB /gimbal/state (Vector3)
+
PUB /gimbal/tracking_enabled (Bool)
+
+ +
+
+ ); +}