From cbcae34b796648ad67608b8bd970b9d805a72f9e Mon Sep 17 00:00:00 2001 From: sl-webui Date: Sat, 14 Mar 2026 14:58:41 -0400 Subject: [PATCH] feat: WebUI gamepad teleoperation panel (Issue #598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Standalone ui/gamepad_panel.{html,js,css} — no build step - Web Gamepad API integration: L-stick=linear, R-stick=angular - LT trigger scales speed down (fine control) - B/Circle button toggles E-stop; Start button resumes - Live raw axis bars and button state in sidebar - Virtual dual joystick (left=drive, right=steer) via Pointer Capture API - Deadzone ring drawn on canvas; configurable 0–40% - Touch and mouse support - WASD/Arrow keyboard input (W/S=forward/reverse, A/D=turn, Space=E-stop) - Speed limiter sliders: linear (0–1.0 m/s), angular (0–2.0 rad/s) - Configurable deadzone slider (0–40%) - E-stop: latches zero-velocity command, blinking overlay, resume button - Publishes geometry_msgs/Twist to /cmd_vel at 20 Hz via rosbridge WebSocket - Input priority: gamepad > keyboard > virtual sticks - Live command display (m/s, rad/s) with color feedback - Pub rate display (Hz) in sidebar - localStorage WS URL persistence, auto-reconnect on load - Mobile-responsive: sidebar hidden ≤800px, right stick hidden ≤560px Co-Authored-By: Claude Sonnet 4.6 --- ui/gamepad_panel.css | 314 ++++++++++++++++++++++++ ui/gamepad_panel.html | 197 +++++++++++++++ ui/gamepad_panel.js | 548 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1059 insertions(+) create mode 100644 ui/gamepad_panel.css create mode 100644 ui/gamepad_panel.html create mode 100644 ui/gamepad_panel.js diff --git a/ui/gamepad_panel.css b/ui/gamepad_panel.css new file mode 100644 index 0000000..76cf204 --- /dev/null +++ b/ui/gamepad_panel.css @@ -0,0 +1,314 @@ +/* gamepad_panel.css — Saltybot Gamepad Teleop (Issue #598) */ + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg0: #050510; + --bg1: #070712; + --bg2: #0a0a1a; + --bd: #0c2a3a; + --bd2: #1e3a5f; + --dim: #374151; + --mid: #6b7280; + --base: #9ca3af; + --hi: #d1d5db; + --cyan: #06b6d4; + --green: #22c55e; + --amber: #f59e0b; + --red: #ef4444; +} + +body { + font-family: 'Courier New', Courier, monospace; + font-size: 12px; + background: var(--bg0); + color: var(--base); + height: 100dvh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ── Header ── */ +#header { + display: flex; + align-items: center; + padding: 5px 12px; + background: var(--bg1); + border-bottom: 1px solid var(--bd); + flex-shrink: 0; + gap: 8px; + flex-wrap: wrap; +} +.logo { color: #f97316; font-weight: bold; letter-spacing: .15em; font-size: 13px; flex-shrink: 0; } + +#conn-dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--dim); flex-shrink: 0; transition: background .3s; +} +#conn-dot.connected { background: var(--green); } +#conn-dot.error { background: var(--red); animation: blink 1s infinite; } + +#gp-indicator { + display: flex; align-items: center; gap: 5px; + font-size: 10px; color: var(--mid); +} +#gp-dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--dim); flex-shrink: 0; transition: background .3s; +} +#gp-dot.active { background: var(--amber); } + +@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} } + +#ws-input { + background: var(--bg2); border: 1px solid var(--bd2); border-radius: 4px; + color: #67e8f9; padding: 2px 7px; font-family: monospace; font-size: 11px; width: 185px; +} +#ws-input:focus { outline: none; border-color: var(--cyan); } + +.hbtn { + padding: 2px 8px; border-radius: 4px; border: 1px solid var(--bd2); + background: var(--bg2); color: #67e8f9; font-family: monospace; + font-size: 10px; font-weight: bold; cursor: pointer; transition: background .15s; + white-space: nowrap; +} +.hbtn:hover { background: #0e4f69; } + +.estop-btn { + border-color: #7f1d1d; background: #1c0505; color: #fca5a5; + padding: 3px 12px; font-size: 11px; +} +.estop-btn:hover { background: #3b0606; } +.estop-btn.active { + background: #7f1d1d; border-color: var(--red); color: #fff; + animation: blink .8s infinite; +} + +.resume-btn { + border-color: #14532d; background: #052010; color: #86efac; + padding: 3px 12px; font-size: 11px; +} +.resume-btn:hover { background: #0a3a1a; } + +/* ── Toolbar ── */ +#toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 12px; + background: var(--bg1); + border-bottom: 1px solid var(--bd); + flex-shrink: 0; + flex-wrap: wrap; + font-size: 10px; +} +.tsep { width: 1px; height: 16px; background: var(--bd2); } +.tlbl { color: var(--mid); letter-spacing: .08em; } +.tval { color: #67e8f9; min-width: 70px; font-family: monospace; } + +.tslider { + -webkit-appearance: none; + width: 100px; height: 4px; border-radius: 2px; + background: var(--bd2); outline: none; cursor: pointer; +} +.tslider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; height: 12px; border-radius: 50%; + background: var(--cyan); cursor: pointer; +} +.tslider::-moz-range-thumb { + width: 12px; height: 12px; border-radius: 50%; + background: var(--cyan); cursor: pointer; border: none; +} + +/* ── Main ── */ +#main { + flex: 1; + display: grid; + grid-template-columns: 1fr 220px; + min-height: 0; + overflow: hidden; +} + +/* ── Joystick area ── */ +#joystick-area { + display: flex; + align-items: center; + justify-content: space-evenly; + gap: 12px; + padding: 16px; + overflow: hidden; +} + +.stick-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.stick-label { + font-size: 9px; letter-spacing: .12em; color: var(--mid); text-transform: uppercase; +} +.stick-vals { + font-size: 10px; color: #67e8f9; font-family: monospace; min-width: 100px; text-align: center; +} + +canvas { + display: block; + border-radius: 50%; + border: 1px solid var(--bd2); + touch-action: none; + cursor: grab; +} +canvas.active { cursor: grabbing; } + +/* ── Center panel ── */ +#center-panel { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + max-width: 260px; + position: relative; +} + +.cp-block { + background: var(--bg2); + border: 1px solid var(--bd2); + border-radius: 6px; + padding: 8px; +} +.cp-title { + font-size: 9px; font-weight: bold; letter-spacing: .12em; + color: #0891b2; text-transform: uppercase; margin-bottom: 5px; +} +.cp-row { + display: flex; justify-content: space-between; + padding: 2px 0; border-bottom: 1px solid var(--bd); + font-size: 10px; +} +.cp-row:last-child { border-bottom: none; } +.cp-lbl { color: var(--mid); } +.cp-val { color: var(--hi); font-family: monospace; } + +/* Keyboard diagram */ +.key-grid { + display: grid; + grid-template-columns: repeat(3, 28px); + grid-template-rows: repeat(3, 22px); + gap: 3px; + justify-content: center; + margin: 4px 0; +} +.key { + background: var(--bg1); + border: 1px solid var(--bd2); + border-radius: 3px; + display: flex; align-items: center; justify-content: center; + font-size: 9px; font-weight: bold; color: var(--mid); + transition: background .1s, color .1s, border-color .1s; +} +.key.pressed { + background: #0e4f69; border-color: var(--cyan); color: #fff; +} +.key-wide { + grid-column: 1 / 4; + width: 100%; height: 22px; +} + +/* E-stop overlay */ +#estop-panel { + position: absolute; inset: 0; + background: rgba(127,0,0,0.9); + border: 2px solid var(--red); + border-radius: 8px; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + gap: 4px; + animation: blink .8s infinite; +} +.estop-text { font-size: 28px; font-weight: bold; color: #fca5a5; letter-spacing: .1em; } +.estop-sub { font-size: 11px; color: #fca5a5; letter-spacing: .2em; } + +/* ── Sidebar ── */ +#sidebar { + background: var(--bg1); + border-left: 1px solid var(--bd); + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 8px; + gap: 8px; + font-size: 11px; +} +.sb-card { + background: var(--bg2); + border: 1px solid var(--bd2); + border-radius: 6px; + padding: 8px; +} +.sb-title { + font-size: 9px; font-weight: bold; letter-spacing: .12em; + color: #0891b2; text-transform: uppercase; margin-bottom: 6px; +} +.sb-row { + display: flex; justify-content: space-between; + padding: 2px 0; border-bottom: 1px solid var(--bd); + font-size: 10px; +} +.sb-row:last-child { border-bottom: none; } +.sb-lbl { color: var(--mid); } +.sb-val { color: var(--hi); font-family: monospace; } + +/* Gamepad axis bars */ +.gp-axis-row { + display: flex; align-items: center; gap: 4px; + font-size: 9px; color: var(--mid); margin-bottom: 3px; +} +.gp-axis-label { width: 30px; flex-shrink: 0; } +.gp-bar-track { + flex: 1; height: 6px; background: var(--bg0); + border: 1px solid var(--bd); border-radius: 3px; overflow: hidden; + position: relative; +} +.gp-bar-center { + position: absolute; top: 0; bottom: 0; + left: 50%; width: 1px; background: var(--dim); +} +.gp-bar-fill { + position: absolute; top: 0; bottom: 0; + background: var(--cyan); transition: none; +} +.gp-axis-val { width: 32px; text-align: right; color: var(--hi); flex-shrink: 0; } + +.gp-btn-row { + display: flex; flex-wrap: wrap; gap: 3px; + margin-top: 6px; +} +.gp-btn-chip { + font-size: 8px; padding: 1px 5px; border-radius: 2px; + border: 1px solid var(--bd); background: var(--bg0); color: var(--dim); + transition: background .1s, color .1s; +} +.gp-btn-chip.pressed { background: var(--bd2); color: var(--hi); border-color: var(--cyan); } + +/* ── Footer ── */ +#footer { + background: var(--bg1); border-top: 1px solid var(--bd); + padding: 3px 12px; + display: flex; align-items: center; justify-content: space-between; + flex-shrink: 0; font-size: 10px; color: var(--dim); +} + +/* ── Responsive ── */ +@media (max-width: 800px) { + #main { grid-template-columns: 1fr; } + #sidebar { display: none; } +} +@media (max-width: 560px) { + #right-wrap { display: none; } + #center-panel { max-width: 100%; } + .tslider { width: 70px; } +} diff --git a/ui/gamepad_panel.html b/ui/gamepad_panel.html new file mode 100644 index 0000000..b922fbc --- /dev/null +++ b/ui/gamepad_panel.html @@ -0,0 +1,197 @@ + + + + + +Saltybot — Gamepad Teleop + + + + + + + + + +
+ + LINEAR + + 0.50 m/s + +
+ + ANGULAR + + 1.00 rad/s + +
+ + DEADZONE + + 10% + +
+ + + +
+ + +
+ + +
+
+
LEFT — DRIVE
+ +
↕ 0.00 m/s
+
+ + +
+
+
COMMAND
+
+ Linear + 0.00 m/s +
+
+ Angular + 0.00 rad/s +
+
+ +
+
LIMITS
+
+ Max lin + 0.50 m/s +
+
+ Max ang + 1.00 rad/s +
+
+ Deadzone + 10% +
+
+ +
+
KEYBOARD
+
+
+
W
+
+
A
+
S
+
D
+
+
SPC
+
+
+
+ W/S=fwd/rev · A/D=turn · SPC=stop +
+
+ + +
+
GAMEPAD MAPPING
+
+
L-stick ↕ → linear vel
+
R-stick ↔ → angular vel
+
LT/RT → fine speed ctrl
+
B/Circle → E-stop toggle
+
Start → Resume
+
+
+ + +
+ +
+
RIGHT — STEER
+ +
↔ 0.00 rad/s
+
+
+ + + +
+ + + + + + + + diff --git a/ui/gamepad_panel.js b/ui/gamepad_panel.js new file mode 100644 index 0000000..8ba10d6 --- /dev/null +++ b/ui/gamepad_panel.js @@ -0,0 +1,548 @@ +/* gamepad_panel.js — Saltybot Gamepad Teleop (Issue #598) */ +'use strict'; + +// ── Constants ────────────────────────────────────────────────────────────── +const MAX_LINEAR = 1.0; // absolute max m/s +const MAX_ANGULAR = 2.0; // absolute max rad/s +const PUBLISH_HZ = 20; +const PUBLISH_MS = 1000 / PUBLISH_HZ; + +// ── State ────────────────────────────────────────────────────────────────── +const state = { + connected: false, + estop: false, + // Speed limits (0..1 fraction of max) + linLimit: 0.5, + angLimit: 0.5, + deadzone: 0.10, + // Current command + linVel: 0, + angVel: 0, + // Input source: 'none' | 'keyboard' | 'virtual' | 'gamepad' + inputSrc: 'none', + // Keyboard keys held + keys: { w: false, a: false, s: false, d: false, space: false }, + // Virtual stick drags + sticks: { + left: { active: false, dx: 0, dy: 0 }, + right: { active: false, dx: 0, dy: 0 }, + }, + // Gamepad + gpIndex: null, + gpPrevBtns: [], + // Stats + pubCount: 0, + pubTs: 0, + pubRate: 0, +}; + +// ── ROS ──────────────────────────────────────────────────────────────────── +let ros = null; +let cmdVelTopic = null; + +function rosCmdVelSetup() { + cmdVelTopic = new ROSLIB.Topic({ + ros, + name: '/cmd_vel', + messageType: 'geometry_msgs/Twist', + }); +} + +function publishTwist(lin, ang) { + if (!ros || !cmdVelTopic || !state.connected) return; + cmdVelTopic.publish(new ROSLIB.Message({ + linear: { x: lin, y: 0, z: 0 }, + angular: { x: 0, y: 0, z: ang }, + })); + state.pubCount++; + const now = performance.now(); + if (state.pubTs) { + const dt = (now - state.pubTs) / 1000; + state.pubRate = 1 / dt; + } + state.pubTs = now; +} + +function sendStop() { publishTwist(0, 0); } + +// ── Connection ───────────────────────────────────────────────────────────── +const $connDot = document.getElementById('conn-dot'); +const $connLabel = document.getElementById('conn-label'); +const $btnConn = document.getElementById('btn-connect'); +const $wsInput = document.getElementById('ws-input'); + +function connect() { + const url = $wsInput.value.trim() || 'ws://localhost:9090'; + if (ros) { try { ros.close(); } catch(_){} } + + $connLabel.textContent = 'Connecting…'; + $connLabel.style.color = '#d97706'; + $connDot.className = ''; + + ros = new ROSLIB.Ros({ url }); + + ros.on('connection', () => { + state.connected = true; + $connDot.className = 'connected'; + $connLabel.style.color = '#22c55e'; + $connLabel.textContent = 'Connected'; + rosCmdVelSetup(); + localStorage.setItem('gp_ws_url', url); + }); + + ros.on('error', () => { + state.connected = false; + $connDot.className = 'error'; + $connLabel.style.color = '#ef4444'; + $connLabel.textContent = 'Error'; + }); + + ros.on('close', () => { + state.connected = false; + $connDot.className = ''; + $connLabel.style.color = '#6b7280'; + $connLabel.textContent = 'Disconnected'; + }); +} + +$btnConn.addEventListener('click', connect); + +// Restore saved URL +const savedUrl = localStorage.getItem('gp_ws_url'); +if (savedUrl) { + $wsInput.value = savedUrl; + document.getElementById('footer-ws').textContent = savedUrl; +} + +// ── E-stop ───────────────────────────────────────────────────────────────── +const $btnEstop = document.getElementById('btn-estop'); +const $btnResume = document.getElementById('btn-resume'); +const $estopPanel = document.getElementById('estop-panel'); +const $sbEstop = document.getElementById('sb-estop'); + +function activateEstop() { + state.estop = true; + sendStop(); + $btnEstop.style.display = 'none'; + $btnResume.style.display = ''; + $btnEstop.classList.add('active'); + $estopPanel.style.display = 'flex'; + $sbEstop.textContent = 'ACTIVE'; + $sbEstop.style.color = '#ef4444'; +} + +function resumeFromEstop() { + state.estop = false; + $btnEstop.style.display = ''; + $btnResume.style.display = 'none'; + $btnEstop.classList.remove('active'); + $estopPanel.style.display = 'none'; + $sbEstop.textContent = 'OFF'; + $sbEstop.style.color = '#6b7280'; +} + +$btnEstop.addEventListener('click', activateEstop); +$btnResume.addEventListener('click', resumeFromEstop); + +// ── Sliders ──────────────────────────────────────────────────────────────── +function setupSlider(id, valId, minVal, maxVal, onUpdate) { + const slider = document.getElementById(id); + const valEl = document.getElementById(valId); + slider.addEventListener('input', () => { + const frac = parseInt(slider.value) / 100; + onUpdate(frac, valEl); + }); + // Init + const frac = parseInt(slider.value) / 100; + onUpdate(frac, valEl); +} + +setupSlider('slider-linear', 'val-linear', 0, MAX_LINEAR, (frac, el) => { + state.linLimit = frac; + const ms = (frac * MAX_LINEAR).toFixed(2); + el.textContent = ms + ' m/s'; + document.getElementById('lim-linear').textContent = ms + ' m/s'; +}); + +setupSlider('slider-angular', 'val-angular', 0, MAX_ANGULAR, (frac, el) => { + state.angLimit = frac; + const rs = (frac * MAX_ANGULAR).toFixed(2); + el.textContent = rs + ' rad/s'; + document.getElementById('lim-angular').textContent = rs + ' rad/s'; +}); + +// Deadzone slider (0–40%) +const $dzSlider = document.getElementById('slider-dz'); +const $dzVal = document.getElementById('val-dz'); +const $limDz = document.getElementById('lim-dz'); +$dzSlider.addEventListener('input', () => { + state.deadzone = parseInt($dzSlider.value) / 100; + $dzVal.textContent = parseInt($dzSlider.value) + '%'; + $limDz.textContent = parseInt($dzSlider.value) + '%'; +}); + +// ── Keyboard input ───────────────────────────────────────────────────────── +const KEY_EL = { + w: document.getElementById('key-w'), + a: document.getElementById('key-a'), + s: document.getElementById('key-s'), + d: document.getElementById('key-d'), + space: document.getElementById('key-space'), +}; + +function setKeyState(key, down) { + if (!(key in state.keys)) return; + state.keys[key] = down; + if (KEY_EL[key]) KEY_EL[key].classList.toggle('pressed', down); +} + +document.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT') return; + switch (e.code) { + case 'KeyW': case 'ArrowUp': setKeyState('w', true); break; + case 'KeyS': case 'ArrowDown': setKeyState('s', true); break; + case 'KeyA': case 'ArrowLeft': setKeyState('a', true); break; + case 'KeyD': case 'ArrowRight': setKeyState('d', true); break; + case 'Space': + e.preventDefault(); + if (state.estop) { resumeFromEstop(); } else { activateEstop(); } + break; + } +}); + +document.addEventListener('keyup', (e) => { + switch (e.code) { + case 'KeyW': case 'ArrowUp': setKeyState('w', false); break; + case 'KeyS': case 'ArrowDown': setKeyState('s', false); break; + case 'KeyA': case 'ArrowLeft': setKeyState('a', false); break; + case 'KeyD': case 'ArrowRight': setKeyState('d', false); break; + } +}); + +function getKeyboardCommand() { + let lin = 0, ang = 0; + if (state.keys.w) lin += 1; + if (state.keys.s) lin -= 1; + if (state.keys.a) ang += 1; + if (state.keys.d) ang -= 1; + return { lin, ang }; +} + +// ── Virtual Joysticks ────────────────────────────────────────────────────── +function setupVirtualStick(canvasId, stickKey, onValue) { + const canvas = document.getElementById(canvasId); + const R = canvas.width / 2; + const ctx = canvas.getContext('2d'); + const stick = state.sticks[stickKey]; + let pointerId = null; + + function draw() { + const W = canvas.width, H = canvas.height; + ctx.clearRect(0, 0, W, H); + + // Base circle + ctx.beginPath(); + ctx.arc(R, R, R - 2, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(10,10,26,0.9)'; + ctx.fill(); + ctx.strokeStyle = '#1e3a5f'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Crosshair + ctx.strokeStyle = '#374151'; + ctx.lineWidth = 0.5; + ctx.beginPath(); ctx.moveTo(R, 4); ctx.lineTo(R, H - 4); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(4, R); ctx.lineTo(W - 4, R); ctx.stroke(); + + // Deadzone ring + const dzR = state.deadzone * (R - 10); + ctx.beginPath(); + ctx.arc(R, R, dzR, 0, Math.PI * 2); + ctx.strokeStyle = '#374151'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.stroke(); + ctx.setLineDash([]); + + // Knob + const kx = R + stick.dx * (R - 16); + const ky = R + stick.dy * (R - 16); + const kColor = stick.active ? '#06b6d4' : '#1e3a5f'; + ctx.beginPath(); + ctx.arc(kx, ky, 16, 0, Math.PI * 2); + ctx.fillStyle = kColor + '44'; + ctx.fill(); + ctx.strokeStyle = kColor; + ctx.lineWidth = 2; + ctx.stroke(); + } + + function getOffset(e) { + const rect = canvas.getBoundingClientRect(); + return { + x: (e.clientX - rect.left) / rect.width * canvas.width, + y: (e.clientY - rect.top) / rect.height * canvas.height, + }; + } + + function pointerdown(e) { + canvas.setPointerCapture(e.pointerId); + pointerId = e.pointerId; + stick.active = true; + canvas.classList.add('active'); + update(e); + } + + function pointermove(e) { + if (!stick.active || e.pointerId !== pointerId) return; + update(e); + } + + function pointerup(e) { + if (e.pointerId !== pointerId) return; + stick.active = false; + stick.dx = 0; + stick.dy = 0; + pointerId = null; + canvas.classList.remove('active'); + onValue(0, 0); + draw(); + } + + function update(e) { + const { x, y } = getOffset(e); + let dx = (x - R) / (R - 16); + let dy = (y - R) / (R - 16); + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 1) { dx /= dist; dy /= dist; } + stick.dx = dx; + stick.dy = dy; + // Apply deadzone + const norm = Math.sqrt(dx * dx + dy * dy); + if (norm < state.deadzone) { onValue(0, 0); } + else { + const scale = (norm - state.deadzone) / (1 - state.deadzone); + onValue((-dy / norm) * scale, (-dx / norm) * scale); // up=+lin, left=+ang + } + draw(); + } + + canvas.addEventListener('pointerdown', pointerdown); + canvas.addEventListener('pointermove', pointermove); + canvas.addEventListener('pointerup', pointerup); + canvas.addEventListener('pointercancel', pointerup); + + draw(); + return { draw }; +} + +let virtLeftLin = 0, virtLeftAng = 0; +let virtRightLin = 0, virtRightAng = 0; + +const leftStickDraw = setupVirtualStick('left-stick', 'left', (fwd, _) => { virtLeftLin = fwd; updateSidebarInput(); }).draw; +const rightStickDraw = setupVirtualStick('right-stick', 'right', (_, turn) => { virtRightAng = turn; updateSidebarInput(); }).draw; + +// Redraw sticks when deadzone changes (for dz ring) +$dzSlider.addEventListener('input', () => { + if (leftStickDraw) leftStickDraw(); + if (rightStickDraw) rightStickDraw(); +}); + +// ── Gamepad API ──────────────────────────────────────────────────────────── +const $gpDot = document.getElementById('gp-dot'); +const $gpLabel = document.getElementById('gp-label'); +const $gpRaw = document.getElementById('gp-raw'); + +window.addEventListener('gamepadconnected', (e) => { + state.gpIndex = e.gamepad.index; + $gpDot.classList.add('active'); + $gpLabel.textContent = e.gamepad.id.substring(0, 28); + updateGpRaw(); +}); + +window.addEventListener('gamepaddisconnected', (e) => { + if (e.gamepad.index === state.gpIndex) { + state.gpIndex = null; + state.gpPrevBtns = []; + $gpDot.classList.remove('active'); + $gpLabel.textContent = 'No gamepad'; + $gpRaw.innerHTML = '
No gamepad connected.
Press any button to activate.
'; + } +}); + +function applyDeadzone(v, dz) { + const sign = v >= 0 ? 1 : -1; + const abs = Math.abs(v); + if (abs < dz) return 0; + return sign * (abs - dz) / (1 - dz); +} + +function getGamepadCommand() { + if (state.gpIndex === null) return null; + const gp = navigator.getGamepads()[state.gpIndex]; + if (!gp) return null; + + // Standard mapping: + // Axes: 0=LX, 1=LY, 2=RX, 3=RY + // Buttons: 0=A/X, 1=B/Circle, 6=LT, 7=RT, 9=Start + const dz = state.deadzone; + const ly = applyDeadzone(gp.axes[1] || 0, dz); + const rx = applyDeadzone(gp.axes[2] || 0, dz); + + // LT/RT fine speed override (reduce max by up to 50%) + const lt = gp.buttons[6] ? (gp.buttons[6].value || 0) : 0; + const rt = gp.buttons[7] ? (gp.buttons[7].value || 0) : 0; + const triggerScale = 1 - (lt * 0.5) - (rt * 0.5) + (rt * 0.5); // RT = full, LT = half + const speedScale = lt > 0.1 ? (1 - lt * 0.5) : 1.0; + + // B/Circle = e-stop + const bBtn = gp.buttons[1] && gp.buttons[1].pressed; + const prevBBtn = state.gpPrevBtns[1] || false; + if (bBtn && !prevBBtn) { + if (!state.estop) activateEstop(); else resumeFromEstop(); + } + + // Start = resume + const startBtn = gp.buttons[9] && gp.buttons[9].pressed; + const prevStart = state.gpPrevBtns[9] || false; + if (startBtn && !prevStart && state.estop) resumeFromEstop(); + + state.gpPrevBtns = gp.buttons.map(b => b.pressed); + + return { lin: -ly * speedScale, ang: -rx }; +} + +function updateGpRaw() { + if (state.gpIndex === null) return; + const gp = navigator.getGamepads()[state.gpIndex]; + if (!gp) return; + + const AXIS_NAMES = ['LX', 'LY', 'RX', 'RY']; + const BTN_NAMES = ['A','B','X','Y','LB','RB','LT','RT','Back','Start', + 'LS','RS','↑','↓','←','→','Home']; + + let html = ''; + // Axes + for (let i = 0; i < Math.min(gp.axes.length, 4); i++) { + const v = gp.axes[i]; + const pct = ((v + 1) / 2 * 100).toFixed(0); + const fill = v >= 0 + ? `left:50%;width:${(v/2*100).toFixed(0)}%` + : `left:${((v+1)/2*100).toFixed(0)}%;width:${(Math.abs(v)/2*100).toFixed(0)}%`; + html += `
+ ${AXIS_NAMES[i]||'A'+i} +
+
+
+
+ ${v.toFixed(2)} +
`; + } + // Buttons + html += '
'; + for (let i = 0; i < gp.buttons.length; i++) { + const pressed = gp.buttons[i].pressed; + const name = BTN_NAMES[i] || 'B'+i; + html += `${name}`; + } + html += '
'; + $gpRaw.innerHTML = html; +} + +// ── Velocity display helpers ─────────────────────────────────────────────── +const $dispLinear = document.getElementById('disp-linear'); +const $dispAngular = document.getElementById('disp-angular'); +const $leftVals = document.getElementById('left-vals'); +const $rightVals = document.getElementById('right-vals'); +const $sbInput = document.getElementById('sb-input'); +const $sbRate = document.getElementById('sb-rate'); + +function updateSidebarInput() { + const src = state.inputSrc; + $sbInput.textContent = src === 'gamepad' ? 'Gamepad' : + src === 'keyboard' ? 'Keyboard' : + src === 'virtual' ? 'Virtual sticks' : '—'; +} + +// ── Main publish loop ────────────────────────────────────────────────────── +let lastPublish = 0; + +function loop(ts) { + requestAnimationFrame(loop); + + // Gamepad raw display (60fps) + if (state.gpIndex !== null) updateGpRaw(); + + // Publish at limited rate + if (ts - lastPublish < PUBLISH_MS) return; + lastPublish = ts; + + // Determine command from highest-priority active input + let lin = 0, ang = 0; + let src = 'none'; + + // 1. Gamepad (highest priority if connected) + const gpCmd = getGamepadCommand(); + if (gpCmd && (Math.abs(gpCmd.lin) > 0.001 || Math.abs(gpCmd.ang) > 0.001)) { + lin = gpCmd.lin; + ang = gpCmd.ang; + src = 'gamepad'; + } + // 2. Keyboard + else { + const kbCmd = getKeyboardCommand(); + if (kbCmd.lin !== 0 || kbCmd.ang !== 0) { + lin = kbCmd.lin; + ang = kbCmd.ang; + src = 'keyboard'; + } + // 3. Virtual sticks + else if (state.sticks.left.active || state.sticks.right.active) { + lin = virtLeftLin; + ang = virtRightAng; + src = 'virtual'; + } + } + + // Apply speed limits + lin = lin * state.linLimit * MAX_LINEAR; + ang = ang * state.angLimit * MAX_ANGULAR; + + // Clamp + lin = Math.max(-MAX_LINEAR, Math.min(MAX_LINEAR, lin)); + ang = Math.max(-MAX_ANGULAR, Math.min(MAX_ANGULAR, ang)); + + state.linVel = lin; + state.angVel = ang; + state.inputSrc = src; + + if (state.estop) { lin = 0; ang = 0; } + + // Publish + publishTwist(lin, ang); + + // Update displays + $dispLinear.textContent = lin.toFixed(2) + ' m/s'; + $dispAngular.textContent = ang.toFixed(2) + ' rad/s'; + $leftVals.textContent = '↕ ' + lin.toFixed(2) + ' m/s'; + $rightVals.textContent = '↔ ' + ang.toFixed(2) + ' rad/s'; + + updateSidebarInput(); + $sbRate.textContent = state.pubRate > 0 ? state.pubRate.toFixed(1) + ' Hz' : '—'; + + // Color command display + const linColor = Math.abs(lin) > 0.01 ? '#22c55e' : '#6b7280'; + const angColor = Math.abs(ang) > 0.01 ? '#06b6d4' : '#6b7280'; + $dispLinear.style.color = linColor; + $dispAngular.style.color = angColor; + $leftVals.style.color = linColor; + $rightVals.style.color = angColor; +} + +requestAnimationFrame(loop); + +// ── Init auto-connect if URL saved ──────────────────────────────────────── +if (savedUrl) { + connect(); +}