saltylab-firmware/ui/gimbal_panel.html
sl-webui cc3a65f4a4 feat: WebUI gimbal control panel (Issue #551)
Adds a full gimbal control panel with live camera preview:

Standalone page (ui/gimbal_panel.html + .js + .css):
- Self-contained HTML page, no build step, served directly
- roslib.js via CDN, connects to rosbridge WebSocket
- 2-D canvas pan/tilt pad: click-drag + touch pointer capture
- Live camera stream (front/rear/left/right selector, base64 CompressedImage)
- FPS badge + angle overlay on video feed
- Preset positions: CENTER / LEFT / RIGHT / UP / DOWN
- Home button (0° / 0°)
- Person-tracking toggle → /gimbal/tracking_enabled
- Current angle display from /gimbal/state feedback
- WS URL persisted in localStorage

React component (GimbalPanel.jsx) + App.jsx integration:
- Same features in dashboard — TELEOP group → Gimbal tab
- Shares rosbridge connection from parent
- Mobile-responsive: stacks vertically on mobile, side-by-side on lg+

ROS topics:
  PUB /gimbal/cmd              geometry_msgs/Vector3
  SUB /gimbal/state            geometry_msgs/Vector3
  PUB /gimbal/tracking_enabled std_msgs/Bool
  SUB /camera/*/image_raw/compressed sensor_msgs/CompressedImage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 10:29:29 -04:00

184 lines
6.4 KiB
HTML

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