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