- 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 <noreply@anthropic.com>
549 lines
18 KiB
JavaScript
549 lines
18 KiB
JavaScript
/* 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 = '<div style="color:#374151;font-size:10px;text-align:center;padding:12px 0">No gamepad connected.<br>Press any button to activate.</div>';
|
||
}
|
||
});
|
||
|
||
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 += `<div class="gp-axis-row">
|
||
<span class="gp-axis-label">${AXIS_NAMES[i]||'A'+i}</span>
|
||
<div class="gp-bar-track">
|
||
<div class="gp-bar-center"></div>
|
||
<div class="gp-bar-fill" style="${fill}"></div>
|
||
</div>
|
||
<span class="gp-axis-val">${v.toFixed(2)}</span>
|
||
</div>`;
|
||
}
|
||
// Buttons
|
||
html += '<div class="gp-btn-row">';
|
||
for (let i = 0; i < gp.buttons.length; i++) {
|
||
const pressed = gp.buttons[i].pressed;
|
||
const name = BTN_NAMES[i] || 'B'+i;
|
||
html += `<span class="gp-btn-chip${pressed?' pressed':''}">${name}</span>`;
|
||
}
|
||
html += '</div>';
|
||
$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();
|
||
}
|