Georg commited on
Commit
598c3a8
·
1 Parent(s): 223bbeb

Implement frontend integration and HTML template loading in mujoco_server.py

Browse files

- Added a new HTML file for the frontend, establishing a structured layout for the Nova Sim interface.
- Updated the mujoco_server.py to load the HTML template dynamically, replacing inline HTML with a more maintainable approach.
- Enhanced the response handling to prevent browser caching, ensuring users receive the latest version of the frontend assets.
- This commit sets the foundation for future frontend enhancements and improves the overall project structure.

Files changed (2) hide show
  1. frontend/index.html +2343 -0
  2. mujoco_server.py +0 -0
frontend/index.html CHANGED
@@ -0,0 +1,2343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+
4
+ <head>
5
+ <title>Nova Sim - Wandelbots Robot Simulator</title>
6
+ <style>
7
+ /* Wandelbots Corporate Design Colors:
8
+ Primary Dark: #01040f (Blue Charcoal)
9
+ Light/Secondary: #bcbeec (Spindle - lavender)
10
+ Accent: #211c44 (Port Gore - deep purple)
11
+ Logo Dark: #181838
12
+ */
13
+ :root {
14
+ --wb-primary: #01040f;
15
+ --wb-secondary: #bcbeec;
16
+ --wb-accent: #211c44;
17
+ --wb-logo: #181838;
18
+ --wb-highlight: #8b7fef;
19
+ --wb-success: #7c6bef;
20
+ --wb-danger: #ef6b6b;
21
+ }
22
+
23
+ body,
24
+ html {
25
+ margin: 0;
26
+ padding: 0;
27
+ width: 100%;
28
+ height: 100%;
29
+ overflow: hidden;
30
+ background: var(--wb-primary);
31
+ font-family: Arial, Helvetica, sans-serif;
32
+ user-select: none;
33
+ }
34
+
35
+ .video-container {
36
+ position: absolute;
37
+ top: 0;
38
+ left: 0;
39
+ width: 100vw;
40
+ height: 100vh;
41
+ cursor: grab;
42
+ }
43
+
44
+ .video-container:active {
45
+ cursor: grabbing;
46
+ }
47
+
48
+ .video-container img {
49
+ width: 100%;
50
+ height: 100%;
51
+ object-fit: cover;
52
+ }
53
+
54
+ .overlay {
55
+ position: absolute;
56
+ top: 20px;
57
+ left: 20px;
58
+ background: rgba(33, 28, 68, 0.85);
59
+ backdrop-filter: blur(15px);
60
+ padding: 25px;
61
+ border-radius: 12px;
62
+ box-shadow: 0 8px 32px rgba(1, 4, 15, 0.6);
63
+ color: var(--wb-secondary);
64
+ border: 1px solid rgba(188, 190, 236, 0.15);
65
+ z-index: 100;
66
+ width: 320px;
67
+ max-height: calc(100vh - 40px);
68
+ overflow-y: auto;
69
+ box-sizing: border-box;
70
+ }
71
+
72
+ .overlay h2 {
73
+ margin: 0 0 15px 0;
74
+ font-size: 1.1em;
75
+ font-weight: 600;
76
+ letter-spacing: 0.5px;
77
+ color: #fff;
78
+ }
79
+
80
+ .control-group {
81
+ margin-bottom: 15px;
82
+ }
83
+
84
+ .control-group label {
85
+ display: block;
86
+ margin-bottom: 5px;
87
+ font-size: 0.8em;
88
+ opacity: 0.8;
89
+ color: var(--wb-secondary);
90
+ }
91
+
92
+ .slider-row {
93
+ display: flex;
94
+ align-items: center;
95
+ gap: 12px;
96
+ }
97
+
98
+ input[type=range] {
99
+ flex-grow: 1;
100
+ cursor: pointer;
101
+ accent-color: var(--wb-highlight);
102
+ }
103
+
104
+ .val-display {
105
+ width: 60px;
106
+ font-family: monospace;
107
+ font-size: 0.9em;
108
+ text-align: right;
109
+ color: #fff;
110
+ }
111
+
112
+ button {
113
+ flex: 1;
114
+ padding: 10px;
115
+ background: rgba(188, 190, 236, 0.1);
116
+ color: var(--wb-secondary);
117
+ border: 1px solid rgba(188, 190, 236, 0.25);
118
+ border-radius: 6px;
119
+ cursor: pointer;
120
+ transition: all 0.2s;
121
+ font-size: 0.85em;
122
+ }
123
+
124
+ button:hover {
125
+ background: rgba(188, 190, 236, 0.2);
126
+ border-color: rgba(188, 190, 236, 0.4);
127
+ }
128
+
129
+ button.danger {
130
+ background: rgba(239, 107, 107, 0.4);
131
+ border-color: rgba(239, 107, 107, 0.5);
132
+ }
133
+
134
+ button.danger:hover {
135
+ background: rgba(239, 107, 107, 0.6);
136
+ }
137
+
138
+ .stats {
139
+ margin-top: 15px;
140
+ font-size: 0.8em;
141
+ opacity: 0.9;
142
+ line-height: 1.6;
143
+ color: var(--wb-secondary);
144
+ }
145
+
146
+ .hint {
147
+ position: absolute;
148
+ bottom: 20px;
149
+ right: 20px;
150
+ color: rgba(188, 190, 236, 0.5);
151
+ font-size: 0.8em;
152
+ pointer-events: none;
153
+ text-align: right;
154
+ }
155
+
156
+ .rl-notifications {
157
+ position: absolute;
158
+ right: 24px;
159
+ bottom: 24px;
160
+ display: flex;
161
+ flex-direction: column;
162
+ gap: 8px;
163
+ z-index: 250;
164
+ }
165
+
166
+ .rl-notifications .notification {
167
+ min-width: 200px;
168
+ padding: 10px 14px;
169
+ background: rgba(9, 8, 29, 0.95);
170
+ border-radius: 12px;
171
+ border: 1px solid rgba(188, 190, 236, 0.25);
172
+ font-size: 0.75em;
173
+ color: #fff;
174
+ box-shadow: 0 10px 24px rgba(1, 4, 15, 0.5);
175
+ animation: toast-pop 0.35s ease;
176
+ }
177
+
178
+ @keyframes toast-pop {
179
+ from {
180
+ transform: translateY(10px);
181
+ opacity: 0;
182
+ }
183
+
184
+ to {
185
+ transform: translateY(0);
186
+ opacity: 1;
187
+ }
188
+ }
189
+
190
+ .overlay-tiles {
191
+ position: absolute;
192
+ right: 20px;
193
+ bottom: 50px;
194
+ top: auto;
195
+ width: 240px;
196
+ display: none;
197
+ flex-direction: column;
198
+ gap: 10px;
199
+ z-index: 200;
200
+ pointer-events: none;
201
+ }
202
+
203
+ .overlay-tile {
204
+ width: 100%;
205
+ height: 140px;
206
+ border-radius: 8px;
207
+ overflow: hidden;
208
+ background: rgba(0, 0, 0, 0.35);
209
+ border: 1px solid rgba(255, 255, 255, 0.2);
210
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
211
+ display: flex;
212
+ flex-direction: column;
213
+ }
214
+
215
+ .overlay-tile img {
216
+ width: 100%;
217
+ height: 100%;
218
+ object-fit: cover;
219
+ filter: saturate(1.1);
220
+ }
221
+
222
+ .overlay-label {
223
+ position: absolute;
224
+ bottom: 4px;
225
+ left: 6px;
226
+ right: 6px;
227
+ font-size: 0.65em;
228
+ text-transform: uppercase;
229
+ letter-spacing: 0.2em;
230
+ color: rgba(255, 255, 255, 0.9);
231
+ text-shadow: 0 0 6px rgba(0, 0, 0, 0.6);
232
+ }
233
+
234
+ .control-panel-info {
235
+ margin-top: 12px;
236
+ padding: 10px;
237
+ border: 1px solid rgba(188, 190, 236, 0.2);
238
+ border-radius: 8px;
239
+ background: rgba(188, 190, 236, 0.05);
240
+ font-size: 0.8em;
241
+ line-height: 1.4;
242
+ }
243
+
244
+ .control-panel-info ul {
245
+ margin: 6px 0 0 10px;
246
+ padding: 0;
247
+ display: flex;
248
+ flex-direction: column;
249
+ gap: 6px;
250
+ }
251
+
252
+ .control-panel-info li {
253
+ list-style: none;
254
+ }
255
+
256
+ .hint-key {
257
+ display: flex;
258
+ align-items: center;
259
+ gap: 6px;
260
+ }
261
+
262
+ .hint-key kbd {
263
+ padding: 2px 6px;
264
+ border-radius: 4px;
265
+ background: rgba(139, 127, 239, 0.35);
266
+ border: 1px solid rgba(139, 127, 239, 0.55);
267
+ font-size: 0.75em;
268
+ color: #fff;
269
+ font-weight: 600;
270
+ }
271
+
272
+ /* State info panel - top right */
273
+ .state-panel {
274
+ position: absolute;
275
+ top: 20px;
276
+ right: 20px;
277
+ background: rgba(33, 28, 68, 0.85);
278
+ backdrop-filter: blur(12px);
279
+ padding: 8px 12px;
280
+ border-radius: 8px;
281
+ box-shadow: 0 3px 14px rgba(1, 4, 15, 0.35);
282
+ color: var(--wb-secondary);
283
+ border: 1px solid rgba(188, 190, 236, 0.15);
284
+ z-index: 100;
285
+ min-width: 180px;
286
+ max-width: 220px;
287
+ font-size: 0.75em;
288
+ line-height: 1.3;
289
+ }
290
+
291
+ .state-panel strong {
292
+ color: #fff;
293
+ }
294
+
295
+ /* Camera controls - bottom left */
296
+ .camera-panel {
297
+ position: absolute;
298
+ bottom: 20px;
299
+ left: 20px;
300
+ background: rgba(33, 28, 68, 0.85);
301
+ backdrop-filter: blur(15px);
302
+ padding: 15px 20px;
303
+ border-radius: 10px;
304
+ box-shadow: 0 4px 20px rgba(1, 4, 15, 0.5);
305
+ color: var(--wb-secondary);
306
+ border: 1px solid rgba(188, 190, 236, 0.15);
307
+ z-index: 100;
308
+ width: 280px;
309
+ }
310
+
311
+ .camera-panel .control-group {
312
+ margin-bottom: 10px;
313
+ }
314
+
315
+ .camera-panel .control-group:last-child {
316
+ margin-bottom: 0;
317
+ }
318
+
319
+ .state-hint {
320
+ margin-top: 12px;
321
+ font-size: 0.75em;
322
+ color: rgba(255, 255, 255, 0.7);
323
+ line-height: 1.4;
324
+ }
325
+
326
+ .camera-panel.inside-panel {
327
+ position: relative;
328
+ bottom: auto;
329
+ left: auto;
330
+ background: rgba(33, 28, 68, 0.75);
331
+ border: 1px solid rgba(188, 190, 236, 0.2);
332
+ box-shadow: none;
333
+ margin-top: 16px;
334
+ }
335
+
336
+ /* Collapsible panel header - title is the button */
337
+ .panel-header {
338
+ display: flex;
339
+ justify-content: space-between;
340
+ align-items: center;
341
+ cursor: pointer;
342
+ margin-bottom: 15px;
343
+ padding: 4px 0;
344
+ border-radius: 4px;
345
+ transition: all 0.2s;
346
+ }
347
+
348
+ .panel-header:hover {
349
+ background: rgba(188, 190, 236, 0.1);
350
+ }
351
+
352
+ .panel-header h2 {
353
+ margin: 0;
354
+ display: flex;
355
+ align-items: center;
356
+ gap: 8px;
357
+ }
358
+
359
+ .panel-header h2::after {
360
+ content: '−';
361
+ font-weight: 300;
362
+ font-size: 1.2em;
363
+ opacity: 0.6;
364
+ transition: transform 0.2s;
365
+ }
366
+
367
+ .panel-header.collapsed h2::after {
368
+ content: '+';
369
+ }
370
+
371
+ .panel-content {
372
+ transition: all 0.3s ease;
373
+ overflow: hidden;
374
+ }
375
+
376
+ .panel-content.collapsed {
377
+ max-height: 0;
378
+ opacity: 0;
379
+ margin: 0;
380
+ padding: 0;
381
+ }
382
+
383
+ .overlay.collapsed {
384
+ width: auto;
385
+ min-width: 200px;
386
+ }
387
+
388
+ .rl-buttons {
389
+ display: flex;
390
+ flex-direction: column;
391
+ gap: 5px;
392
+ margin: 10px 0;
393
+ }
394
+
395
+ .rl-row {
396
+ display: flex;
397
+ justify-content: center;
398
+ gap: 5px;
399
+ }
400
+
401
+ .rl-btn {
402
+ padding: 12px 16px;
403
+ min-width: 80px;
404
+ background: rgba(124, 107, 239, 0.4);
405
+ border: 1px solid rgba(124, 107, 239, 0.5);
406
+ color: #fff;
407
+ border-radius: 6px;
408
+ cursor: pointer;
409
+ transition: all 0.15s;
410
+ font-size: 0.85em;
411
+ font-weight: 500;
412
+ }
413
+
414
+ .rl-btn:hover {
415
+ background: rgba(124, 107, 239, 0.6);
416
+ }
417
+
418
+ .rl-btn:active {
419
+ background: rgba(124, 107, 239, 0.8);
420
+ transform: scale(0.95);
421
+ }
422
+
423
+ .rl-btn.stop {
424
+ background: rgba(239, 107, 107, 0.4);
425
+ border-color: rgba(239, 107, 107, 0.5);
426
+ }
427
+
428
+ .rl-btn.stop:hover {
429
+ background: rgba(239, 107, 107, 0.6);
430
+ }
431
+
432
+ .cmd-display {
433
+ font-family: monospace;
434
+ font-size: 0.8em;
435
+ opacity: 0.8;
436
+ margin-top: 8px;
437
+ color: var(--wb-secondary);
438
+ }
439
+
440
+ .connection-status-inline {
441
+ padding: 6px 12px;
442
+ border-radius: 4px;
443
+ font-size: 0.75em;
444
+ font-weight: 600;
445
+ background: rgba(124, 107, 239, 0.25);
446
+ color: #fff;
447
+ border: 1px solid rgba(124, 107, 239, 0.4);
448
+ margin-bottom: 12px;
449
+ text-align: center;
450
+ text-transform: uppercase;
451
+ letter-spacing: 0.5px;
452
+ }
453
+
454
+ .connection-status-inline.disconnected {
455
+ background: rgba(239, 107, 107, 0.25);
456
+ border-color: rgba(239, 107, 107, 0.4);
457
+ }
458
+
459
+ .connection-status-inline .status-text {
460
+ display: inline-block;
461
+ }
462
+
463
+ .connection-status-inline .status-loader {
464
+ display: none;
465
+ width: 12px;
466
+ height: 12px;
467
+ border: 2px solid rgba(255, 255, 255, 0.35);
468
+ border-top-color: #fff;
469
+ border-radius: 50%;
470
+ animation: status-spin 0.8s linear infinite;
471
+ margin-left: 8px;
472
+ }
473
+
474
+ .connection-status-inline.connecting {
475
+ background: rgba(139, 127, 239, 0.35);
476
+ border-color: rgba(255, 255, 255, 0.3);
477
+ }
478
+
479
+ .connection-status-inline.connecting .status-loader {
480
+ display: inline-block;
481
+ }
482
+
483
+ @keyframes status-spin {
484
+ from {
485
+ transform: rotate(0deg);
486
+ }
487
+
488
+ to {
489
+ transform: rotate(360deg);
490
+ }
491
+ }
492
+
493
+ .status-card {
494
+ padding: 10px 12px;
495
+ border-radius: 8px;
496
+ border-left: 3px solid var(--wb-success);
497
+ background: rgba(76, 175, 80, 0.15);
498
+ display: flex;
499
+ flex-direction: column;
500
+ gap: 6px;
501
+ margin-top: 10px;
502
+ transition: border-color 0.2s, background 0.2s;
503
+ }
504
+
505
+ .status-card strong {
506
+ font-size: 0.8em;
507
+ letter-spacing: 0.2em;
508
+ text-transform: uppercase;
509
+ color: #fff;
510
+ }
511
+
512
+ .status-card .status-card-text {
513
+ font-size: 0.75em;
514
+ color: rgba(255, 255, 255, 0.8);
515
+ }
516
+
517
+ .status-card.disconnected {
518
+ background: rgba(239, 107, 107, 0.15);
519
+ border-color: rgba(239, 107, 107, 0.7);
520
+ }
521
+
522
+ .trainer-status {
523
+ display: flex;
524
+ align-items: center;
525
+ gap: 6px;
526
+ font-size: 0.7em;
527
+ letter-spacing: 0.4px;
528
+ text-transform: uppercase;
529
+ }
530
+
531
+ .trainer-status-dot {
532
+ width: 8px;
533
+ height: 8px;
534
+ border-radius: 50%;
535
+ background: rgba(124, 107, 239, 0.6);
536
+ border: 1px solid rgba(255, 255, 255, 0.45);
537
+ }
538
+
539
+ .trainer-status.connected .trainer-status-dot {
540
+ background: #53e89b;
541
+ box-shadow: 0 0 6px rgba(83, 232, 155, 0.8);
542
+ }
543
+
544
+ .trainer-status.disconnected .trainer-status-dot {
545
+ background: #ef6b6b;
546
+ box-shadow: 0 0 6px rgba(239, 107, 107, 0.8);
547
+ }
548
+
549
+ .camera-controls {
550
+ margin-top: 15px;
551
+ }
552
+
553
+ select {
554
+ width: 100%;
555
+ padding: 10px;
556
+ background: rgba(188, 190, 236, 0.1);
557
+ color: #fff;
558
+ border: 1px solid rgba(188, 190, 236, 0.25);
559
+ border-radius: 6px;
560
+ cursor: pointer;
561
+ font-size: 0.9em;
562
+ }
563
+
564
+ select option {
565
+ background: var(--wb-accent);
566
+ color: #fff;
567
+ }
568
+
569
+ .robot-selector {
570
+ margin-bottom: 20px;
571
+ }
572
+
573
+ .scene-summary {
574
+ margin-top: 12px;
575
+ font-size: 0.8em;
576
+ display: flex;
577
+ align-items: baseline;
578
+ gap: 6px;
579
+ color: rgba(255, 255, 255, 0.75);
580
+ }
581
+
582
+ .scene-summary strong {
583
+ font-size: 0.9em;
584
+ opacity: 0.95;
585
+ }
586
+
587
+ .robot-info {
588
+ padding: 10px;
589
+ background: rgba(139, 127, 239, 0.2);
590
+ border-radius: 6px;
591
+ margin-top: 10px;
592
+ font-size: 0.8em;
593
+ border: 1px solid rgba(139, 127, 239, 0.3);
594
+ }
595
+
596
+ .arm-controls {
597
+ display: none;
598
+ }
599
+
600
+ .arm-controls.active {
601
+ display: block;
602
+ }
603
+
604
+ .locomotion-controls {
605
+ display: block;
606
+ }
607
+
608
+ .locomotion-controls.hidden {
609
+ display: none;
610
+ }
611
+
612
+ .gripper-btns {
613
+ display: flex;
614
+ gap: 10px;
615
+ margin: 10px 0;
616
+ }
617
+
618
+ .gripper-btns button {
619
+ flex: 1;
620
+ }
621
+
622
+ .target-sliders {
623
+ margin: 10px 0;
624
+ }
625
+
626
+ .target-sliders .slider-row {
627
+ margin-bottom: 8px;
628
+ }
629
+
630
+ .target-sliders label {
631
+ width: 20px;
632
+ display: inline-block;
633
+ }
634
+
635
+ .mode-toggle {
636
+ display: flex;
637
+ gap: 5px;
638
+ margin-bottom: 15px;
639
+ }
640
+
641
+ .mode-toggle button {
642
+ flex: 1;
643
+ padding: 8px;
644
+ background: rgba(188, 190, 236, 0.1);
645
+ border: 1px solid rgba(188, 190, 236, 0.2);
646
+ }
647
+
648
+ .mode-toggle button.active {
649
+ background: rgba(139, 127, 239, 0.5);
650
+ border-color: rgba(139, 127, 239, 0.7);
651
+ }
652
+
653
+ .ik-controls,
654
+ .joint-controls {
655
+ display: none;
656
+ }
657
+
658
+ .ik-controls.active,
659
+ .joint-controls.active {
660
+ display: block;
661
+ }
662
+
663
+ .joint-sliders .slider-row {
664
+ margin-bottom: 6px;
665
+ }
666
+
667
+ .joint-sliders label {
668
+ width: 40px;
669
+ display: inline-block;
670
+ font-size: 0.75em;
671
+ }
672
+
673
+ /* Jogging controls */
674
+ .jog-controls {
675
+ margin: 10px 0;
676
+ }
677
+
678
+ .jog-row {
679
+ display: flex;
680
+ align-items: center;
681
+ gap: 8px;
682
+ margin-bottom: 8px;
683
+ }
684
+
685
+ .jog-row label {
686
+ width: 30px;
687
+ display: inline-block;
688
+ font-size: 0.85em;
689
+ text-align: left;
690
+ }
691
+
692
+ .jog-btn {
693
+ width: 40px;
694
+ height: 40px;
695
+ padding: 0;
696
+ font-size: 1.5em;
697
+ font-weight: bold;
698
+ background: rgba(188, 190, 236, 0.15);
699
+ color: var(--wb-secondary);
700
+ border: 1px solid rgba(188, 190, 236, 0.3);
701
+ border-radius: 6px;
702
+ cursor: pointer;
703
+ transition: all 0.15s;
704
+ user-select: none;
705
+ -webkit-user-select: none;
706
+ flex-shrink: 0;
707
+ }
708
+
709
+ .jog-btn:hover {
710
+ background: rgba(139, 127, 239, 0.3);
711
+ border-color: rgba(139, 127, 239, 0.5);
712
+ transform: scale(1.05);
713
+ }
714
+
715
+ .jog-btn:active {
716
+ background: rgba(139, 127, 239, 0.5);
717
+ border-color: rgba(139, 127, 239, 0.7);
718
+ transform: scale(0.95);
719
+ }
720
+
721
+ .jog-row .val-display {
722
+ flex-grow: 1;
723
+ width: auto;
724
+ text-align: center;
725
+ font-family: monospace;
726
+ font-size: 0.9em;
727
+ color: #fff;
728
+ }
729
+ </style>
730
+ </head>
731
+
732
+ <body>
733
+ <div class="video-container" id="viewport">
734
+ <img src=\"""" + API_PREFIX + """/video_feed" draggable="false">
735
+ </div>
736
+
737
+ <!-- State info panel - top right -->
738
+ <div class="state-panel" id="state_panel">
739
+ <!-- Connection status badge (inside panel) -->
740
+ <div class="connection-status-inline connecting" id="conn_status">
741
+ <span class="status-text" id="conn_status_text">Connecting...</span>
742
+ <span class="status-loader" id="conn_loader" aria-hidden="true"></span>
743
+ </div>
744
+
745
+ <div id="locomotion_state">
746
+ <strong>Robot State</strong><br>
747
+ Position: <span id="loco_pos">0.00, 0.00, 0.00</span><br>
748
+ Orientation: <span id="loco_ori">0.00, 0.00, 0.00</span><br>
749
+ Steps: <span id="step_val">0</span><br>
750
+ Teleop: <span id="loco_teleop_display"
751
+ style="display: inline-block; word-wrap: break-word; max-width: 100%;">-</span>
752
+ </div>
753
+ <div id="arm_state" style="display: none;">
754
+ <strong>Arm State</strong><br>
755
+ EE Pos: <span id="ee_pos">0.00, 0.00, 0.00</span><br>
756
+ EE Ori: <span id="ee_ori">0.00, 0.00, 0.00</span><br>
757
+ <span id="gripper_state_display">Gripper: <span id="gripper_val">50%</span><br></span>
758
+ Reward: <span id="arm_reward">-</span><br>
759
+ Mode: <span id="control_mode_display">IK</span> | Steps: <span id="arm_step_val">0</span><br>
760
+ Teleop: <span id="arm_teleop_display"
761
+ style="display: inline-block; word-wrap: break-word; max-width: 100%;">-</span><br>
762
+ <div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(188, 190, 236, 0.2);">
763
+ <div id="nova_connection_badge" class="status-card" style="display: none; margin-bottom: 8px;">
764
+ <strong id="nova_badge_title"
765
+ style="font-size: 0.85em; color: var(--wb-success); display: flex; align-items: center; gap: 6px;">
766
+ Nova Connected
767
+ </strong>
768
+ <div style="font-size: 0.75em; color: rgba(255, 255, 255, 0.85);">
769
+ <span id="nova_mode_text">Digital Twin Mode</span> - Read Only
770
+ </div>
771
+ </div>
772
+ </div>
773
+ </div>
774
+ <div id="clients_status_card" class="status-card">
775
+ <strong>Connected Clients</strong>
776
+ <div class="clients-status" id="clients_status_indicator">
777
+ <span class="status-card-text" id="clients_status_text">No clients connected</span>
778
+ <ul id="clients_list" style="margin: 0.5em 0 0 0; padding-left: 1.5em; font-size: 0.9em;"></ul>
779
+ </div>
780
+ </div>
781
+ </div>
782
+ <div class="overlay-tiles" id="overlay_tiles"></div>
783
+
784
+ <div class="overlay" id="control_panel">
785
+ <div class="panel-header" id="panel_header" onclick="togglePanel()">
786
+ <h2 id="robot_title">Unitree G1 Humanoid</h2>
787
+ </div>
788
+
789
+ <div class="panel-content" id="panel_content">
790
+ <div class="robot-selector">
791
+ <select id="robot_select" onchange="switchRobot()">
792
+ <option value="">Loading robots...</option>
793
+ </select>
794
+ <div class="control-panel-info" id="arm_hints" style="display: none;">
795
+ <strong>Keyboard Controls</strong>
796
+ <ul>
797
+ <li>
798
+ <span class="hint-key">
799
+ <kbd>W/A/S/D</kbd>
800
+ <span>XY jog</span>
801
+ </span>
802
+ </li>
803
+ <li>
804
+ <span class="hint-key">
805
+ <kbd>R/F</kbd>
806
+ <span>Z nudge</span>
807
+ </span>
808
+ </li>
809
+ <li>
810
+ <span class="hint-key">
811
+ <kbd>Enter</kbd>
812
+ <span>Move to Home</span>
813
+ </span>
814
+ </li>
815
+ </ul>
816
+ </div>
817
+ <div class="control-panel-info" id="loco_hints" style="display: none;">
818
+ <strong>Keyboard Controls</strong>
819
+ <ul>
820
+ <li>
821
+ <span class="hint-key">
822
+ <kbd>W/S</kbd>
823
+ <span>Forward/Back</span>
824
+ </span>
825
+ </li>
826
+ <li>
827
+ <span class="hint-key">
828
+ <kbd>A/D</kbd>
829
+ <span>Turn Left/Right</span>
830
+ </span>
831
+ </li>
832
+ <li>
833
+ <span class="hint-key">
834
+ <kbd>Q/E</kbd>
835
+ <span>Strafe Left/Right</span>
836
+ </span>
837
+ </li>
838
+ </ul>
839
+ </div>
840
+ <div class="robot-info" id="robot_info">
841
+ 29 DOF humanoid with RL walking policy
842
+ </div>
843
+ </div>
844
+
845
+ <!-- Locomotion controls (G1, Spot) -->
846
+ <div class="locomotion-controls" id="locomotion_controls">
847
+ <div class="control-group">
848
+ <label>Walking Controls (WASD or buttons)</label>
849
+ <div class="rl-buttons">
850
+ <div class="rl-row">
851
+ <button class="rl-btn" onmousedown="setCmd(0.8, 0, 0)" onmouseup="setCmd(0,0,0)"
852
+ ontouchstart="setCmd(0.8, 0, 0)" ontouchend="setCmd(0,0,0)">W Forward</button>
853
+ </div>
854
+ <div class="rl-row">
855
+ <button class="rl-btn" onmousedown="setCmd(0, 0, 1.2)" onmouseup="setCmd(0,0,0)"
856
+ ontouchstart="setCmd(0, 0, 1.2)" ontouchend="setCmd(0,0,0)">A Turn L</button>
857
+ <button class="rl-btn" onmousedown="setCmd(-0.5, 0, 0)" onmouseup="setCmd(0,0,0)"
858
+ ontouchstart="setCmd(-0.5, 0, 0)" ontouchend="setCmd(0,0,0)">S Back</button>
859
+ <button class="rl-btn" onmousedown="setCmd(0, 0, -1.2)" onmouseup="setCmd(0,0,0)"
860
+ ontouchstart="setCmd(0, 0, -1.2)" ontouchend="setCmd(0,0,0)">D Turn R</button>
861
+ </div>
862
+ <div class="rl-row">
863
+ <button class="rl-btn" onmousedown="setCmd(0, 0.4, 0)" onmouseup="setCmd(0,0,0)"
864
+ ontouchstart="setCmd(0, 0.4, 0)" ontouchend="setCmd(0,0,0)">Q Strafe L</button>
865
+ <button class="rl-btn stop" onclick="setCmd(0, 0, 0)">Stop</button>
866
+ <button class="rl-btn" onmousedown="setCmd(0, -0.4, 0)" onmouseup="setCmd(0,0,0)"
867
+ ontouchstart="setCmd(0, -0.4, 0)" ontouchend="setCmd(0,0,0)">E Strafe R</button>
868
+ </div>
869
+ </div>
870
+ </div>
871
+ </div>
872
+
873
+ <!-- Arm controls (UR5) -->
874
+ <div class="arm-controls" id="arm_controls">
875
+ <div class="control-group">
876
+ <label>Control Mode</label>
877
+ <div class="mode-toggle">
878
+ <button id="mode_ik" class="active" onclick="setControlMode('ik')">IK (XYZ Target)</button>
879
+ <button id="mode_joint" onclick="setControlMode('joint')">Direct Joints</button>
880
+ </div>
881
+ </div>
882
+
883
+ <!-- IK Controls -->
884
+ <div class="ik-controls active" id="ik_controls">
885
+ <div class="control-group">
886
+ <label>Translation (XYZ)</label>
887
+ <div class="jog-controls">
888
+ <div class="jog-row">
889
+ <label style="color: #ff6b6b; font-weight: bold;">X</label>
890
+ <button class="jog-btn" onmousedown="startJog('cartesian_translation', 'x', '-')"
891
+ onmouseup="stopJog()"
892
+ ontouchstart="startJog('cartesian_translation', 'x', '-'); event.preventDefault()"
893
+ ontouchend="stopJog()">−</button>
894
+ <span class="val-display" id="pos_x_val">0.00</span>
895
+ <button class="jog-btn" onmousedown="startJog('cartesian_translation', 'x', '+')"
896
+ onmouseup="stopJog()"
897
+ ontouchstart="startJog('cartesian_translation', 'x', '+'); event.preventDefault()"
898
+ ontouchend="stopJog()">+</button>
899
+ </div>
900
+ <div class="jog-row">
901
+ <label style="color: #51cf66; font-weight: bold;">Y</label>
902
+ <button class="jog-btn" onmousedown="startJog('cartesian_translation', 'y', '-')"
903
+ onmouseup="stopJog()"
904
+ ontouchstart="startJog('cartesian_translation', 'y', '-'); event.preventDefault()"
905
+ ontouchend="stopJog()">−</button>
906
+ <span class="val-display" id="pos_y_val">0.00</span>
907
+ <button class="jog-btn" onmousedown="startJog('cartesian_translation', 'y', '+')"
908
+ onmouseup="stopJog()"
909
+ ontouchstart="startJog('cartesian_translation', 'y', '+'); event.preventDefault()"
910
+ ontouchend="stopJog()">+</button>
911
+ </div>
912
+ <div class="jog-row">
913
+ <label style="color: #339af0; font-weight: bold;">Z</label>
914
+ <button class="jog-btn" onmousedown="startJog('cartesian_translation', 'z', '-')"
915
+ onmouseup="stopJog()"
916
+ ontouchstart="startJog('cartesian_translation', 'z', '-'); event.preventDefault()"
917
+ ontouchend="stopJog()">−</button>
918
+ <span class="val-display" id="pos_z_val">0.00</span>
919
+ <button class="jog-btn" onmousedown="startJog('cartesian_translation', 'z', '+')"
920
+ onmouseup="stopJog()"
921
+ ontouchstart="startJog('cartesian_translation', 'z', '+'); event.preventDefault()"
922
+ ontouchend="stopJog()">+</button>
923
+ </div>
924
+ </div>
925
+ <div style="margin-top: 8px;">
926
+ <label style="font-size: 0.85em;">Velocity: <span id="trans_vel_val">5</span> mm/s</label>
927
+ <input type="range" id="trans_velocity" min="1" max="100" step="1" value="5"
928
+ oninput="updateTransVelocity()" style="width: 100%;">
929
+ </div>
930
+ </div>
931
+ <div class="control-group">
932
+ <label>Rotation (RPY)</label>
933
+ <div class="jog-controls">
934
+ <div class="jog-row">
935
+ <label style="color: #ff6b6b; font-weight: bold;">Rx</label>
936
+ <button class="jog-btn" onmousedown="startJog('cartesian_rotation', 'x', '-')"
937
+ onmouseup="stopJog()"
938
+ ontouchstart="startJog('cartesian_rotation', 'x', '-'); event.preventDefault()"
939
+ ontouchend="stopJog()">−</button>
940
+ <span class="val-display" id="rot_x_val">0.00</span>
941
+ <button class="jog-btn" onmousedown="startJog('cartesian_rotation', 'x', '+')"
942
+ onmouseup="stopJog()"
943
+ ontouchstart="startJog('cartesian_rotation', 'x', '+'); event.preventDefault()"
944
+ ontouchend="stopJog()">+</button>
945
+ </div>
946
+ <div class="jog-row">
947
+ <label style="color: #51cf66; font-weight: bold;">Ry</label>
948
+ <button class="jog-btn" onmousedown="startJog('cartesian_rotation', 'y', '-')"
949
+ onmouseup="stopJog()"
950
+ ontouchstart="startJog('cartesian_rotation', 'y', '-'); event.preventDefault()"
951
+ ontouchend="stopJog()">−</button>
952
+ <span class="val-display" id="rot_y_val">0.00</span>
953
+ <button class="jog-btn" onmousedown="startJog('cartesian_rotation', 'y', '+')"
954
+ onmouseup="stopJog()"
955
+ ontouchstart="startJog('cartesian_rotation', 'y', '+'); event.preventDefault()"
956
+ ontouchend="stopJog()">+</button>
957
+ </div>
958
+ <div class="jog-row">
959
+ <label style="color: #339af0; font-weight: bold;">Rz</label>
960
+ <button class="jog-btn" onmousedown="startJog('cartesian_rotation', 'z', '-')"
961
+ onmouseup="stopJog()"
962
+ ontouchstart="startJog('cartesian_rotation', 'z', '-'); event.preventDefault()"
963
+ ontouchend="stopJog()">−</button>
964
+ <span class="val-display" id="rot_z_val">0.00</span>
965
+ <button class="jog-btn" onmousedown="startJog('cartesian_rotation', 'z', '+')"
966
+ onmouseup="stopJog()"
967
+ ontouchstart="startJog('cartesian_rotation', 'z', '+'); event.preventDefault()"
968
+ ontouchend="stopJog()">+</button>
969
+ </div>
970
+ </div>
971
+ <div style="margin-top: 8px;">
972
+ <label style="font-size: 0.85em;">Velocity: <span id="rot_vel_val">0.3</span> rad/s</label>
973
+ <input type="range" id="rot_velocity" min="0.1" max="1.0" step="0.1" value="0.3"
974
+ oninput="updateRotVelocity()" style="width: 100%;">
975
+ </div>
976
+ </div>
977
+ </div>
978
+
979
+ <!-- Joint Controls -->
980
+ <div class="joint-controls" id="joint_controls">
981
+ <div class="control-group">
982
+ <label>Joint Positions</label>
983
+ <div class="jog-controls">
984
+ <div class="jog-row">
985
+ <label>J1</label>
986
+ <button class="jog-btn" onmousedown="startJog('joint', 1, '-')" onmouseup="stopJog()"
987
+ ontouchstart="startJog('joint', 1, '-'); event.preventDefault()"
988
+ ontouchend="stopJog()">−</button>
989
+ <span class="val-display" id="joint_0_val">-1.57</span>
990
+ <button class="jog-btn" onmousedown="startJog('joint', 1, '+')" onmouseup="stopJog()"
991
+ ontouchstart="startJog('joint', 1, '+'); event.preventDefault()"
992
+ ontouchend="stopJog()">+</button>
993
+ </div>
994
+ <div class="jog-row">
995
+ <label>J2</label>
996
+ <button class="jog-btn" onmousedown="startJog('joint', 2, '-')" onmouseup="stopJog()"
997
+ ontouchstart="startJog('joint', 2, '-'); event.preventDefault()"
998
+ ontouchend="stopJog()">−</button>
999
+ <span class="val-display" id="joint_1_val">-1.57</span>
1000
+ <button class="jog-btn" onmousedown="startJog('joint', 2, '+')" onmouseup="stopJog()"
1001
+ ontouchstart="startJog('joint', 2, '+'); event.preventDefault()"
1002
+ ontouchend="stopJog()">+</button>
1003
+ </div>
1004
+ <div class="jog-row">
1005
+ <label>J3</label>
1006
+ <button class="jog-btn" onmousedown="startJog('joint', 3, '-')" onmouseup="stopJog()"
1007
+ ontouchstart="startJog('joint', 3, '-'); event.preventDefault()"
1008
+ ontouchend="stopJog()">−</button>
1009
+ <span class="val-display" id="joint_2_val">1.57</span>
1010
+ <button class="jog-btn" onmousedown="startJog('joint', 3, '+')" onmouseup="stopJog()"
1011
+ ontouchstart="startJog('joint', 3, '+'); event.preventDefault()"
1012
+ ontouchend="stopJog()">+</button>
1013
+ </div>
1014
+ <div class="jog-row">
1015
+ <label>J4</label>
1016
+ <button class="jog-btn" onmousedown="startJog('joint', 4, '-')" onmouseup="stopJog()"
1017
+ ontouchstart="startJog('joint', 4, '-'); event.preventDefault()"
1018
+ ontouchend="stopJog()">−</button>
1019
+ <span class="val-display" id="joint_3_val">-1.57</span>
1020
+ <button class="jog-btn" onmousedown="startJog('joint', 4, '+')" onmouseup="stopJog()"
1021
+ ontouchstart="startJog('joint', 4, '+'); event.preventDefault()"
1022
+ ontouchend="stopJog()">+</button>
1023
+ </div>
1024
+ <div class="jog-row">
1025
+ <label>J5</label>
1026
+ <button class="jog-btn" onmousedown="startJog('joint', 5, '-')" onmouseup="stopJog()"
1027
+ ontouchstart="startJog('joint', 5, '-'); event.preventDefault()"
1028
+ ontouchend="stopJog()">−</button>
1029
+ <span class="val-display" id="joint_4_val">-1.57</span>
1030
+ <button class="jog-btn" onmousedown="startJog('joint', 5, '+')" onmouseup="stopJog()"
1031
+ ontouchstart="startJog('joint', 5, '+'); event.preventDefault()"
1032
+ ontouchend="stopJog()">+</button>
1033
+ </div>
1034
+ <div class="jog-row">
1035
+ <label>J6</label>
1036
+ <button class="jog-btn" onmousedown="startJog('joint', 6, '-')" onmouseup="stopJog()"
1037
+ ontouchstart="startJog('joint', 6, '-'); event.preventDefault()"
1038
+ ontouchend="stopJog()">−</button>
1039
+ <span class="val-display" id="joint_5_val">0.00</span>
1040
+ <button class="jog-btn" onmousedown="startJog('joint', 6, '+')" onmouseup="stopJog()"
1041
+ ontouchstart="startJog('joint', 6, '+'); event.preventDefault()"
1042
+ ontouchend="stopJog()">+</button>
1043
+ </div>
1044
+ </div>
1045
+ <div style="margin-top: 8px;">
1046
+ <label style="font-size: 0.85em;">Velocity: <span id="joint_vel_val">0.5</span>
1047
+ rad/s</label>
1048
+ <input type="range" id="joint_velocity" min="0.1" max="2.0" step="0.1" value="0.5"
1049
+ oninput="updateJointVelocity()" style="width: 100%;">
1050
+ </div>
1051
+ </div>
1052
+ </div>
1053
+
1054
+ <div class="control-group" id="gripper_controls">
1055
+ <label>Gripper</label>
1056
+ <div class="gripper-btns">
1057
+ <button class="rl-btn" onclick="setGripper('open')">Open</button>
1058
+ <button class="rl-btn" onclick="setGripper('close')">Close</button>
1059
+ </div>
1060
+ </div>
1061
+ <div class="control-group">
1062
+ <label>Camera Distance</label>
1063
+ <div class="slider-row">
1064
+ <input type="range" id="cam_dist" min="1" max="10" step="0.1" value="3.0">
1065
+ <span class="val-display" id="cam_dist_val">3.0</span>
1066
+ </div>
1067
+ </div>
1068
+ <div class="control-group">
1069
+ <label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
1070
+ <input type="checkbox" id="cam_follow" checked onchange="setCameraFollow()">
1071
+ Camera Follow Robot
1072
+ </label>
1073
+ </div>
1074
+ </div>
1075
+
1076
+ <button class="danger" style="width: 100%; margin-top: 15px;" onclick="resetEnv()">Reset
1077
+ Environment</button>
1078
+ <button id="homeBtn" style="width: 100%; margin-top: 10px; display: none;" onmousedown="startHoming()"
1079
+ onmouseup="stopHoming()" onmouseleave="stopHoming()" ontouchstart="startHoming()"
1080
+ ontouchend="stopHoming()">🏠 Move to Home</button>
1081
+ </div>
1082
+ </div>
1083
+
1084
+ <div class="rl-notifications" id="rl_notifications_list"></div>
1085
+
1086
+ <div class="hint" id="hint_box" style="display: none;">
1087
+ Drag: Rotate Camera<br>
1088
+ Scroll: Zoom
1089
+ </div>
1090
+
1091
+ <script>
1092
+ const API_PREFIX = '""" + API_PREFIX + """';
1093
+ const WS_URL = (window.location.protocol === 'https:' ? 'wss://' : 'ws://') +
1094
+ window.location.host + API_PREFIX + '/ws';
1095
+
1096
+ let ws = null;
1097
+ let reconnectTimer = null;
1098
+ const connStatus = document.getElementById('conn_status');
1099
+ const connStatusText = document.getElementById('conn_status_text');
1100
+ const robotSelect = document.getElementById('robot_select');
1101
+ const sceneLabel = document.getElementById('scene_label');
1102
+ const overlayTiles = document.getElementById('overlay_tiles');
1103
+ const clientsStatusCard = document.getElementById('clients_status_card');
1104
+ const clientsStatusIndicator = document.getElementById('clients_status_indicator');
1105
+ const clientsStatusText = document.getElementById('clients_status_text');
1106
+ const clientsList = document.getElementById('clients_list');
1107
+ const viewportImage = document.querySelector('.video-container img');
1108
+ const robotTitle = document.getElementById('robot_title');
1109
+ const robotInfo = document.getElementById('robot_info');
1110
+ const metadataUrl = API_PREFIX + '/metadata';
1111
+ const envUrl = API_PREFIX + '/env';
1112
+ let metadataCache = null;
1113
+ let envCache = null;
1114
+ let pendingRobotSelection = null;
1115
+ let currentRobot = null;
1116
+ let currentScene = null;
1117
+ let novaStateStreaming = false; // Track if Nova state streaming is active
1118
+ let currentHomePose = null; // Store home pose from env data
1119
+ let currentJointPositions = null; // Store latest joint positions from state stream
1120
+ let prevNovaConnected = null; // Track previous Nova connection state for error detection
1121
+ function updateConnectionLabel(text) {
1122
+ if (connStatusText) {
1123
+ connStatusText.innerText = text;
1124
+ }
1125
+ }
1126
+
1127
+ function enterConnectingState() {
1128
+ updateConnectionLabel('Connecting...');
1129
+ if (connStatus) {
1130
+ connStatus.classList.add('connecting');
1131
+ connStatus.classList.remove('disconnected');
1132
+ }
1133
+ }
1134
+
1135
+ function markConnectedState() {
1136
+ updateConnectionLabel('Connected');
1137
+ if (connStatus) {
1138
+ connStatus.classList.remove('connecting', 'disconnected');
1139
+ }
1140
+ }
1141
+
1142
+ function refreshVideoStreams() {
1143
+ const timestamp = Date.now();
1144
+ if (viewportImage) {
1145
+ viewportImage.src = `${API_PREFIX}/video_feed?ts=${timestamp}`;
1146
+ }
1147
+ if (overlayTiles) {
1148
+ overlayTiles.querySelectorAll('img[data-feed]').forEach((img) => {
1149
+ const feedName = img.dataset.feed || 'main';
1150
+ img.src = `${API_PREFIX}/camera/${feedName}/video_feed?ts=${timestamp}`;
1151
+ });
1152
+ }
1153
+ }
1154
+ const novaToggleButton = document.getElementById('nova_toggle_button');
1155
+ let novaEnabledState = false;
1156
+ let novaManualToggle = false;
1157
+ let novaAutoEnableRequested = false;
1158
+ let novaPreconfigured = false;
1159
+ function setNovaToggleState(available, enabled) {
1160
+ if (!novaToggleButton) {
1161
+ return;
1162
+ }
1163
+ novaEnabledState = !!enabled;
1164
+ if (novaEnabledState) {
1165
+ novaAutoEnableRequested = false;
1166
+ }
1167
+ if (!available) {
1168
+ novaAutoEnableRequested = false;
1169
+ }
1170
+ novaToggleButton.style.display = available ? 'inline-flex' : 'none';
1171
+ novaToggleButton.disabled = !available;
1172
+ novaToggleButton.innerText = novaEnabledState ? 'Turn Nova Off' : 'Turn Nova On';
1173
+ }
1174
+ if (novaToggleButton) {
1175
+ novaToggleButton.addEventListener('click', () => {
1176
+ novaManualToggle = true;
1177
+ send('set_nova_mode', { enabled: !novaEnabledState });
1178
+ });
1179
+ }
1180
+ const NOVA_TRANSLATION_VELOCITY = 50.0;
1181
+ const NOVA_ROTATION_VELOCITY = 0.3;
1182
+ const NOVA_JOINT_VELOCITY = 0.5;
1183
+ let novaVelocitiesConfigured = false;
1184
+
1185
+ function updateConnectedClients(clients) {
1186
+ if (!clientsStatusCard || !clientsStatusText || !clientsList) {
1187
+ return;
1188
+ }
1189
+
1190
+ // Update text and list
1191
+ if (!clients || clients.length === 0) {
1192
+ clientsStatusText.innerText = "No clients connected";
1193
+ clientsList.innerHTML = "";
1194
+ clientsList.style.display = "none";
1195
+ } else {
1196
+ clientsStatusText.innerText = `${clients.length} client${clients.length > 1 ? 's' : ''} connected`;
1197
+ clientsList.innerHTML = clients.map(id => `<li>${id}</li>`).join('');
1198
+ clientsList.style.display = "block";
1199
+ }
1200
+ }
1201
+
1202
+ // Legacy function for backward compatibility with old state messages
1203
+ function updateTrainerStatus(connected) {
1204
+ // Deprecated: convert to new format
1205
+ updateConnectedClients(connected ? ["legacy-client"] : []);
1206
+ }
1207
+
1208
+ function configureNovaVelocities() {
1209
+ if (novaVelocitiesConfigured) {
1210
+ return;
1211
+ }
1212
+ const transSlider = document.getElementById('trans_velocity');
1213
+ const rotSlider = document.getElementById('rot_velocity');
1214
+ const jointSlider = document.getElementById('joint_velocity');
1215
+
1216
+ if (transSlider) {
1217
+ transSlider.value = NOVA_TRANSLATION_VELOCITY;
1218
+ updateTransVelocity();
1219
+ }
1220
+ if (rotSlider) {
1221
+ rotSlider.value = NOVA_ROTATION_VELOCITY;
1222
+ updateRotVelocity();
1223
+ }
1224
+ if (jointSlider) {
1225
+ jointSlider.value = NOVA_JOINT_VELOCITY;
1226
+ updateJointVelocity();
1227
+ }
1228
+ novaVelocitiesConfigured = true;
1229
+ }
1230
+ function refreshOverlayTiles() {
1231
+ if (!metadataCache) {
1232
+ return;
1233
+ }
1234
+ setupOverlayTiles();
1235
+ }
1236
+
1237
+ const robotInfoText = {
1238
+ 'g1': '29 DOF humanoid with RL walking policy',
1239
+ 'spot': '12 DOF quadruped with trot gait controller',
1240
+ 'ur5': '6 DOF robot arm with Robotiq gripper',
1241
+ 'ur5_t_push': 'UR5e T-push task with stick tool'
1242
+ };
1243
+
1244
+ const robotTitles = {
1245
+ 'g1': 'Unitree G1 Humanoid',
1246
+ 'spot': 'Boston Dynamics Spot',
1247
+ 'ur5': 'Universal Robots UR5e',
1248
+ 'ur5_t_push': 'UR5e T-Push Scene'
1249
+ };
1250
+
1251
+ const locomotionControls = document.getElementById('locomotion_controls');
1252
+ const armControls = document.getElementById('arm_controls');
1253
+ const notificationList = document.getElementById('rl_notifications_list');
1254
+
1255
+ const armRobotTypes = new Set(['ur5', 'ur5_t_push']);
1256
+ const armTeleopKeys = new Set(['KeyW', 'KeyA', 'KeyS', 'KeyD', 'KeyR', 'KeyF']);
1257
+ const locomotionKeys = new Set([
1258
+ 'KeyW', 'KeyA', 'KeyS', 'KeyD', 'KeyQ', 'KeyE',
1259
+ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'
1260
+ ]);
1261
+ function maybeAutoEnableNova() {
1262
+ if (!novaPreconfigured || novaManualToggle) {
1263
+ return;
1264
+ }
1265
+ const activeSelection = getActiveSelection();
1266
+ if (!armRobotTypes.has(activeSelection.robot)) {
1267
+ return;
1268
+ }
1269
+ if (novaEnabledState) {
1270
+ novaAutoEnableRequested = false;
1271
+ return;
1272
+ }
1273
+ if (novaAutoEnableRequested) {
1274
+ return;
1275
+ }
1276
+ if (ws && ws.readyState === WebSocket.OPEN) {
1277
+ send('set_nova_mode', { enabled: true });
1278
+ novaAutoEnableRequested = true;
1279
+ }
1280
+ }
1281
+ let teleopTranslationStep = 0.005; // meters per keyboard nudge
1282
+ let teleopVerticalStep = 0.01;
1283
+ const TELEOP_REPEAT_INTERVAL_MS = 80;
1284
+ const NOTIFICATION_DURATION_MS = 5000;
1285
+ let teleopRepeatTimer = null;
1286
+ let lastTeleopCommand = { dx: 0, dy: 0, dz: 0 };
1287
+ const armJogKeyMap = {
1288
+ KeyW: { jogType: 'cartesian_translation', axis: 'x', direction: '+' },
1289
+ KeyS: { jogType: 'cartesian_translation', axis: 'x', direction: '-' },
1290
+ KeyA: { jogType: 'cartesian_translation', axis: 'y', direction: '+' },
1291
+ KeyD: { jogType: 'cartesian_translation', axis: 'y', direction: '-' },
1292
+ KeyR: { jogType: 'cartesian_translation', axis: 'z', direction: '+' },
1293
+ KeyF: { jogType: 'cartesian_translation', axis: 'z', direction: '-' },
1294
+ };
1295
+
1296
+ function humanizeScene(scene) {
1297
+ if (!scene) {
1298
+ return 'Default';
1299
+ }
1300
+ const cleaned = scene.replace(/^scene_/, '').replace(/_/g, ' ');
1301
+ return cleaned.replace(/\\b\\w/g, (char) => char.toUpperCase());
1302
+ }
1303
+
1304
+ function buildSelectionValue(robot, scene) {
1305
+ return scene ? `${robot}::${scene}` : robot;
1306
+ }
1307
+
1308
+ function parseSelection(value) {
1309
+ if (!value) {
1310
+ return { robot: 'g1', scene: null };
1311
+ }
1312
+ const [robotPart, scenePart] = value.split('::');
1313
+ return { robot: robotPart, scene: scenePart || null };
1314
+ }
1315
+
1316
+ function createRobotSceneOption(robot, scene, label) {
1317
+ const option = document.createElement("option");
1318
+ option.value = buildSelectionValue(robot, scene);
1319
+ const sceneText = scene ? ` · ${humanizeScene(scene)}` : '';
1320
+ option.textContent = `${label || robot}${sceneText}`;
1321
+ return option;
1322
+ }
1323
+
1324
+ function getDefaultSelection(metadata) {
1325
+ if (!metadata) {
1326
+ return '';
1327
+ }
1328
+ const robots = Object.keys(metadata.robots);
1329
+ if (!robots.length) {
1330
+ return '';
1331
+ }
1332
+ const preferred = metadata.robots['ur5_t_push'] ? 'ur5_t_push' : robots[0];
1333
+ const preferredRobot = metadata.robots[preferred];
1334
+ const preferredScenes = preferredRobot && preferredRobot.scenes;
1335
+ const defaultScene = (metadata.default_scene && metadata.default_scene[preferred])
1336
+ || (preferredScenes && preferredScenes[0]) || '';
1337
+ return buildSelectionValue(preferred, defaultScene);
1338
+ }
1339
+
1340
+ function populateRobotOptions(metadata) {
1341
+ if (!metadata) {
1342
+ return null;
1343
+ }
1344
+ robotSelect.innerHTML = "";
1345
+ Object.entries(metadata.robots).forEach(([robot, meta]) => {
1346
+ const scenes = meta.scenes || [];
1347
+ if (scenes.length <= 1) {
1348
+ const scene = scenes[0] || "";
1349
+ robotSelect.appendChild(createRobotSceneOption(robot, scene, meta.label));
1350
+ } else {
1351
+ const group = document.createElement("optgroup");
1352
+ group.label = meta.label || robot;
1353
+ scenes.forEach((scene) => {
1354
+ group.appendChild(createRobotSceneOption(robot, scene, meta.label));
1355
+ });
1356
+ robotSelect.appendChild(group);
1357
+ }
1358
+ });
1359
+
1360
+ const defaultValue = getDefaultSelection(metadata);
1361
+ if (defaultValue) {
1362
+ robotSelect.value = defaultValue;
1363
+ const parsed = parseSelection(defaultValue);
1364
+ return parsed;
1365
+ }
1366
+ return null;
1367
+ }
1368
+
1369
+ async function setupOverlayTiles() {
1370
+ if (!overlayTiles) {
1371
+ return;
1372
+ }
1373
+ if (!envCache) {
1374
+ overlayTiles.innerHTML = "";
1375
+ overlayTiles.dataset.overlayKey = "";
1376
+ overlayTiles.style.display = 'none';
1377
+ return;
1378
+ }
1379
+ // Get overlay camera feeds from envCache (excludes main camera)
1380
+ const allFeeds = envCache.camera_feeds || [];
1381
+ const overlayFeeds = allFeeds.filter(feed => feed.name !== 'main');
1382
+
1383
+ if (!overlayFeeds.length) {
1384
+ overlayTiles.innerHTML = "";
1385
+ overlayTiles.dataset.overlayKey = "";
1386
+ overlayTiles.style.display = 'none';
1387
+ return;
1388
+ }
1389
+ const feedNames = overlayFeeds.map((feed) => feed.name || "aux");
1390
+ const key = `${envCache.robot || ''}|${envCache.scene || ''}|${feedNames.join(',')}`;
1391
+ if (overlayTiles.dataset.overlayKey === key) {
1392
+ overlayTiles.style.display = 'flex';
1393
+ return;
1394
+ }
1395
+ overlayTiles.dataset.overlayKey = key;
1396
+ overlayTiles.innerHTML = "";
1397
+ overlayTiles.style.display = 'flex';
1398
+ overlayFeeds.forEach((feed) => {
1399
+ const tile = document.createElement("div");
1400
+ tile.className = "overlay-tile";
1401
+ const img = document.createElement("img");
1402
+ const feedName = feed.name || "aux";
1403
+ img.dataset.feed = feedName;
1404
+ img.src = feed.url + `?ts=${Date.now()}`;
1405
+ tile.appendChild(img);
1406
+ const label = document.createElement("div");
1407
+ label.className = "overlay-label";
1408
+ label.innerText = feed.label || feed.name;
1409
+ tile.appendChild(label);
1410
+ overlayTiles.appendChild(tile);
1411
+ });
1412
+ }
1413
+
1414
+ async function loadMetadata() {
1415
+ try {
1416
+ const resp = await fetch(metadataUrl);
1417
+ if (!resp.ok) {
1418
+ console.warn("Failed to load metadata");
1419
+ return;
1420
+ }
1421
+ metadataCache = await resp.json();
1422
+ const selection = populateRobotOptions(metadataCache);
1423
+ if (selection) {
1424
+ updateRobotUI(selection.robot, selection.scene);
1425
+ refreshOverlayTiles();
1426
+ }
1427
+ const novaInfo = metadataCache && metadataCache.nova_api;
1428
+ novaPreconfigured = Boolean(novaInfo && novaInfo.preconfigured);
1429
+ setNovaToggleState(!!(novaInfo && novaInfo.preconfigured), !!(novaInfo && novaInfo.preconfigured));
1430
+ maybeAutoEnableNova();
1431
+ if (ws && ws.readyState === WebSocket.OPEN && pendingRobotSelection) {
1432
+ send('switch_robot', {
1433
+ robot: pendingRobotSelection.robot,
1434
+ scene: pendingRobotSelection.scene
1435
+ });
1436
+ }
1437
+ } catch (error) {
1438
+ console.warn("Metadata fetch error:", error);
1439
+ }
1440
+ }
1441
+
1442
+ async function loadEnvData() {
1443
+ try {
1444
+ const resp = await fetch(envUrl);
1445
+ if (!resp.ok) {
1446
+ console.warn("Failed to load env data, status:", resp.status);
1447
+ return;
1448
+ }
1449
+ envCache = await resp.json();
1450
+ // Store home_pose if available
1451
+ if (envCache && envCache.home_pose) {
1452
+ currentHomePose = envCache.home_pose;
1453
+ }
1454
+ } catch (error) {
1455
+ console.error("Env fetch error:", error);
1456
+ }
1457
+ }
1458
+
1459
+ function connect() {
1460
+ ws = new WebSocket(WS_URL);
1461
+
1462
+ loadMetadata();
1463
+ loadEnvData();
1464
+
1465
+ ws.onopen = () => {
1466
+ markConnectedState();
1467
+ refreshVideoStreams();
1468
+ refreshOverlayTiles();
1469
+ novaAutoEnableRequested = false;
1470
+ maybeAutoEnableNova();
1471
+ if (reconnectTimer) {
1472
+ clearInterval(reconnectTimer);
1473
+ reconnectTimer = null;
1474
+ }
1475
+ if (pendingRobotSelection) {
1476
+ send('switch_robot', {
1477
+ robot: pendingRobotSelection.robot,
1478
+ scene: pendingRobotSelection.scene
1479
+ });
1480
+ }
1481
+ };
1482
+
1483
+ ws.onclose = () => {
1484
+ enterConnectingState();
1485
+ // Auto-reconnect
1486
+ if (!reconnectTimer) {
1487
+ reconnectTimer = setInterval(() => {
1488
+ if (ws.readyState === WebSocket.CLOSED) {
1489
+ connect();
1490
+ }
1491
+ }, 2000);
1492
+ }
1493
+ };
1494
+
1495
+ ws.onerror = (err) => {
1496
+ console.error('WebSocket error:', err);
1497
+ };
1498
+
1499
+ ws.onmessage = (event) => {
1500
+ try {
1501
+ const msg = JSON.parse(event.data);
1502
+ if (msg.type === 'state') {
1503
+ const data = msg.data;
1504
+
1505
+ // Check if robot or scene changed in state stream
1506
+ if (data.robot && data.robot !== currentRobot) {
1507
+ currentRobot = data.robot;
1508
+ updateRobotUI(data.robot, data.scene);
1509
+ // Fetch new environment info only when robot actually changes
1510
+ fetch('/nova-sim/api/v1/env')
1511
+ .then(r => r.json())
1512
+ .then(envData => {
1513
+ has_gripper = envData.has_gripper || false;
1514
+ control_mode = envData.control_mode || 'ik';
1515
+ if (envData.home_pose) {
1516
+ currentHomePose = envData.home_pose;
1517
+ }
1518
+ });
1519
+ }
1520
+ if (data.scene && data.scene !== currentScene) {
1521
+ currentScene = data.scene;
1522
+ }
1523
+
1524
+ // Handle both new and legacy client status formats
1525
+ if (data.connected_clients && Array.isArray(data.connected_clients)) {
1526
+ updateConnectedClients(data.connected_clients);
1527
+ } else if (typeof data.trainer_connected === 'boolean') {
1528
+ // Legacy format
1529
+ updateTrainerStatus(data.trainer_connected);
1530
+ }
1531
+
1532
+ if (currentRobot === 'ur5' || currentRobot === 'ur5_t_push') {
1533
+ // UR5 state - Access observation data first
1534
+ const obs = data.observation || {};
1535
+
1536
+ // Update end effector position
1537
+ if (obs.end_effector) {
1538
+ const ee = obs.end_effector;
1539
+ document.getElementById('ee_pos').innerText =
1540
+ ee.x.toFixed(2) + ', ' + ee.y.toFixed(2) + ', ' + ee.z.toFixed(2);
1541
+ }
1542
+
1543
+ // EE Orientation - convert quaternion to euler for display
1544
+ if (obs.ee_orientation) {
1545
+ const q = obs.ee_orientation;
1546
+ const euler = quatToEuler(q.w, q.x, q.y, q.z);
1547
+ document.getElementById('ee_ori').innerText =
1548
+ euler[0].toFixed(2) + ', ' + euler[1].toFixed(2) + ', ' + euler[2].toFixed(2);
1549
+ }
1550
+
1551
+ // Show/hide gripper UI based on has_gripper
1552
+ const gripperStateDisplay = document.getElementById('gripper_state_display');
1553
+ const gripperControls = document.getElementById('gripper_controls');
1554
+ if (data.has_gripper) {
1555
+ gripperStateDisplay.style.display = '';
1556
+ gripperControls.style.display = '';
1557
+ // Gripper: 0=open, 255=closed (Robotiq 2F-85)
1558
+ if (obs.gripper !== undefined) {
1559
+ document.getElementById('gripper_val').innerText =
1560
+ ((255 - obs.gripper) / 255 * 100).toFixed(0) + '% open';
1561
+ }
1562
+ } else {
1563
+ gripperStateDisplay.style.display = 'none';
1564
+ gripperControls.style.display = 'none';
1565
+ }
1566
+
1567
+ document.getElementById('arm_step_val').innerText = data.steps;
1568
+ const rewardEl = document.getElementById('arm_reward');
1569
+ if (data.reward === null || data.reward === undefined) {
1570
+ rewardEl.innerText = '-';
1571
+ } else {
1572
+ rewardEl.innerText = data.reward.toFixed(3);
1573
+ }
1574
+
1575
+ // Update joint position display (actual positions)
1576
+ if (obs.joint_positions) {
1577
+ const jp = obs.joint_positions;
1578
+ currentJointPositions = jp; // Store for homing
1579
+ const jointPosDisplay = document.getElementById('joint_pos_display');
1580
+ if (jointPosDisplay) {
1581
+ jointPosDisplay.innerText = jp.map(j => j.toFixed(2)).join(', ');
1582
+ }
1583
+
1584
+ // Update jog button displays with actual joint positions
1585
+ for (let i = 0; i < 6; i++) {
1586
+ const el = document.getElementById('joint_' + i + '_val');
1587
+ if (el) {
1588
+ el.innerText = jp[i].toFixed(2);
1589
+ }
1590
+ }
1591
+
1592
+ }
1593
+
1594
+ // Update cartesian position displays with actual EE position
1595
+ if (obs.end_effector) {
1596
+ const ee = obs.end_effector;
1597
+ const posXEl = document.getElementById('pos_x_val');
1598
+ const posYEl = document.getElementById('pos_y_val');
1599
+ const posZEl = document.getElementById('pos_z_val');
1600
+
1601
+ if (posXEl) posXEl.innerText = ee.x.toFixed(3);
1602
+ if (posYEl) posYEl.innerText = ee.y.toFixed(3);
1603
+ if (posZEl) posZEl.innerText = ee.z.toFixed(3);
1604
+ }
1605
+
1606
+ // Update rotation displays with actual EE orientation
1607
+ if (obs.ee_orientation) {
1608
+ const q = obs.ee_orientation;
1609
+ const euler = quatToEuler(q.w, q.x, q.y, q.z);
1610
+ document.getElementById('rot_x_val').innerText = euler[0].toFixed(2);
1611
+ document.getElementById('rot_y_val').innerText = euler[1].toFixed(2);
1612
+ document.getElementById('rot_z_val').innerText = euler[2].toFixed(2);
1613
+ }
1614
+
1615
+ // Update control mode display
1616
+ if (data.control_mode) {
1617
+ document.getElementById('control_mode_display').innerText =
1618
+ data.control_mode === 'ik' ? 'IK' : 'Joint';
1619
+ // Sync UI if mode changed externally
1620
+ if (currentControlMode !== data.control_mode) {
1621
+ setControlMode(data.control_mode);
1622
+ }
1623
+ }
1624
+
1625
+ // Update Nova API controller status
1626
+ if (data.nova_api) {
1627
+ const stateController = document.getElementById('nova_state_controller');
1628
+ const ikController = document.getElementById('nova_ik_controller');
1629
+ const badge = document.getElementById('nova_connection_badge');
1630
+ const badgeTitle = document.getElementById('nova_badge_title');
1631
+ const modeText = document.getElementById('nova_mode_text');
1632
+ setNovaToggleState(data.nova_api.available, data.nova_api.enabled);
1633
+
1634
+ // Track Nova state streaming status
1635
+ novaStateStreaming = data.nova_api.state_streaming || false;
1636
+
1637
+ // Detect Nova connection errors and show toast notifications
1638
+ const currentNovaConnected = data.nova_api.connected;
1639
+ if (prevNovaConnected !== null && prevNovaConnected !== currentNovaConnected) {
1640
+ if (!currentNovaConnected && data.nova_api.enabled) {
1641
+ // Connection was lost or failed to connect while Nova is enabled
1642
+ showClientNotification({
1643
+ message: 'Nova API connection lost. Check that Nova is running and accessible.',
1644
+ status: 'error'
1645
+ });
1646
+ } else if (currentNovaConnected && data.nova_api.enabled) {
1647
+ // Connection was restored
1648
+ showClientNotification({
1649
+ message: 'Nova API connection established.',
1650
+ status: 'info'
1651
+ });
1652
+ }
1653
+ }
1654
+ // Also check for enabled but not connected state (initial error)
1655
+ if (prevNovaConnected === null && data.nova_api.enabled && !currentNovaConnected) {
1656
+ showClientNotification({
1657
+ message: 'Nova API is enabled but not connected. Waiting for Nova to be ready...',
1658
+ status: 'warning'
1659
+ });
1660
+ }
1661
+ prevNovaConnected = currentNovaConnected;
1662
+
1663
+ if (data.nova_api.available) {
1664
+ badge.style.display = 'block';
1665
+ } else {
1666
+ badge.style.display = 'none';
1667
+ }
1668
+ badge.classList.toggle('connected', data.nova_api.enabled);
1669
+ badge.classList.toggle('disconnected', !data.nova_api.enabled);
1670
+
1671
+ const connectedColor = data.nova_api.connected ? 'var(--wb-success)' : 'var(--wb-highlight)';
1672
+ if (stateController) {
1673
+ stateController.innerText = data.nova_api.state_streaming ? 'Nova API' : 'Internal';
1674
+ stateController.style.color = connectedColor;
1675
+ }
1676
+ if (ikController) {
1677
+ ikController.innerText = data.nova_api.ik ? 'Nova API' : 'Internal';
1678
+ ikController.style.color = data.nova_api.ik ? 'var(--wb-success)' : 'var(--wb-highlight)';
1679
+ }
1680
+
1681
+ if (badgeTitle) {
1682
+ if (data.nova_api.enabled) {
1683
+ badgeTitle.innerText = data.nova_api.connected ? '🌐 Nova Connected' : '🌐 Nova Powering Up';
1684
+ } else {
1685
+ badgeTitle.innerText = '🌐 Nova Disabled';
1686
+ }
1687
+ }
1688
+
1689
+ if (modeText) {
1690
+ if (data.nova_api.connected) {
1691
+ if (data.nova_api.state_streaming && data.nova_api.ik) {
1692
+ modeText.innerText = 'Hybrid Mode';
1693
+ } else if (data.nova_api.state_streaming) {
1694
+ modeText.innerText = 'Digital Twin Mode';
1695
+ } else if (data.nova_api.ik) {
1696
+ modeText.innerText = 'Nova IK Mode';
1697
+ }
1698
+ } else if (data.nova_api.enabled) {
1699
+ modeText.innerText = 'Awaiting connection';
1700
+ } else {
1701
+ modeText.innerText = 'Internal control';
1702
+ }
1703
+ }
1704
+ }
1705
+
1706
+ // Update teleop command display - only show non-zero values
1707
+ const armTeleop = data.teleop_action;
1708
+ const armTeleopDisplayEl = document.getElementById('arm_teleop_display');
1709
+ if (armTeleop && armTeleopDisplayEl) {
1710
+ const parts = [];
1711
+ // Check all possible teleop fields
1712
+ const fields = {
1713
+ 'vx': armTeleop.vx,
1714
+ 'vy': armTeleop.vy,
1715
+ 'vz': armTeleop.vz,
1716
+ 'vyaw': armTeleop.vyaw,
1717
+ 'vrx': armTeleop.vrx,
1718
+ 'vry': armTeleop.vry,
1719
+ 'vrz': armTeleop.vrz,
1720
+ 'j1': armTeleop.j1,
1721
+ 'j2': armTeleop.j2,
1722
+ 'j3': armTeleop.j3,
1723
+ 'j4': armTeleop.j4,
1724
+ 'j5': armTeleop.j5,
1725
+ 'j6': armTeleop.j6,
1726
+ 'g': armTeleop.gripper
1727
+ };
1728
+
1729
+ for (const [key, value] of Object.entries(fields)) {
1730
+ const numVal = Number(value ?? 0);
1731
+ if (numVal !== 0) {
1732
+ if (key === 'g') {
1733
+ parts.push(`${key}=${numVal.toFixed(0)}`);
1734
+ } else {
1735
+ parts.push(`${key}=${numVal.toFixed(2)}`);
1736
+ }
1737
+ }
1738
+ }
1739
+
1740
+ armTeleopDisplayEl.innerText = parts.length > 0 ? parts.join(', ') : '-';
1741
+ }
1742
+ } else {
1743
+ // Locomotion state
1744
+ const locoObs = data.observation || {};
1745
+
1746
+ // Update position display
1747
+ const locoPos = document.getElementById('loco_pos');
1748
+ if (locoObs.position) {
1749
+ const p = locoObs.position;
1750
+ locoPos.innerText = `${p.x.toFixed(2)}, ${p.y.toFixed(2)}, ${p.z.toFixed(2)}`;
1751
+ }
1752
+
1753
+ // Update orientation display (convert quaternion to euler)
1754
+ const locoOri = document.getElementById('loco_ori');
1755
+ if (locoObs.orientation) {
1756
+ const q = locoObs.orientation;
1757
+ const euler = quatToEuler(q.w, q.x, q.y, q.z);
1758
+ locoOri.innerText = `${euler[0].toFixed(2)}, ${euler[1].toFixed(2)}, ${euler[2].toFixed(2)}`;
1759
+ }
1760
+
1761
+ // Update teleop command display - only show non-zero values
1762
+ const locoTeleop = data.teleop_action || {};
1763
+ const locoTeleopDisplayEl = document.getElementById('loco_teleop_display');
1764
+ if (locoTeleopDisplayEl) {
1765
+ const parts = [];
1766
+ const fields = {
1767
+ 'vx': locoTeleop.vx,
1768
+ 'vy': locoTeleop.vy,
1769
+ 'vyaw': locoTeleop.vyaw
1770
+ };
1771
+
1772
+ for (const [key, value] of Object.entries(fields)) {
1773
+ const numVal = Number(value ?? 0);
1774
+ if (numVal !== 0) {
1775
+ parts.push(`${key}=${numVal.toFixed(2)}`);
1776
+ }
1777
+ }
1778
+
1779
+ locoTeleopDisplayEl.innerText = parts.length > 0 ? parts.join(', ') : '-';
1780
+ }
1781
+
1782
+ stepVal.innerText = data.steps;
1783
+ }
1784
+ } else if (msg.type === 'connected_clients') {
1785
+ const payload = msg.data || {};
1786
+ if (payload.clients && Array.isArray(payload.clients)) {
1787
+ updateConnectedClients(payload.clients);
1788
+ }
1789
+ } else if (msg.type === 'trainer_status') {
1790
+ // Legacy support
1791
+ const payload = msg.data || {};
1792
+ if (typeof payload.connected === 'boolean') {
1793
+ updateTrainerStatus(payload.connected);
1794
+ }
1795
+ } else if (msg.type === 'client_notification' || msg.type === 'trainer_notification') {
1796
+ showClientNotification(msg.data);
1797
+ }
1798
+ } catch (e) {
1799
+ console.error('Error parsing message:', e);
1800
+ }
1801
+ };
1802
+ }
1803
+
1804
+ function send(type, data = {}) {
1805
+ if (ws && ws.readyState === WebSocket.OPEN) {
1806
+ const msg = { type, data };
1807
+ ws.send(JSON.stringify(msg));
1808
+ } else {
1809
+ console.warn('Cannot send message, WebSocket not ready:', {
1810
+ type,
1811
+ hasWs: !!ws,
1812
+ wsState: ws ? ws.readyState : 'no ws'
1813
+ });
1814
+ }
1815
+ }
1816
+
1817
+ function requestEpisodeControl(action) {
1818
+ if (!action) {
1819
+ return;
1820
+ }
1821
+ send('episode_control', { action });
1822
+ }
1823
+
1824
+ function showClientNotification(payload) {
1825
+ if (!notificationList || !payload) {
1826
+ return;
1827
+ }
1828
+ const entry = document.createElement('div');
1829
+ entry.className = 'notification';
1830
+ entry.style.opacity = '0.9';
1831
+ const when = payload.timestamp ? new Date(payload.timestamp * 1000).toLocaleTimeString() : new Date().toLocaleTimeString();
1832
+ const status = payload.status ? payload.status.toUpperCase() : 'INFO';
1833
+ const message = payload.message || payload.text || payload.detail || 'Notification';
1834
+ entry.textContent = `[${when}] ${status}: ${message}`;
1835
+ notificationList.insertBefore(entry, notificationList.firstChild);
1836
+ setTimeout(() => {
1837
+ if (entry.parentNode) {
1838
+ entry.parentNode.removeChild(entry);
1839
+ }
1840
+ }, NOTIFICATION_DURATION_MS);
1841
+ while (notificationList.childElementCount > 5) {
1842
+ notificationList.removeChild(notificationList.lastChild);
1843
+ }
1844
+ }
1845
+
1846
+ // Convert quaternion to euler angles (XYZ convention)
1847
+ function quatToEuler(w, x, y, z) {
1848
+ // Roll (x-axis rotation)
1849
+ const sinr_cosp = 2 * (w * x + y * z);
1850
+ const cosr_cosp = 1 - 2 * (x * x + y * y);
1851
+ const roll = Math.atan2(sinr_cosp, cosr_cosp);
1852
+
1853
+ // Pitch (y-axis rotation)
1854
+ const sinp = 2 * (w * y - z * x);
1855
+ let pitch;
1856
+ if (Math.abs(sinp) >= 1) {
1857
+ pitch = Math.sign(sinp) * Math.PI / 2;
1858
+ } else {
1859
+ pitch = Math.asin(sinp);
1860
+ }
1861
+
1862
+ // Yaw (z-axis rotation)
1863
+ const siny_cosp = 2 * (w * z + x * y);
1864
+ const cosy_cosp = 1 - 2 * (y * y + z * z);
1865
+ const yaw = Math.atan2(siny_cosp, cosy_cosp);
1866
+
1867
+ return [roll, pitch, yaw];
1868
+ }
1869
+
1870
+ function updateHintBox(robotType) {
1871
+ const hintBox = document.getElementById('hint_box');
1872
+ if (!hintBox) return;
1873
+
1874
+ if (robotType === 'arm') {
1875
+ hintBox.innerHTML = `
1876
+ Drag: Rotate Camera<br>
1877
+ Scroll: Zoom<br>
1878
+ <strong>Keyboard:</strong><br>
1879
+ W/A/S/D: XY jog<br>
1880
+ R/F: Z nudge<br>
1881
+ Enter: Move to Home
1882
+ `;
1883
+ } else {
1884
+ hintBox.innerHTML = `
1885
+ Drag: Rotate Camera<br>
1886
+ Scroll: Zoom<br>
1887
+ <strong>Keyboard:</strong><br>
1888
+ W/S: Forward/Back<br>
1889
+ A/D: Turn<br>
1890
+ Q/E: Strafe
1891
+ `;
1892
+ }
1893
+ }
1894
+
1895
+ function updateRobotUI(robot, scene = null) {
1896
+ currentRobot = robot;
1897
+ currentScene = scene;
1898
+ robotTitle.innerText = robotTitles[robot] || robot;
1899
+ robotInfo.innerText = robotInfoText[robot] || '';
1900
+ if (sceneLabel) {
1901
+ sceneLabel.innerText = humanizeScene(scene);
1902
+ }
1903
+ // Toggle controls based on robot type
1904
+ if (robot === 'ur5' || robot === 'ur5_t_push') {
1905
+ locomotionControls.classList.add('hidden');
1906
+ armControls.classList.add('active');
1907
+ document.getElementById('locomotion_state').style.display = 'none';
1908
+ document.getElementById('arm_state').style.display = 'block';
1909
+ document.getElementById('arm_hints').style.display = 'block';
1910
+ document.getElementById('loco_hints').style.display = 'none';
1911
+ // Update hint box for ARM robots
1912
+ updateHintBox('arm');
1913
+ // Load env data and show home button only if home_pose is available
1914
+ loadEnvData().then(() => {
1915
+ setupOverlayTiles();
1916
+ // Show home button only if home_pose was loaded
1917
+ const homeBtn = document.getElementById('homeBtn');
1918
+ if (homeBtn) {
1919
+ if (currentHomePose && currentHomePose.length > 0) {
1920
+ homeBtn.style.display = 'block';
1921
+ } else {
1922
+ homeBtn.style.display = 'none';
1923
+ console.warn('Home button hidden: no home_pose available');
1924
+ }
1925
+ }
1926
+ });
1927
+ } else {
1928
+ locomotionControls.classList.remove('hidden');
1929
+ armControls.classList.remove('active');
1930
+ document.getElementById('locomotion_state').style.display = 'block';
1931
+ document.getElementById('arm_state').style.display = 'none';
1932
+ document.getElementById('arm_hints').style.display = 'none';
1933
+ document.getElementById('loco_hints').style.display = 'block';
1934
+ // Hide home button for non-UR5 robots
1935
+ const homeBtn = document.getElementById('homeBtn');
1936
+ if (homeBtn) homeBtn.style.display = 'none';
1937
+ // Update hint box for locomotion robots
1938
+ updateHintBox('locomotion');
1939
+ }
1940
+ maybeAutoEnableNova();
1941
+ }
1942
+
1943
+ let panelCollapsed = false;
1944
+ function togglePanel() {
1945
+ panelCollapsed = !panelCollapsed;
1946
+ const content = document.getElementById('panel_content');
1947
+ const header = document.getElementById('panel_header');
1948
+ const panel = document.getElementById('control_panel');
1949
+ if (panelCollapsed) {
1950
+ content.classList.add('collapsed');
1951
+ panel.classList.add('collapsed');
1952
+ header.classList.add('collapsed');
1953
+ } else {
1954
+ content.classList.remove('collapsed');
1955
+ panel.classList.remove('collapsed');
1956
+ header.classList.remove('collapsed');
1957
+ }
1958
+ }
1959
+
1960
+ function switchRobot() {
1961
+ const selectionValue = robotSelect.value;
1962
+ if (!selectionValue) {
1963
+ return;
1964
+ }
1965
+ const parsed = parseSelection(selectionValue);
1966
+ pendingRobotSelection = {
1967
+ value: selectionValue,
1968
+ robot: parsed.robot,
1969
+ scene: parsed.scene
1970
+ };
1971
+ send('switch_robot', { robot: parsed.robot, scene: parsed.scene });
1972
+ updateRobotUI(parsed.robot, parsed.scene);
1973
+ }
1974
+
1975
+ const viewport = document.getElementById('viewport');
1976
+ const stepVal = document.getElementById('step_val');
1977
+ const camDist = document.getElementById('cam_dist');
1978
+ const camDistVal = document.getElementById('cam_dist_val');
1979
+ const armTeleopVx = document.getElementById('arm_teleop_vx');
1980
+ const armTeleopVy = document.getElementById('arm_teleop_vy');
1981
+ const armTeleopVz = document.getElementById('arm_teleop_vz');
1982
+
1983
+ let keysPressed = new Set();
1984
+
1985
+ function resetEnv() {
1986
+ send('reset');
1987
+ }
1988
+
1989
+ // Homing functionality for UR5
1990
+ let homingInterval = null;
1991
+ let homingJoints = []; // Joints being homed
1992
+
1993
+ function startHoming() {
1994
+ if (currentRobot !== 'ur5' && currentRobot !== 'ur5_t_push') {
1995
+ return;
1996
+ }
1997
+
1998
+ // Check if we have necessary data
1999
+ if (!currentHomePose) {
2000
+ return;
2001
+ }
2002
+
2003
+ // Send home messages continuously while button is pressed
2004
+ send('home', {});
2005
+
2006
+ // Send home message every 100ms while button is pressed
2007
+ if (homingInterval) clearInterval(homingInterval);
2008
+ homingInterval = setInterval(() => {
2009
+ send('home', {});
2010
+ }, 100);
2011
+ }
2012
+
2013
+ function stopHoming() {
2014
+ // Stop the home message interval
2015
+ if (homingInterval) {
2016
+ clearInterval(homingInterval);
2017
+ homingInterval = null;
2018
+ }
2019
+ // Send stop_home message to backend
2020
+ send('stop_home', {});
2021
+ }
2022
+
2023
+ function setCmd(vx, vy, vyaw) {
2024
+ send('command', { vx, vy, vyaw });
2025
+ }
2026
+
2027
+ function setCameraFollow() {
2028
+ const follow = document.getElementById('cam_follow').checked;
2029
+ send('camera_follow', { follow });
2030
+ }
2031
+
2032
+ // UR5 controls
2033
+ let currentControlMode = 'ik';
2034
+
2035
+ function setControlMode(mode) {
2036
+ currentControlMode = mode;
2037
+ send('control_mode', { mode });
2038
+
2039
+ // Update UI
2040
+ document.getElementById('mode_ik').classList.toggle('active', mode === 'ik');
2041
+ document.getElementById('mode_joint').classList.toggle('active', mode === 'joint');
2042
+ document.getElementById('ik_controls').classList.toggle('active', mode === 'ik');
2043
+ document.getElementById('joint_controls').classList.toggle('active', mode === 'joint');
2044
+ }
2045
+
2046
+ // Jogging velocities
2047
+ let transVelocity = 50.0; // mm/s
2048
+ let rotVelocity = 0.3; // rad/s
2049
+ let jointVelocity = 0.5; // rad/s
2050
+
2051
+ function updateTransVelocity() {
2052
+ transVelocity = parseFloat(document.getElementById('trans_velocity').value);
2053
+ document.getElementById('trans_vel_val').innerText = transVelocity.toFixed(0);
2054
+ teleopTranslationStep = transVelocity / 1000;
2055
+ }
2056
+
2057
+ function updateRotVelocity() {
2058
+ rotVelocity = parseFloat(document.getElementById('rot_velocity').value);
2059
+ document.getElementById('rot_vel_val').innerText = rotVelocity.toFixed(1);
2060
+ }
2061
+
2062
+ function updateJointVelocity() {
2063
+ jointVelocity = parseFloat(document.getElementById('joint_velocity').value);
2064
+ document.getElementById('joint_vel_val').innerText = jointVelocity.toFixed(1);
2065
+ }
2066
+
2067
+ function startJog(jogType, axisOrJoint, direction) {
2068
+ const actionData = {};
2069
+
2070
+ if (jogType === 'cartesian_translation') {
2071
+ const velocity = (direction === '+' ? 1 : -1) * (transVelocity / 1000.0); // Convert mm/s to m/s
2072
+ if (axisOrJoint === 'x') actionData.vx = velocity;
2073
+ else if (axisOrJoint === 'y') actionData.vy = velocity;
2074
+ else if (axisOrJoint === 'z') actionData.vz = velocity;
2075
+ } else if (jogType === 'cartesian_rotation') {
2076
+ const velocity = (direction === '+' ? 1 : -1) * rotVelocity;
2077
+ if (axisOrJoint === 'x') actionData.vrx = velocity;
2078
+ else if (axisOrJoint === 'y') actionData.vry = velocity;
2079
+ else if (axisOrJoint === 'z') actionData.vrz = velocity;
2080
+ } else if (jogType === 'joint') {
2081
+ const velocity = (direction === '+' ? 1 : -1) * jointVelocity;
2082
+ actionData['j' + axisOrJoint] = velocity; // j1-j6
2083
+ }
2084
+
2085
+ send('action', actionData);
2086
+ }
2087
+
2088
+ function stopJog() {
2089
+ send('action', {}); // Send zero velocities
2090
+ }
2091
+
2092
+ function setGripper(action) {
2093
+ send('gripper', { action });
2094
+ }
2095
+
2096
+ function updateCmdFromKeys() {
2097
+ let vx = 0, vy = 0, vyaw = 0;
2098
+ if (keysPressed.has('KeyW') || keysPressed.has('ArrowUp')) vx = 0.8;
2099
+ if (keysPressed.has('KeyS') || keysPressed.has('ArrowDown')) vx = -0.5;
2100
+ if (keysPressed.has('KeyA')) vyaw = 1.2;
2101
+ if (keysPressed.has('KeyD')) vyaw = -1.2;
2102
+ if (keysPressed.has('KeyQ')) vy = 0.4;
2103
+ if (keysPressed.has('KeyE')) vy = -0.4;
2104
+ if (keysPressed.has('ArrowLeft')) vyaw = 1.2;
2105
+ if (keysPressed.has('ArrowRight')) vyaw = -1.2;
2106
+ setCmd(vx, vy, vyaw);
2107
+ }
2108
+
2109
+ function getActiveSelection() {
2110
+ return parseSelection(robotSelect.value);
2111
+ }
2112
+
2113
+ function isArmRobot() {
2114
+ const active = getActiveSelection();
2115
+ return armRobotTypes.has(active.robot);
2116
+ }
2117
+
2118
+ function shouldCaptureKey(code) {
2119
+ const active = getActiveSelection();
2120
+ return armRobotTypes.has(active.robot) ? armTeleopKeys.has(code) : locomotionKeys.has(code);
2121
+ }
2122
+
2123
+ function hasActiveTeleopKeys() {
2124
+ const keySet = isArmRobot() ? armTeleopKeys : locomotionKeys;
2125
+ for (const key of keySet) {
2126
+ if (keysPressed.has(key)) {
2127
+ return true;
2128
+ }
2129
+ }
2130
+ return false;
2131
+ }
2132
+
2133
+ function startTeleopRepeat() {
2134
+ if (teleopRepeatTimer) {
2135
+ return;
2136
+ }
2137
+ teleopRepeatTimer = setInterval(() => updateArmTeleopFromKeys(true), TELEOP_REPEAT_INTERVAL_MS);
2138
+ }
2139
+
2140
+ function stopTeleopRepeat() {
2141
+ if (!teleopRepeatTimer) {
2142
+ return;
2143
+ }
2144
+ clearInterval(teleopRepeatTimer);
2145
+ teleopRepeatTimer = null;
2146
+ }
2147
+
2148
+ function updateArmTeleopFromKeys(force = false) {
2149
+ let dx = 0, dy = 0, dz = 0;
2150
+ if (keysPressed.has('KeyW')) dx += teleopTranslationStep;
2151
+ if (keysPressed.has('KeyS')) dx -= teleopTranslationStep;
2152
+ if (keysPressed.has('KeyA')) dy += teleopTranslationStep;
2153
+ if (keysPressed.has('KeyD')) dy -= teleopTranslationStep;
2154
+ if (keysPressed.has('KeyR')) dz += teleopVerticalStep;
2155
+ if (keysPressed.has('KeyF')) dz -= teleopVerticalStep;
2156
+
2157
+ const unchanged =
2158
+ Math.abs(dx - lastTeleopCommand.dx) < 1e-6 &&
2159
+ Math.abs(dy - lastTeleopCommand.dy) < 1e-6 &&
2160
+ Math.abs(dz - lastTeleopCommand.dz) < 1e-6;
2161
+
2162
+ if (unchanged && !force) {
2163
+ return;
2164
+ }
2165
+
2166
+ lastTeleopCommand = { dx, dy, dz };
2167
+ send('teleop_action', { dx, dy, dz });
2168
+ }
2169
+
2170
+ function startArmJogForKey(code) {
2171
+ const entry = armJogKeyMap[code];
2172
+ if (!entry) {
2173
+ return;
2174
+ }
2175
+ startJog(entry.jogType, entry.axis, entry.direction);
2176
+
2177
+ }
2178
+
2179
+ function handleArmKeyDown(code) {
2180
+ startArmJogForKey(code);
2181
+ }
2182
+
2183
+ function handleArmKeyUp(code) {
2184
+ if (!armJogKeyMap[code]) {
2185
+ return;
2186
+ }
2187
+ stopJog();
2188
+ const remaining = [...keysPressed].filter((key) => armJogKeyMap[key]);
2189
+ if (remaining.length > 0) {
2190
+ startArmJogForKey(remaining[remaining.length - 1]);
2191
+ }
2192
+ }
2193
+
2194
+ function handleKeyStateChange() {
2195
+ if (isArmRobot()) {
2196
+ return;
2197
+ }
2198
+ const active = hasActiveTeleopKeys();
2199
+ if (active) {
2200
+ startTeleopRepeat();
2201
+ } else {
2202
+ stopTeleopRepeat();
2203
+ }
2204
+ updateCmdFromKeys();
2205
+ }
2206
+
2207
+ window.addEventListener('keydown', (e) => {
2208
+ if (e.code === 'Enter') {
2209
+ e.preventDefault();
2210
+ // For ARM robots, trigger homing; for locomotion robots, terminate episode
2211
+ if (isArmRobot()) {
2212
+ startHoming();
2213
+ } else {
2214
+ requestEpisodeControl('terminate');
2215
+ }
2216
+ return;
2217
+ }
2218
+ if (shouldCaptureKey(e.code)) {
2219
+ e.preventDefault();
2220
+ const alreadyPressed = keysPressed.has(e.code);
2221
+ keysPressed.add(e.code);
2222
+ if (isArmRobot()) {
2223
+ if (!alreadyPressed) {
2224
+ handleArmKeyDown(e.code);
2225
+ }
2226
+ } else {
2227
+ handleKeyStateChange();
2228
+ }
2229
+ }
2230
+ });
2231
+
2232
+ window.addEventListener('keyup', (e) => {
2233
+ if (e.code === 'Enter') {
2234
+ // For ARM robots, stop homing on Enter key release
2235
+ if (isArmRobot()) {
2236
+ stopHoming();
2237
+ }
2238
+ return;
2239
+ }
2240
+ if (keysPressed.delete(e.code)) {
2241
+ if (isArmRobot()) {
2242
+ handleArmKeyUp(e.code);
2243
+ } else {
2244
+ handleKeyStateChange();
2245
+ }
2246
+ }
2247
+ });
2248
+
2249
+ camDist.oninput = () => {
2250
+ camDistVal.innerText = parseFloat(camDist.value).toFixed(1);
2251
+ send('camera', { action: 'set_distance', distance: parseFloat(camDist.value) });
2252
+ };
2253
+
2254
+ let isDragging = false;
2255
+ let lastX, lastY;
2256
+
2257
+ viewport.oncontextmenu = (e) => e.preventDefault();
2258
+
2259
+ // Mouse controls
2260
+ viewport.onmousedown = (e) => {
2261
+ isDragging = true;
2262
+ lastX = e.clientX;
2263
+ lastY = e.clientY;
2264
+ };
2265
+
2266
+ window.addEventListener('mouseup', () => {
2267
+ isDragging = false;
2268
+ });
2269
+
2270
+ window.onmousemove = (e) => {
2271
+ if (isDragging) {
2272
+ const dx = e.clientX - lastX;
2273
+ const dy = e.clientY - lastY;
2274
+ lastX = e.clientX;
2275
+ lastY = e.clientY;
2276
+ send('camera', { action: 'rotate', dx, dy });
2277
+ }
2278
+ };
2279
+
2280
+ viewport.onwheel = (e) => {
2281
+ e.preventDefault();
2282
+ send('camera', { action: 'zoom', dz: e.deltaY });
2283
+ };
2284
+
2285
+ // Touch controls for camera rotation and pinch-to-zoom
2286
+ let touchStartX, touchStartY;
2287
+ let lastPinchDist = null;
2288
+
2289
+ function getTouchDistance(touches) {
2290
+ const dx = touches[0].clientX - touches[1].clientX;
2291
+ const dy = touches[0].clientY - touches[1].clientY;
2292
+ return Math.sqrt(dx * dx + dy * dy);
2293
+ }
2294
+
2295
+ viewport.addEventListener('touchstart', (e) => {
2296
+ if (e.touches.length === 1) {
2297
+ // Single touch - rotation
2298
+ touchStartX = e.touches[0].clientX;
2299
+ touchStartY = e.touches[0].clientY;
2300
+ lastPinchDist = null;
2301
+ } else if (e.touches.length === 2) {
2302
+ // Two touches - pinch zoom
2303
+ lastPinchDist = getTouchDistance(e.touches);
2304
+ }
2305
+ }, { passive: true });
2306
+
2307
+ viewport.addEventListener('touchmove', (e) => {
2308
+ e.preventDefault();
2309
+ if (e.touches.length === 1 && lastPinchDist === null) {
2310
+ // Single touch drag - rotate camera
2311
+ const dx = e.touches[0].clientX - touchStartX;
2312
+ const dy = e.touches[0].clientY - touchStartY;
2313
+ touchStartX = e.touches[0].clientX;
2314
+ touchStartY = e.touches[0].clientY;
2315
+ send('camera', { action: 'rotate', dx, dy });
2316
+ } else if (e.touches.length === 2 && lastPinchDist !== null) {
2317
+ // Pinch zoom
2318
+ const dist = getTouchDistance(e.touches);
2319
+ const delta = lastPinchDist - dist;
2320
+ lastPinchDist = dist;
2321
+ // Scale delta for smoother zoom
2322
+ send('camera', { action: 'zoom', dz: delta * 3 });
2323
+ }
2324
+ }, { passive: false });
2325
+
2326
+ viewport.addEventListener('touchend', (e) => {
2327
+ if (e.touches.length < 2) {
2328
+ lastPinchDist = null;
2329
+ }
2330
+ if (e.touches.length === 1) {
2331
+ // Reset for single finger after pinch
2332
+ touchStartX = e.touches[0].clientX;
2333
+ touchStartY = e.touches[0].clientY;
2334
+ }
2335
+ }, { passive: true });
2336
+
2337
+ // Connect on load
2338
+ enterConnectingState();
2339
+ connect();
2340
+ </script>
2341
+ </body>
2342
+
2343
+ </html>
mujoco_server.py CHANGED
The diff for this file is too large to render. See raw diff