Spaces:
Running
Running
Replace joystick with RPY sliders synced to robot state
Browse files- Roll, Pitch, Yaw sliders for absolute head control
- Sliders update live from robot state when not being controlled
- Removed joystick and all related code/CSS
- Cleaner, simpler interface
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- index.html +74 -217
index.html
CHANGED
|
@@ -267,97 +267,6 @@
|
|
| 267 |
padding: 12px;
|
| 268 |
}
|
| 269 |
|
| 270 |
-
/* Joystick - Full Width Responsive */
|
| 271 |
-
.joystick-wrapper {
|
| 272 |
-
display: flex;
|
| 273 |
-
align-items: center;
|
| 274 |
-
justify-content: center;
|
| 275 |
-
gap: 20px;
|
| 276 |
-
padding: 10px;
|
| 277 |
-
}
|
| 278 |
-
|
| 279 |
-
.joystick-area {
|
| 280 |
-
width: min(250px, 40vw);
|
| 281 |
-
height: min(250px, 40vw);
|
| 282 |
-
min-width: 180px;
|
| 283 |
-
min-height: 180px;
|
| 284 |
-
background: radial-gradient(circle at center, var(--pollen-card-light) 0%, var(--pollen-darker) 100%);
|
| 285 |
-
border-radius: 50%;
|
| 286 |
-
position: relative;
|
| 287 |
-
border: 3px solid var(--pollen-coral);
|
| 288 |
-
touch-action: none;
|
| 289 |
-
cursor: grab;
|
| 290 |
-
flex-shrink: 0;
|
| 291 |
-
}
|
| 292 |
-
|
| 293 |
-
.joystick-knob {
|
| 294 |
-
width: 25%;
|
| 295 |
-
height: 25%;
|
| 296 |
-
background: var(--pollen-coral);
|
| 297 |
-
border-radius: 50%;
|
| 298 |
-
position: absolute;
|
| 299 |
-
top: 50%;
|
| 300 |
-
left: 50%;
|
| 301 |
-
transform: translate(-50%, -50%);
|
| 302 |
-
box-shadow: 0 4px 16px rgba(255,107,53,0.5);
|
| 303 |
-
pointer-events: none;
|
| 304 |
-
}
|
| 305 |
-
|
| 306 |
-
.joystick-label {
|
| 307 |
-
position: absolute;
|
| 308 |
-
font-size: 0.7em;
|
| 309 |
-
color: var(--text-muted);
|
| 310 |
-
font-weight: 500;
|
| 311 |
-
}
|
| 312 |
-
|
| 313 |
-
.joystick-label.top { top: 8%; left: 50%; transform: translateX(-50%); }
|
| 314 |
-
.joystick-label.bottom { bottom: 8%; left: 50%; transform: translateX(-50%); }
|
| 315 |
-
.joystick-label.left { left: 5%; top: 50%; transform: translateY(-50%); }
|
| 316 |
-
.joystick-label.right { right: 5%; top: 50%; transform: translateY(-50%); }
|
| 317 |
-
|
| 318 |
-
.roll-control {
|
| 319 |
-
display: flex;
|
| 320 |
-
flex-direction: column;
|
| 321 |
-
align-items: center;
|
| 322 |
-
gap: 8px;
|
| 323 |
-
flex-shrink: 0;
|
| 324 |
-
}
|
| 325 |
-
|
| 326 |
-
.roll-slider {
|
| 327 |
-
writing-mode: vertical-lr;
|
| 328 |
-
direction: rtl;
|
| 329 |
-
height: min(220px, 38vw);
|
| 330 |
-
min-height: 160px;
|
| 331 |
-
width: 16px;
|
| 332 |
-
-webkit-appearance: none;
|
| 333 |
-
background: var(--pollen-darker);
|
| 334 |
-
border-radius: 8px;
|
| 335 |
-
border: 2px solid var(--pollen-card-light);
|
| 336 |
-
}
|
| 337 |
-
|
| 338 |
-
.roll-slider::-webkit-slider-thumb {
|
| 339 |
-
-webkit-appearance: none;
|
| 340 |
-
width: 36px;
|
| 341 |
-
height: 36px;
|
| 342 |
-
background: var(--pollen-coral);
|
| 343 |
-
border-radius: 50%;
|
| 344 |
-
cursor: pointer;
|
| 345 |
-
box-shadow: 0 2px 10px rgba(255,107,53,0.5);
|
| 346 |
-
}
|
| 347 |
-
|
| 348 |
-
.roll-label {
|
| 349 |
-
font-size: 0.75em;
|
| 350 |
-
color: var(--text-muted);
|
| 351 |
-
font-weight: 500;
|
| 352 |
-
}
|
| 353 |
-
|
| 354 |
-
.joystick-hint {
|
| 355 |
-
text-align: center;
|
| 356 |
-
font-size: 0.75em;
|
| 357 |
-
color: var(--text-muted);
|
| 358 |
-
padding-top: 8px;
|
| 359 |
-
}
|
| 360 |
-
|
| 361 |
/* Sliders */
|
| 362 |
.slider-row {
|
| 363 |
display: flex;
|
|
@@ -578,15 +487,6 @@
|
|
| 578 |
grid-column: 2;
|
| 579 |
grid-row: 4;
|
| 580 |
}
|
| 581 |
-
|
| 582 |
-
.joystick-area {
|
| 583 |
-
width: 220px;
|
| 584 |
-
height: 220px;
|
| 585 |
-
}
|
| 586 |
-
|
| 587 |
-
.roll-slider {
|
| 588 |
-
height: 200px;
|
| 589 |
-
}
|
| 590 |
}
|
| 591 |
|
| 592 |
/* Login View */
|
|
@@ -716,20 +616,25 @@
|
|
| 716 |
</div>
|
| 717 |
</div>
|
| 718 |
|
| 719 |
-
<!--
|
| 720 |
<div class="panel">
|
| 721 |
-
<div class="panel-header">Head
|
| 722 |
<div class="panel-content">
|
| 723 |
-
<div class="
|
| 724 |
-
<
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 731 |
</div>
|
| 732 |
-
<div class="joystick-hint">Hold and drag to move head continuously</div>
|
| 733 |
</div>
|
| 734 |
</div>
|
| 735 |
|
|
@@ -804,14 +709,8 @@
|
|
| 804 |
let sseAbortController = null;
|
| 805 |
let stateRefreshInterval = null;
|
| 806 |
|
| 807 |
-
//
|
| 808 |
-
let
|
| 809 |
-
let targetPitch = 0;
|
| 810 |
-
let targetRoll = 0;
|
| 811 |
-
let joystickActive = false;
|
| 812 |
-
let joystickX = 0;
|
| 813 |
-
let joystickY = 0;
|
| 814 |
-
let joystickInterval = null;
|
| 815 |
|
| 816 |
// Audio state
|
| 817 |
let localStream = null;
|
|
@@ -834,7 +733,7 @@
|
|
| 834 |
|
| 835 |
document.addEventListener('DOMContentLoaded', () => {
|
| 836 |
initAuth();
|
| 837 |
-
|
| 838 |
initAntennaSliders();
|
| 839 |
});
|
| 840 |
|
|
@@ -1084,7 +983,6 @@
|
|
| 1084 |
|
| 1085 |
function stopStream() {
|
| 1086 |
if (stateRefreshInterval) clearInterval(stateRefreshInterval);
|
| 1087 |
-
if (joystickInterval) clearInterval(joystickInterval);
|
| 1088 |
if (peerConnection) peerConnection.close();
|
| 1089 |
if (dataChannel) dataChannel.close();
|
| 1090 |
peerConnection = null;
|
|
@@ -1118,15 +1016,20 @@
|
|
| 1118 |
const pitch = Math.asin(-m[2][0]) * 180 / Math.PI;
|
| 1119 |
const yaw = Math.atan2(m[1][0], m[0][0]) * 180 / Math.PI;
|
| 1120 |
const roll = Math.atan2(m[2][1], m[2][2]) * 180 / Math.PI;
|
|
|
|
|
|
|
| 1121 |
document.getElementById('stateYaw').textContent = yaw.toFixed(1) + '°';
|
| 1122 |
document.getElementById('statePitch').textContent = pitch.toFixed(1) + '°';
|
| 1123 |
document.getElementById('stateRoll').textContent = roll.toFixed(1) + '°';
|
| 1124 |
|
| 1125 |
-
//
|
| 1126 |
-
if (!
|
| 1127 |
-
|
| 1128 |
-
|
| 1129 |
-
|
|
|
|
|
|
|
|
|
|
| 1130 |
}
|
| 1131 |
}
|
| 1132 |
if (state.body_yaw !== undefined) {
|
|
@@ -1147,105 +1050,59 @@
|
|
| 1147 |
}
|
| 1148 |
}
|
| 1149 |
|
| 1150 |
-
// =====================
|
| 1151 |
-
function
|
| 1152 |
-
const
|
| 1153 |
-
const
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
const
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
| 1164 |
-
const dist = Math.sqrt(x * x + y * y);
|
| 1165 |
-
if (dist > maxRadius) {
|
| 1166 |
-
x = (x / dist) * maxRadius;
|
| 1167 |
-
y = (y / dist) * maxRadius;
|
| 1168 |
-
}
|
| 1169 |
-
return {
|
| 1170 |
-
x, y,
|
| 1171 |
-
normX: x / maxRadius, // -1 to 1 (left to right)
|
| 1172 |
-
normY: y / maxRadius // -1 to 1 (top to bottom)
|
| 1173 |
-
};
|
| 1174 |
-
}
|
| 1175 |
-
|
| 1176 |
-
function updateKnob(pos) {
|
| 1177 |
-
const rect = joystick.getBoundingClientRect();
|
| 1178 |
-
knob.style.left = (rect.width / 2 + pos.x) + 'px';
|
| 1179 |
-
knob.style.top = (rect.height / 2 + pos.y) + 'px';
|
| 1180 |
-
}
|
| 1181 |
-
|
| 1182 |
-
function startJoystick(e) {
|
| 1183 |
-
e.preventDefault();
|
| 1184 |
-
joystickActive = true;
|
| 1185 |
-
const pos = getPosition(e);
|
| 1186 |
-
updateKnob(pos);
|
| 1187 |
-
joystickX = pos.normX;
|
| 1188 |
-
joystickY = pos.normY;
|
| 1189 |
-
startContinuousMovement();
|
| 1190 |
}
|
| 1191 |
|
| 1192 |
-
function
|
| 1193 |
-
|
| 1194 |
-
e.preventDefault();
|
| 1195 |
-
const pos = getPosition(e);
|
| 1196 |
-
updateKnob(pos);
|
| 1197 |
-
joystickX = pos.normX;
|
| 1198 |
-
joystickY = pos.normY;
|
| 1199 |
}
|
| 1200 |
|
| 1201 |
-
function
|
| 1202 |
-
|
| 1203 |
-
knob.style.left = '50%';
|
| 1204 |
-
knob.style.top = '50%';
|
| 1205 |
-
joystickX = 0;
|
| 1206 |
-
joystickY = 0;
|
| 1207 |
-
stopContinuousMovement();
|
| 1208 |
}
|
| 1209 |
|
| 1210 |
-
|
| 1211 |
-
|
| 1212 |
-
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
function startContinuousMovement() {
|
| 1220 |
-
if (joystickInterval) return;
|
| 1221 |
-
|
| 1222 |
-
joystickInterval = setInterval(() => {
|
| 1223 |
-
if (!joystickActive) return;
|
| 1224 |
-
|
| 1225 |
-
const speed = 1.5; // degrees per tick
|
| 1226 |
-
|
| 1227 |
-
// Joystick mapping:
|
| 1228 |
-
// Left/Right (X) controls Yaw: right = turn right (negative yaw)
|
| 1229 |
-
// Up/Down (Y) controls Pitch: up = look up (negative pitch)
|
| 1230 |
-
targetYaw += -joystickX * speed;
|
| 1231 |
-
targetPitch += joystickY * speed;
|
| 1232 |
-
|
| 1233 |
-
// Clamp to limits
|
| 1234 |
-
targetYaw = Math.max(-45, Math.min(45, targetYaw));
|
| 1235 |
-
targetPitch = Math.max(-30, Math.min(30, targetPitch));
|
| 1236 |
-
targetRoll = Math.max(-20, Math.min(20, targetRoll));
|
| 1237 |
-
|
| 1238 |
-
// Send command
|
| 1239 |
-
sendCommand({ set_target: buildMatrix(targetYaw, targetPitch, targetRoll) });
|
| 1240 |
|
| 1241 |
-
|
| 1242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1243 |
|
| 1244 |
-
|
| 1245 |
-
|
| 1246 |
-
|
| 1247 |
-
|
| 1248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1249 |
}
|
| 1250 |
|
| 1251 |
function buildMatrix(yawDeg, pitchDeg, rollDeg) {
|
|
|
|
| 267 |
padding: 12px;
|
| 268 |
}
|
| 269 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
/* Sliders */
|
| 271 |
.slider-row {
|
| 272 |
display: flex;
|
|
|
|
| 487 |
grid-column: 2;
|
| 488 |
grid-row: 4;
|
| 489 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
}
|
| 491 |
|
| 492 |
/* Login View */
|
|
|
|
| 616 |
</div>
|
| 617 |
</div>
|
| 618 |
|
| 619 |
+
<!-- Head Control - RPY Sliders -->
|
| 620 |
<div class="panel">
|
| 621 |
+
<div class="panel-header">Head Orientation</div>
|
| 622 |
<div class="panel-content">
|
| 623 |
+
<div class="slider-row">
|
| 624 |
+
<span class="slider-label">Roll</span>
|
| 625 |
+
<input type="range" class="slider" id="rollSlider" min="-20" max="20" value="0" step="0.5">
|
| 626 |
+
<span class="slider-value" id="rollValue">0.0°</span>
|
| 627 |
+
</div>
|
| 628 |
+
<div class="slider-row">
|
| 629 |
+
<span class="slider-label">Pitch</span>
|
| 630 |
+
<input type="range" class="slider" id="pitchSlider" min="-30" max="30" value="0" step="0.5">
|
| 631 |
+
<span class="slider-value" id="pitchValue">0.0°</span>
|
| 632 |
+
</div>
|
| 633 |
+
<div class="slider-row">
|
| 634 |
+
<span class="slider-label">Yaw</span>
|
| 635 |
+
<input type="range" class="slider" id="yawSlider" min="-45" max="45" value="0" step="0.5">
|
| 636 |
+
<span class="slider-value" id="yawValue">0.0°</span>
|
| 637 |
</div>
|
|
|
|
| 638 |
</div>
|
| 639 |
</div>
|
| 640 |
|
|
|
|
| 709 |
let sseAbortController = null;
|
| 710 |
let stateRefreshInterval = null;
|
| 711 |
|
| 712 |
+
// Head control state
|
| 713 |
+
let headSlidersActive = false; // True while user is dragging a slider
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
|
| 715 |
// Audio state
|
| 716 |
let localStream = null;
|
|
|
|
| 733 |
|
| 734 |
document.addEventListener('DOMContentLoaded', () => {
|
| 735 |
initAuth();
|
| 736 |
+
initHeadSliders();
|
| 737 |
initAntennaSliders();
|
| 738 |
});
|
| 739 |
|
|
|
|
| 983 |
|
| 984 |
function stopStream() {
|
| 985 |
if (stateRefreshInterval) clearInterval(stateRefreshInterval);
|
|
|
|
| 986 |
if (peerConnection) peerConnection.close();
|
| 987 |
if (dataChannel) dataChannel.close();
|
| 988 |
peerConnection = null;
|
|
|
|
| 1016 |
const pitch = Math.asin(-m[2][0]) * 180 / Math.PI;
|
| 1017 |
const yaw = Math.atan2(m[1][0], m[0][0]) * 180 / Math.PI;
|
| 1018 |
const roll = Math.atan2(m[2][1], m[2][2]) * 180 / Math.PI;
|
| 1019 |
+
|
| 1020 |
+
// Update state bar
|
| 1021 |
document.getElementById('stateYaw').textContent = yaw.toFixed(1) + '°';
|
| 1022 |
document.getElementById('statePitch').textContent = pitch.toFixed(1) + '°';
|
| 1023 |
document.getElementById('stateRoll').textContent = roll.toFixed(1) + '°';
|
| 1024 |
|
| 1025 |
+
// Update sliders with real position when not being controlled
|
| 1026 |
+
if (!headSlidersActive) {
|
| 1027 |
+
document.getElementById('rollSlider').value = roll;
|
| 1028 |
+
document.getElementById('rollValue').textContent = roll.toFixed(1) + '°';
|
| 1029 |
+
document.getElementById('pitchSlider').value = pitch;
|
| 1030 |
+
document.getElementById('pitchValue').textContent = pitch.toFixed(1) + '°';
|
| 1031 |
+
document.getElementById('yawSlider').value = yaw;
|
| 1032 |
+
document.getElementById('yawValue').textContent = yaw.toFixed(1) + '°';
|
| 1033 |
}
|
| 1034 |
}
|
| 1035 |
if (state.body_yaw !== undefined) {
|
|
|
|
| 1050 |
}
|
| 1051 |
}
|
| 1052 |
|
| 1053 |
+
// ===================== Head Sliders =====================
|
| 1054 |
+
function initHeadSliders() {
|
| 1055 |
+
const rollSlider = document.getElementById('rollSlider');
|
| 1056 |
+
const pitchSlider = document.getElementById('pitchSlider');
|
| 1057 |
+
const yawSlider = document.getElementById('yawSlider');
|
| 1058 |
+
const rollValue = document.getElementById('rollValue');
|
| 1059 |
+
const pitchValue = document.getElementById('pitchValue');
|
| 1060 |
+
const yawValue = document.getElementById('yawValue');
|
| 1061 |
+
|
| 1062 |
+
function sendHeadPose() {
|
| 1063 |
+
const roll = parseFloat(rollSlider.value);
|
| 1064 |
+
const pitch = parseFloat(pitchSlider.value);
|
| 1065 |
+
const yaw = parseFloat(yawSlider.value);
|
| 1066 |
+
sendCommand({ set_target: buildMatrix(yaw, pitch, roll) });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1067 |
}
|
| 1068 |
|
| 1069 |
+
function onSliderStart() {
|
| 1070 |
+
headSlidersActive = true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1071 |
}
|
| 1072 |
|
| 1073 |
+
function onSliderEnd() {
|
| 1074 |
+
headSlidersActive = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1075 |
}
|
| 1076 |
|
| 1077 |
+
// Roll slider
|
| 1078 |
+
rollSlider.addEventListener('mousedown', onSliderStart);
|
| 1079 |
+
rollSlider.addEventListener('touchstart', onSliderStart);
|
| 1080 |
+
rollSlider.addEventListener('mouseup', onSliderEnd);
|
| 1081 |
+
rollSlider.addEventListener('touchend', onSliderEnd);
|
| 1082 |
+
rollSlider.addEventListener('input', () => {
|
| 1083 |
+
rollValue.textContent = parseFloat(rollSlider.value).toFixed(1) + '°';
|
| 1084 |
+
sendHeadPose();
|
| 1085 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1086 |
|
| 1087 |
+
// Pitch slider
|
| 1088 |
+
pitchSlider.addEventListener('mousedown', onSliderStart);
|
| 1089 |
+
pitchSlider.addEventListener('touchstart', onSliderStart);
|
| 1090 |
+
pitchSlider.addEventListener('mouseup', onSliderEnd);
|
| 1091 |
+
pitchSlider.addEventListener('touchend', onSliderEnd);
|
| 1092 |
+
pitchSlider.addEventListener('input', () => {
|
| 1093 |
+
pitchValue.textContent = parseFloat(pitchSlider.value).toFixed(1) + '°';
|
| 1094 |
+
sendHeadPose();
|
| 1095 |
+
});
|
| 1096 |
|
| 1097 |
+
// Yaw slider
|
| 1098 |
+
yawSlider.addEventListener('mousedown', onSliderStart);
|
| 1099 |
+
yawSlider.addEventListener('touchstart', onSliderStart);
|
| 1100 |
+
yawSlider.addEventListener('mouseup', onSliderEnd);
|
| 1101 |
+
yawSlider.addEventListener('touchend', onSliderEnd);
|
| 1102 |
+
yawSlider.addEventListener('input', () => {
|
| 1103 |
+
yawValue.textContent = parseFloat(yawSlider.value).toFixed(1) + '°';
|
| 1104 |
+
sendHeadPose();
|
| 1105 |
+
});
|
| 1106 |
}
|
| 1107 |
|
| 1108 |
function buildMatrix(yawDeg, pitchDeg, rollDeg) {
|