feat: WebUI gimbal control panel (Issue #551) #557
210
ui/gimbal_panel.css
Normal file
210
ui/gimbal_panel.css
Normal 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
183
ui/gimbal_panel.html
Normal 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 | ↓ 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
392
ui/gimbal_panel.js
Normal 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();
|
||||
@ -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} />}
|
||||
|
||||
|
||||
382
ui/social-bot/src/components/GimbalPanel.jsx
Normal file
382
ui/social-bot/src/components/GimbalPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user