gameglue 4.0.0 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +275 -275
  3. package/babel.config.cjs +5 -5
  4. package/coverage/auth.js.html +525 -525
  5. package/coverage/base.css +224 -224
  6. package/coverage/block-navigation.js +87 -87
  7. package/coverage/favicon.png +0 -0
  8. package/coverage/index.html +175 -175
  9. package/coverage/index.js.html +309 -309
  10. package/coverage/lcov-report/auth.js.html +525 -525
  11. package/coverage/lcov-report/base.css +224 -224
  12. package/coverage/lcov-report/block-navigation.js +87 -87
  13. package/coverage/lcov-report/favicon.png +0 -0
  14. package/coverage/lcov-report/index.html +175 -175
  15. package/coverage/lcov-report/index.js.html +309 -309
  16. package/coverage/lcov-report/listener.js.html +528 -528
  17. package/coverage/lcov-report/prettify.css +1 -1
  18. package/coverage/lcov-report/prettify.js +2 -2
  19. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  20. package/coverage/lcov-report/sorter.js +210 -210
  21. package/coverage/lcov-report/user.js.html +117 -117
  22. package/coverage/lcov-report/utils.js.html +117 -117
  23. package/coverage/lcov.info +391 -391
  24. package/coverage/listener.js.html +528 -528
  25. package/coverage/prettify.css +1 -1
  26. package/coverage/prettify.js +2 -2
  27. package/coverage/sort-arrow-sprite.png +0 -0
  28. package/coverage/sorter.js +210 -210
  29. package/coverage/user.js.html +117 -117
  30. package/coverage/utils.js.html +117 -117
  31. package/dist/gg.cjs.js +1 -1
  32. package/dist/gg.cjs.js.map +1 -1
  33. package/dist/gg.esm.js +1 -1
  34. package/dist/gg.esm.js.map +1 -1
  35. package/dist/gg.umd.js +1 -1
  36. package/dist/gg.umd.js.map +1 -1
  37. package/examples/certs/cert.pem +19 -19
  38. package/examples/certs/key.pem +28 -28
  39. package/examples/flight-dashboard.html +431 -431
  40. package/examples/server.js +99 -99
  41. package/examples/telemetry-validator.html +1410 -1410
  42. package/jest.config.cjs +33 -33
  43. package/package.json +56 -56
  44. package/rollup.config.js +57 -57
  45. package/src/auth.js +255 -255
  46. package/src/auth.spec.js +481 -481
  47. package/src/index.js +168 -168
  48. package/src/listener.js +196 -193
  49. package/src/listener.spec.js +598 -598
  50. package/src/presence_listener.js +112 -112
  51. package/src/test/fixtures.js +106 -106
  52. package/src/test/setup.js +51 -51
  53. package/src/utils.js +63 -63
  54. package/src/utils.spec.js +78 -78
  55. package/types/index.d.ts +338 -338
  56. package/webpack.config.js +15 -15
@@ -1,1410 +1,1410 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>GameGlue Telemetry Validator</title>
7
- <style>
8
- * { box-sizing: border-box; margin: 0; padding: 0; }
9
- body {
10
- font-family: 'SF Mono', 'Consolas', monospace;
11
- background: #0a0a0a;
12
- color: #e2e8f0;
13
- min-height: 100vh;
14
- padding: 1.5rem;
15
- }
16
- .container { max-width: 1400px; margin: 0 auto; }
17
- h1 { font-size: 1.25rem; margin-bottom: 0.25rem; color: #00ff88; }
18
- .subtitle { color: #666; margin-bottom: 1.5rem; font-size: 0.875rem; }
19
- .card {
20
- background: #111;
21
- border: 1px solid #222;
22
- border-radius: 0.5rem;
23
- padding: 1rem;
24
- margin-bottom: 1rem;
25
- }
26
- .card h2 {
27
- font-size: 0.75rem;
28
- color: #666;
29
- text-transform: uppercase;
30
- letter-spacing: 0.1em;
31
- margin-bottom: 0.75rem;
32
- }
33
-
34
- /* Stats */
35
- .stats-grid {
36
- display: grid;
37
- grid-template-columns: repeat(6, 1fr);
38
- gap: 1rem;
39
- margin-bottom: 1rem;
40
- }
41
- .stat-card {
42
- background: #111;
43
- border: 1px solid #222;
44
- border-radius: 0.5rem;
45
- padding: 1rem;
46
- text-align: center;
47
- }
48
- .stat-value { font-size: 1.5rem; font-weight: bold; color: #fff; }
49
- .stat-value.success { color: #00ff88; }
50
- .stat-value.warning { color: #fbbf24; }
51
- .stat-value.error { color: #ef4444; }
52
- .stat-label { font-size: 0.7rem; color: #666; text-transform: uppercase; margin-top: 0.25rem; }
53
-
54
- /* Form */
55
- .form-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 1rem; align-items: end; }
56
- .form-group { display: flex; flex-direction: column; gap: 0.25rem; }
57
- .form-group label { font-size: 0.7rem; color: #666; text-transform: uppercase; }
58
- .form-group input, .form-group select {
59
- background: #0a0a0a;
60
- border: 1px solid #333;
61
- color: #fff;
62
- padding: 0.5rem 0.75rem;
63
- border-radius: 0.25rem;
64
- font-family: inherit;
65
- font-size: 0.875rem;
66
- }
67
- .form-group input:focus, .form-group select:focus { outline: none; border-color: #00ff88; }
68
- .btn {
69
- padding: 0.5rem 1rem;
70
- border: none;
71
- border-radius: 0.25rem;
72
- font-family: inherit;
73
- font-size: 0.875rem;
74
- cursor: pointer;
75
- }
76
- .btn-primary { background: #00ff88; color: #0a0a0a; }
77
- .btn-secondary { background: #333; color: #fff; border: 1px solid #444; }
78
- .btn-danger { background: #ef4444; color: #fff; }
79
- .btn:disabled { opacity: 0.5; cursor: not-allowed; }
80
- .btn-group { display: flex; gap: 0.5rem; }
81
-
82
- /* Status */
83
- .status-badge {
84
- display: inline-block;
85
- padding: 0.25rem 0.75rem;
86
- border-radius: 9999px;
87
- font-size: 0.75rem;
88
- text-transform: uppercase;
89
- }
90
- .status-badge.disconnected { background: #7f1d1d; color: #fca5a5; }
91
- .status-badge.connected { background: #14532d; color: #86efac; }
92
- .status-badge.listening { background: #1e3a5f; color: #93c5fd; }
93
-
94
- /* Field Table */
95
- .field-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
96
- .field-table th {
97
- text-align: left;
98
- padding: 0.5rem;
99
- border-bottom: 1px solid #333;
100
- color: #666;
101
- font-weight: normal;
102
- text-transform: uppercase;
103
- font-size: 0.7rem;
104
- }
105
- .field-table td { padding: 0.5rem; border-bottom: 1px solid #222; }
106
- .field-table tr.ok { background: rgba(0, 255, 136, 0.03); }
107
- .field-table tr.missing { background: rgba(239, 68, 68, 0.05); }
108
- .field-table tr.warning { background: rgba(251, 191, 36, 0.05); }
109
- .canonical { color: #00ff88; }
110
- .raw { color: #888; font-size: 0.75rem; }
111
- .arrow { color: #444; margin: 0 0.5rem; }
112
- .required { color: #ef4444; margin-left: 0.25rem; }
113
- .field-value { text-align: right; color: #fff; }
114
- .field-unit { color: #666; margin-left: 0.25rem; }
115
- .badge {
116
- display: inline-block;
117
- padding: 0.125rem 0.5rem;
118
- border-radius: 0.25rem;
119
- font-size: 0.65rem;
120
- text-transform: uppercase;
121
- border: 1px solid;
122
- }
123
- .badge.ok { border-color: #00ff88; color: #00ff88; }
124
- .badge.missing { border-color: #ef4444; color: #ef4444; }
125
- .badge.null { border-color: #fbbf24; color: #fbbf24; }
126
- .badge.new { border-color: #3b82f6; color: #3b82f6; }
127
-
128
- /* Category */
129
- .category-header {
130
- display: flex;
131
- justify-content: space-between;
132
- align-items: center;
133
- padding: 0.75rem 0.5rem;
134
- background: #0a0a0a;
135
- margin: 1rem 0 0.5rem 0;
136
- border-radius: 0.25rem;
137
- }
138
- .category-name { color: #fff; font-size: 0.875rem; }
139
- .category-count { font-size: 0.75rem; }
140
- .category-count.complete { color: #00ff88; }
141
- .category-count.incomplete { color: #fbbf24; }
142
-
143
- /* Unexpected */
144
- .unexpected-fields { display: flex; flex-wrap: wrap; gap: 0.5rem; }
145
- .unexpected-field {
146
- padding: 0.25rem 0.5rem;
147
- background: #1e3a5f;
148
- border: 1px solid #3b82f6;
149
- border-radius: 0.25rem;
150
- font-size: 0.75rem;
151
- color: #93c5fd;
152
- }
153
-
154
- /* Auth */
155
- .auth-section { text-align: center; padding: 3rem; }
156
- .auth-btn {
157
- background: #00ff88;
158
- color: #0a0a0a;
159
- border: none;
160
- padding: 0.75rem 2rem;
161
- border-radius: 0.5rem;
162
- font-size: 1rem;
163
- font-weight: 600;
164
- cursor: pointer;
165
- font-family: inherit;
166
- }
167
-
168
- .hidden { display: none !important; }
169
-
170
- /* Toast notification */
171
- .toast {
172
- position: fixed;
173
- bottom: 2rem;
174
- right: 2rem;
175
- background: #14532d;
176
- color: #86efac;
177
- padding: 0.75rem 1.25rem;
178
- border-radius: 0.5rem;
179
- font-size: 0.875rem;
180
- opacity: 0;
181
- transform: translateY(1rem);
182
- transition: all 0.3s ease;
183
- z-index: 1000;
184
- }
185
- .toast.show {
186
- opacity: 1;
187
- transform: translateY(0);
188
- }
189
- .waiting { text-align: center; padding: 2rem; color: #666; }
190
- .spinner {
191
- display: inline-block;
192
- width: 24px;
193
- height: 24px;
194
- border: 2px solid #333;
195
- border-top-color: #00ff88;
196
- border-radius: 50%;
197
- animation: spin 1s linear infinite;
198
- margin-bottom: 1rem;
199
- }
200
- @keyframes spin { to { transform: rotate(360deg); } }
201
-
202
- .instructions {
203
- background: #0a0a0a;
204
- border: 1px solid #222;
205
- border-radius: 0.5rem;
206
- padding: 1rem;
207
- margin-top: 1rem;
208
- }
209
- .instructions h3 {
210
- font-size: 0.75rem;
211
- color: #666;
212
- text-transform: uppercase;
213
- margin-bottom: 0.5rem;
214
- }
215
- .instructions ol { margin-left: 1.25rem; font-size: 0.8rem; color: #888; }
216
- .instructions li { margin-bottom: 0.25rem; }
217
-
218
- /* Key Events */
219
- .key-events-list { display: flex; flex-direction: column; gap: 0.5rem; max-height: 400px; overflow-y: auto; }
220
- .key-event {
221
- display: flex;
222
- align-items: flex-start;
223
- gap: 0.75rem;
224
- padding: 0.75rem;
225
- background: #0a0a0a;
226
- border: 1px solid #222;
227
- border-radius: 0.375rem;
228
- border-left: 3px solid;
229
- }
230
- .key-event.landing { border-left-color: #00ff88; }
231
- .key-event.takeoff { border-left-color: #3b82f6; }
232
- .key-event.flight_phase { border-left-color: #fbbf24; }
233
- .key-event-icon {
234
- width: 32px;
235
- height: 32px;
236
- display: flex;
237
- align-items: center;
238
- justify-content: center;
239
- border-radius: 0.25rem;
240
- font-size: 1rem;
241
- flex-shrink: 0;
242
- }
243
- .key-event.landing .key-event-icon { background: rgba(0, 255, 136, 0.15); }
244
- .key-event.takeoff .key-event-icon { background: rgba(59, 130, 246, 0.15); }
245
- .key-event.flight_phase .key-event-icon { background: rgba(251, 191, 36, 0.15); }
246
- .key-event-content { flex: 1; min-width: 0; }
247
- .key-event-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.25rem; }
248
- .key-event-type { font-weight: 600; font-size: 0.875rem; text-transform: capitalize; }
249
- .key-event.landing .key-event-type { color: #00ff88; }
250
- .key-event.takeoff .key-event-type { color: #3b82f6; }
251
- .key-event.flight_phase .key-event-type { color: #fbbf24; }
252
- .key-event-time { font-size: 0.7rem; color: #666; }
253
- .key-event-details { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; font-size: 0.75rem; }
254
- .key-event-detail { display: flex; gap: 0.25rem; }
255
- .key-event-detail-label { color: #666; }
256
- .key-event-detail-value { color: #fff; }
257
- .key-event-quality { padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.65rem; text-transform: uppercase; font-weight: 600; }
258
- .key-event-quality.butter { background: #14532d; color: #86efac; }
259
- .key-event-quality.smooth { background: #1e3a5f; color: #93c5fd; }
260
- .key-event-quality.normal { background: #3f3f46; color: #d4d4d8; }
261
- .key-event-quality.firm { background: #78350f; color: #fcd34d; }
262
- .key-event-quality.hard { background: #7c2d12; color: #fdba74; }
263
- .key-event-quality.crash { background: #7f1d1d; color: #fca5a5; }
264
- .key-events-empty { text-align: center; padding: 2rem; color: #666; font-size: 0.875rem; }
265
-
266
- /* Tab Navigation */
267
- .tabs {
268
- display: flex;
269
- gap: 0.5rem;
270
- margin-bottom: 1rem;
271
- border-bottom: 1px solid #222;
272
- padding-bottom: 0.5rem;
273
- }
274
- .tab {
275
- padding: 0.5rem 1rem;
276
- background: transparent;
277
- border: 1px solid transparent;
278
- border-radius: 0.25rem 0.25rem 0 0;
279
- color: #888;
280
- cursor: pointer;
281
- font-family: inherit;
282
- font-size: 0.875rem;
283
- transition: all 0.2s;
284
- }
285
- .tab:hover { color: #fff; }
286
- .tab.active {
287
- background: #111;
288
- border-color: #222;
289
- border-bottom-color: #111;
290
- color: #00ff88;
291
- }
292
-
293
- /* Commands Section */
294
- .commands-grid {
295
- display: grid;
296
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
297
- gap: 1rem;
298
- }
299
- .command-category {
300
- background: #0a0a0a;
301
- border: 1px solid #222;
302
- border-radius: 0.5rem;
303
- padding: 1rem;
304
- }
305
- .command-category h3 {
306
- font-size: 0.75rem;
307
- color: #00ff88;
308
- text-transform: uppercase;
309
- letter-spacing: 0.05em;
310
- margin-bottom: 0.75rem;
311
- padding-bottom: 0.5rem;
312
- border-bottom: 1px solid #222;
313
- }
314
- .command-list { display: flex; flex-direction: column; gap: 0.5rem; }
315
- .command-item {
316
- display: flex;
317
- align-items: center;
318
- gap: 0.5rem;
319
- }
320
- .command-btn {
321
- flex: 1;
322
- padding: 0.375rem 0.75rem;
323
- background: #1a1a1a;
324
- border: 1px solid #333;
325
- border-radius: 0.25rem;
326
- color: #e2e8f0;
327
- cursor: pointer;
328
- font-family: inherit;
329
- font-size: 0.75rem;
330
- text-align: left;
331
- transition: all 0.15s;
332
- }
333
- .command-btn:hover {
334
- background: #252525;
335
- border-color: #444;
336
- }
337
- .command-btn:active {
338
- background: #00ff88;
339
- color: #0a0a0a;
340
- }
341
- .command-btn:disabled {
342
- opacity: 0.4;
343
- cursor: not-allowed;
344
- }
345
- .command-input {
346
- width: 80px;
347
- padding: 0.375rem 0.5rem;
348
- background: #0a0a0a;
349
- border: 1px solid #333;
350
- border-radius: 0.25rem;
351
- color: #fff;
352
- font-family: inherit;
353
- font-size: 0.75rem;
354
- text-align: right;
355
- }
356
- .command-input:focus {
357
- outline: none;
358
- border-color: #00ff88;
359
- }
360
- .command-unit {
361
- font-size: 0.65rem;
362
- color: #666;
363
- min-width: 30px;
364
- }
365
- .command-log {
366
- max-height: 200px;
367
- overflow-y: auto;
368
- font-size: 0.75rem;
369
- background: #0a0a0a;
370
- border: 1px solid #222;
371
- border-radius: 0.25rem;
372
- padding: 0.5rem;
373
- margin-top: 1rem;
374
- }
375
- .command-log-entry {
376
- padding: 0.25rem 0;
377
- border-bottom: 1px solid #1a1a1a;
378
- display: flex;
379
- justify-content: space-between;
380
- }
381
- .command-log-entry:last-child { border-bottom: none; }
382
- .command-log-time { color: #666; }
383
- .command-log-cmd { color: #00ff88; }
384
- .command-log-value { color: #fff; }
385
- .command-log-empty { color: #666; text-align: center; padding: 1rem; }
386
- </style>
387
- </head>
388
- <body>
389
- <div class="container">
390
- <h1>Telemetry Validator</h1>
391
- <p class="subtitle">Validate real-time telemetry and see field normalization</p>
392
-
393
- <!-- Auth -->
394
- <div id="auth-section" class="card auth-section">
395
- <p style="margin-bottom: 1rem; color: #888;">Connect to validate telemetry from your broadcaster</p>
396
- <button class="auth-btn" onclick="doLogin()">Connect with GameGlue</button>
397
- </div>
398
-
399
- <!-- Dashboard -->
400
- <div id="dashboard" class="hidden">
401
- <!-- Connection -->
402
- <div class="card">
403
- <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
404
- <h2 style="margin: 0;">Connection</h2>
405
- <span id="status-badge" class="status-badge disconnected">Disconnected</span>
406
- </div>
407
- <div class="form-row">
408
- <div class="form-group">
409
- <label>Game</label>
410
- <select id="game-select" onchange="onGameChange()">
411
- <option value="msfs">Microsoft Flight Simulator</option>
412
- <option value="xplane">X-Plane 12</option>
413
- </select>
414
- </div>
415
- <div class="form-group">
416
- <label>User ID (Broadcaster)</label>
417
- <input type="text" id="user-id" placeholder="Your user ID">
418
- </div>
419
- <div class="btn-group">
420
- <button id="connect-btn" class="btn btn-primary" onclick="startValidation()">Start</button>
421
- <button id="stop-btn" class="btn btn-danger hidden" onclick="stopValidation()">Stop</button>
422
- <button id="reset-btn" class="btn btn-secondary hidden" onclick="resetStats()">Reset</button>
423
- <button id="export-btn" class="btn btn-secondary hidden" onclick="exportReport()">Export</button>
424
- <button class="btn btn-secondary" onclick="doLogout()">Logout</button>
425
- </div>
426
- </div>
427
- <div id="error-msg" class="hidden" style="color: #ef4444; font-size: 0.8rem; margin-top: 0.5rem;"></div>
428
- </div>
429
-
430
- <!-- Tabs -->
431
- <div id="tabs-section" class="hidden">
432
- <div class="tabs">
433
- <button class="tab active" onclick="switchTab('telemetry')">Telemetry</button>
434
- <button class="tab" onclick="switchTab('commands')">Commands</button>
435
- </div>
436
- </div>
437
-
438
- <!-- Stats -->
439
- <div id="stats-section" class="hidden">
440
- <div class="stats-grid">
441
- <div class="stat-card">
442
- <div id="stat-rate" class="stat-value">0</div>
443
- <div class="stat-label">Hz</div>
444
- </div>
445
- <div class="stat-card">
446
- <div id="stat-total" class="stat-value">0</div>
447
- <div class="stat-label">Updates</div>
448
- </div>
449
- <div class="stat-card">
450
- <div id="stat-fields" class="stat-value">0/0</div>
451
- <div class="stat-label">Fields</div>
452
- </div>
453
- <div class="stat-card">
454
- <div id="stat-required" class="stat-value">0/0</div>
455
- <div class="stat-label">Required</div>
456
- </div>
457
- <div class="stat-card">
458
- <div id="stat-key-events" class="stat-value">0</div>
459
- <div class="stat-label">Key Events</div>
460
- </div>
461
- <div class="stat-card">
462
- <div id="stat-issues" class="stat-value success">0</div>
463
- <div class="stat-label">Issues</div>
464
- </div>
465
- </div>
466
- </div>
467
-
468
- <!-- Key Events -->
469
- <div id="key-events-section" class="hidden">
470
- <div class="card">
471
- <h2>Key Events</h2>
472
- <p style="font-size: 0.75rem; color: #666; margin-bottom: 0.75rem;">
473
- Computed events from gg-client: landings, takeoffs, and flight phase changes.
474
- </p>
475
- <div id="key-events-list" class="key-events-list">
476
- <div class="key-events-empty">No key events received yet. Fly the aircraft to trigger events.</div>
477
- </div>
478
- </div>
479
- </div>
480
-
481
- <!-- Waiting -->
482
- <div id="waiting-section" class="hidden">
483
- <div class="card waiting">
484
- <div class="spinner"></div>
485
- <p>Waiting for telemetry...</p>
486
- <p style="font-size: 0.75rem; margin-top: 0.5rem;">Make sure gg-client is broadcasting.</p>
487
- </div>
488
- </div>
489
-
490
- <!-- Unexpected Fields -->
491
- <div id="unexpected-section" class="hidden">
492
- <div class="card">
493
- <h2>Unmapped Fields</h2>
494
- <p style="font-size: 0.75rem; color: #666; margin-bottom: 0.75rem;">
495
- These raw fields aren't mapped to canonical names yet.
496
- </p>
497
- <div id="unexpected-fields" class="unexpected-fields"></div>
498
- </div>
499
- </div>
500
-
501
- <!-- Coverage -->
502
- <div id="coverage-section" class="hidden">
503
- <div class="card">
504
- <h2>Field Coverage</h2>
505
- <p style="font-size: 0.75rem; color: #666; margin-bottom: 0.75rem;">
506
- <span class="canonical">Canonical</span> <span class="arrow">&larr;</span> <span class="raw">raw_field</span>
507
- shows how game fields map to normalized names.
508
- </p>
509
- <div id="categories"></div>
510
- </div>
511
- </div>
512
-
513
- <!-- Commands Section -->
514
- <div id="commands-section" class="hidden">
515
- <div class="card">
516
- <h2>Send Commands</h2>
517
- <p style="font-size: 0.75rem; color: #666; margin-bottom: 0.75rem;">
518
- Send canonical commands to the simulator. Commands work across both MSFS and X-Plane.
519
- </p>
520
- <div id="commands-grid" class="commands-grid"></div>
521
- <div class="command-log">
522
- <div id="command-log-entries">
523
- <div class="command-log-empty">No commands sent yet</div>
524
- </div>
525
- </div>
526
- </div>
527
- </div>
528
-
529
- <!-- Not Listening -->
530
- <div id="not-listening-section">
531
- <div class="card" style="text-align: center; padding: 2rem;">
532
- <p style="color: #fff; margin-bottom: 0.5rem;">Ready to validate</p>
533
- <p style="color: #666; font-size: 0.8rem;">Select a game and click Start to begin.</p>
534
- <div class="instructions">
535
- <h3>About Normalization</h3>
536
- <ol>
537
- <li>Each sim uses different field names (e.g., MSFS: <code>indicated_airspeed</code>, X-Plane: <code>ias</code>)</li>
538
- <li>GameGlue normalizes these to canonical names (e.g., <code>indicated_airspeed</code>)</li>
539
- <li>This lets you write code once that works across all supported sims</li>
540
- </ol>
541
- </div>
542
- </div>
543
- </div>
544
- </div>
545
- </div>
546
-
547
- <!-- Toast notification -->
548
- <div id="toast" class="toast">Report copied to clipboard!</div>
549
-
550
- <script src="../dist/gg.umd.js"></script>
551
- <script>
552
- // ===========================================
553
- // CANONICAL SCHEMA (loaded from @gameglue/schemas)
554
- // ===========================================
555
- // Fields that are 0-1 ratios but displayed as percentages
556
- const RATIO_FIELDS = ['throttle_0', 'throttle_1', 'throttle_2', 'throttle_3', 'flaps', 'spoiler'];
557
-
558
- // Unit display mapping (schema units -> display symbols)
559
- const UNIT_DISPLAY = {
560
- 'degrees': '°',
561
- 'deg': '°',
562
- 'ft': 'ft',
563
- 'kts': 'kts',
564
- 'fpm': 'fpm',
565
- 'lbs': 'lbs',
566
- 'lbs/hr': 'lbs/hr',
567
- 'rpm': 'RPM',
568
- '%': '%',
569
- 'ratio': '%',
570
- '°C': '°C',
571
- 'G': 'G',
572
- };
573
-
574
- // Build CANONICAL from category schema
575
- function buildCanonicalFromSchema() {
576
- const categorySchema = window.GameGlueSchemas?.getCategorySchema('flight_sim');
577
- if (!categorySchema) {
578
- console.warn('Category schema not available, using fallback');
579
- return { requiredFields: [], fields: {} };
580
- }
581
-
582
- const fields = {};
583
- for (const [fieldName, fieldDef] of Object.entries(categorySchema.fields)) {
584
- fields[fieldName] = {
585
- group: fieldDef.group || 'Other',
586
- desc: fieldDef.description || fieldName,
587
- unit: UNIT_DISPLAY[fieldDef.unit] || fieldDef.unit || '',
588
- type: fieldDef.type,
589
- ratio: RATIO_FIELDS.includes(fieldName),
590
- };
591
- }
592
-
593
- return {
594
- requiredFields: categorySchema.requiredFields || [],
595
- fields
596
- };
597
- }
598
-
599
- // Initialize after SDK loads
600
- let CANONICAL = { requiredFields: [], fields: {} };
601
-
602
- // ===========================================
603
- // GAME SELECTION (schemas from @gameglue/schemas via SDK)
604
- // ===========================================
605
- const GAME_OPTIONS = [
606
- { gameId: 'msfs', name: 'Microsoft Flight Simulator' },
607
- { gameId: 'xplane', name: 'X-Plane 12' }
608
- ];
609
-
610
- // Build reverse mappings (canonical -> raw) from schema
611
- function buildReverseMappings(gameId) {
612
- const schema = window.GameGlueSchemas?.getGameSchema(gameId);
613
- if (!schema?.fieldMappings) return {};
614
-
615
- const reverse = {};
616
- for (const [rawField, mapping] of Object.entries(schema.fieldMappings)) {
617
- const canonical = mapping.canonical;
618
- if (!reverse[canonical]) {
619
- reverse[canonical] = rawField;
620
- }
621
- }
622
- return reverse;
623
- }
624
-
625
- // ===========================================
626
- // STATE
627
- // ===========================================
628
- const CLIENT_ID = 'gameglue-sdk-examples';
629
- const REDIRECT_URI = window.location.href.split('?')[0].split('#')[0];
630
- // For local development, add socketUrl: 'http://localhost:3031'
631
- const ggClient = new GameGlue({ clientId: CLIENT_ID, redirect_uri: REDIRECT_URI, scopes: ['msfs:read', 'msfs:write', 'xplane:read', 'xplane:write'] });
632
-
633
- let listener = null;
634
- let currentGameId = 'msfs';
635
- let reverseMappings = {}; // canonical -> raw field name
636
- let state = {
637
- totalUpdates: 0,
638
- timestamps: [],
639
- fieldStats: {}, // canonical -> { rawField, lastValue, updateCount }
640
- unmappedFields: new Set(),
641
- keyEvents: [] // Array of { eventType, data, receivedAt }
642
- };
643
-
644
- // ===========================================
645
- // PROCESS TELEMETRY (using SDK's normalized data)
646
- // ===========================================
647
- function processUpdate(evt) {
648
- const now = Date.now();
649
- state.totalUpdates++;
650
- state.timestamps.push(now);
651
- if (state.timestamps.length > 100) state.timestamps = state.timestamps.slice(-100);
652
-
653
- const normalizedData = evt.data || {};
654
- const rawData = evt.raw || {};
655
-
656
- // Track canonical fields (already normalized by SDK via @gameglue/schemas)
657
- for (const [canonical, value] of Object.entries(normalizedData)) {
658
- // Find which raw field produced this canonical field
659
- const rawField = reverseMappings[canonical] || canonical;
660
- const existing = state.fieldStats[canonical] || { rawField, updateCount: 0 };
661
- state.fieldStats[canonical] = {
662
- rawField,
663
- lastValue: value,
664
- updateCount: existing.updateCount + 1
665
- };
666
- }
667
-
668
- // Track unmapped raw fields (not in schema)
669
- for (const rawField of Object.keys(rawData)) {
670
- const schema = window.GameGlueSchemas?.getGameSchema(currentGameId);
671
- if (schema?.fieldMappings && !schema.fieldMappings[rawField]) {
672
- state.unmappedFields.add(rawField);
673
- }
674
- }
675
-
676
- updateUI();
677
- }
678
-
679
- function getRate() {
680
- const now = Date.now();
681
- return state.timestamps.filter(t => now - t < 1000).length;
682
- }
683
-
684
- // ===========================================
685
- // KEY EVENTS
686
- // ===========================================
687
- function processKeyEvent(eventType, data) {
688
- state.keyEvents.unshift({
689
- eventType,
690
- data,
691
- receivedAt: Date.now()
692
- });
693
- // Keep last 50 events
694
- if (state.keyEvents.length > 50) {
695
- state.keyEvents.pop();
696
- }
697
- updateKeyEventsUI();
698
- }
699
-
700
- function updateKeyEventsUI() {
701
- const list = document.getElementById('key-events-list');
702
- const stat = document.getElementById('stat-key-events');
703
-
704
- stat.textContent = state.keyEvents.length;
705
- stat.className = 'stat-value ' + (state.keyEvents.length > 0 ? 'success' : '');
706
-
707
- if (state.keyEvents.length === 0) {
708
- list.innerHTML = '<div class="key-events-empty">No key events received yet. Fly the aircraft to trigger landings, takeoffs, and phase changes.</div>';
709
- return;
710
- }
711
-
712
- list.innerHTML = state.keyEvents.map(evt => renderKeyEvent(evt)).join('');
713
- }
714
-
715
- function renderKeyEvent(evt) {
716
- const { eventType, data, receivedAt } = evt;
717
- const time = new Date(receivedAt).toLocaleTimeString();
718
- const icon = getEventIcon(eventType);
719
- const details = getEventDetails(eventType, data);
720
-
721
- return `
722
- <div class="key-event ${eventType}">
723
- <div class="key-event-icon">${icon}</div>
724
- <div class="key-event-content">
725
- <div class="key-event-header">
726
- <span class="key-event-type">${formatEventType(eventType)}</span>
727
- <span class="key-event-time">${time}</span>
728
- </div>
729
- <div class="key-event-details">${details}</div>
730
- </div>
731
- </div>
732
- `;
733
- }
734
-
735
- function getEventIcon(eventType) {
736
- switch (eventType) {
737
- case 'landing': return '&#9992;'; // airplane
738
- case 'takeoff': return '&#128640;'; // rocket
739
- case 'flight_phase': return '&#128508;'; // round pushpin
740
- default: return '&#9679;'; // bullet
741
- }
742
- }
743
-
744
- function formatEventType(eventType) {
745
- switch (eventType) {
746
- case 'landing': return 'Landing';
747
- case 'takeoff': return 'Takeoff';
748
- case 'flight_phase': return 'Phase Change';
749
- default: return eventType;
750
- }
751
- }
752
-
753
- function getEventDetails(eventType, data) {
754
- switch (eventType) {
755
- case 'landing':
756
- const bounceCount = data.bounce_count || 0;
757
- const bounceInfo = bounceCount > 0
758
- ? `<span class="key-event-detail"><span class="key-event-detail-label">Bounces:</span><span class="key-event-detail-value" style="color:#fbbf24">${bounceCount}</span></span>`
759
- : '';
760
- return `
761
- <span class="key-event-quality ${data.quality || 'normal'}">${data.quality || 'unknown'}</span>
762
- <span class="key-event-detail">
763
- <span class="key-event-detail-label">Rate:</span>
764
- <span class="key-event-detail-value">${Math.abs(data.landing_rate || 0).toFixed(0)} fpm</span>
765
- </span>
766
- <span class="key-event-detail">
767
- <span class="key-event-detail-label">Speed:</span>
768
- <span class="key-event-detail-value">${(data.speed_at_touchdown || 0).toFixed(0)} kts</span>
769
- </span>
770
- <span class="key-event-detail">
771
- <span class="key-event-detail-label">Pitch:</span>
772
- <span class="key-event-detail-value">${(data.pitch_at_touchdown || 0).toFixed(1)}°</span>
773
- </span>
774
- ${bounceInfo}
775
- `;
776
- case 'takeoff':
777
- return `
778
- <span class="key-event-detail">
779
- <span class="key-event-detail-label">Rotation:</span>
780
- <span class="key-event-detail-value">${(data.rotation_speed || 0).toFixed(0)} kts</span>
781
- </span>
782
- <span class="key-event-detail">
783
- <span class="key-event-detail-label">Pitch:</span>
784
- <span class="key-event-detail-value">${(data.pitch_at_liftoff || 0).toFixed(1)}°</span>
785
- </span>
786
- <span class="key-event-detail">
787
- <span class="key-event-detail-label">Flaps:</span>
788
- <span class="key-event-detail-value">${((data.flaps_setting || 0) * 100).toFixed(0)}%</span>
789
- </span>
790
- `;
791
- case 'flight_phase':
792
- return `
793
- <span class="key-event-detail">
794
- <span class="key-event-detail-label">Phase:</span>
795
- <span class="key-event-detail-value">${formatPhase(data.phase)}</span>
796
- </span>
797
- <span class="key-event-detail">
798
- <span class="key-event-detail-label">From:</span>
799
- <span class="key-event-detail-value">${formatPhase(data.previous_phase)}</span>
800
- </span>
801
- <span class="key-event-detail">
802
- <span class="key-event-detail-label">MSL:</span>
803
- <span class="key-event-detail-value">${(data.altitude_msl || 0).toFixed(0)} ft</span>
804
- </span>
805
- <span class="key-event-detail">
806
- <span class="key-event-detail-label">AGL:</span>
807
- <span class="key-event-detail-value">${(data.altitude_agl || 0).toFixed(0)} ft</span>
808
- </span>
809
- <span class="key-event-detail">
810
- <span class="key-event-detail-label">Speed:</span>
811
- <span class="key-event-detail-value">${(data.speed || 0).toFixed(0)} kts</span>
812
- </span>
813
- `;
814
- default:
815
- return `<span class="key-event-detail"><span class="key-event-detail-value">${JSON.stringify(data)}</span></span>`;
816
- }
817
- }
818
-
819
- function formatPhase(phase) {
820
- if (!phase) return 'unknown';
821
- return phase.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
822
- }
823
-
824
- // ===========================================
825
- // UI
826
- // ===========================================
827
- function updateUI() {
828
- const rate = getRate();
829
- const totalCanonical = Object.keys(CANONICAL.fields).length;
830
- const received = Object.keys(state.fieldStats).length;
831
- const requiredReceived = CANONICAL.requiredFields.filter(f => state.fieldStats[f]).length;
832
- const totalRequired = CANONICAL.requiredFields.length;
833
-
834
- // Update stats (always visible in stats-section)
835
- document.getElementById('stat-rate').textContent = rate;
836
- document.getElementById('stat-rate').className = 'stat-value ' + (rate >= 10 ? 'success' : rate > 0 ? 'warning' : '');
837
- document.getElementById('stat-total').textContent = state.totalUpdates;
838
- document.getElementById('stat-fields').textContent = `${received}/${totalCanonical}`;
839
- document.getElementById('stat-fields').className = 'stat-value ' + (received === totalCanonical ? 'success' : 'warning');
840
- document.getElementById('stat-required').textContent = `${requiredReceived}/${totalRequired}`;
841
- document.getElementById('stat-required').className = 'stat-value ' + (requiredReceived === totalRequired ? 'success' : 'error');
842
- document.getElementById('stat-issues').textContent = state.unmappedFields.size;
843
- document.getElementById('stat-issues').className = 'stat-value ' + (state.unmappedFields.size === 0 ? 'success' : 'warning');
844
-
845
- // Only update section visibility if on telemetry tab
846
- if (currentTab !== 'telemetry') return;
847
-
848
- if (state.totalUpdates === 0) {
849
- document.getElementById('waiting-section').classList.remove('hidden');
850
- document.getElementById('coverage-section').classList.add('hidden');
851
- } else {
852
- document.getElementById('waiting-section').classList.add('hidden');
853
- document.getElementById('coverage-section').classList.remove('hidden');
854
- renderCategories();
855
- }
856
-
857
- if (state.unmappedFields.size > 0) {
858
- document.getElementById('unexpected-section').classList.remove('hidden');
859
- document.getElementById('unexpected-fields').innerHTML = Array.from(state.unmappedFields)
860
- .map(f => `<span class="unexpected-field">${f}</span>`).join('');
861
- } else {
862
- document.getElementById('unexpected-section').classList.add('hidden');
863
- }
864
- }
865
-
866
- function renderCategories() {
867
- const groups = {};
868
- for (const [canonical, def] of Object.entries(CANONICAL.fields)) {
869
- const g = def.group || 'Other';
870
- if (!groups[g]) groups[g] = [];
871
- groups[g].push({ canonical, ...def });
872
- }
873
-
874
- let html = '';
875
- for (const [group, fields] of Object.entries(groups)) {
876
- const received = fields.filter(f => state.fieldStats[f.canonical]).length;
877
- const complete = received === fields.length;
878
-
879
- html += `<div class="category-header">
880
- <span class="category-name">${group}</span>
881
- <span class="category-count ${complete ? 'complete' : 'incomplete'}">${received}/${fields.length}</span>
882
- </div>`;
883
- html += '<table class="field-table"><thead><tr><th>Canonical Field</th><th>Raw Field</th><th style="text-align:right">Value</th><th style="text-align:right">Status</th></tr></thead><tbody>';
884
-
885
- for (const field of fields) {
886
- const stats = state.fieldStats[field.canonical];
887
- const isRequired = CANONICAL.requiredFields.includes(field.canonical);
888
- let status = 'missing';
889
- let rawField = '—';
890
- let value = '—';
891
-
892
- if (stats) {
893
- status = 'ok';
894
- rawField = stats.rawField;
895
- let displayVal = stats.lastValue;
896
- // Convert ratio (0-1) to percentage (0-100) for fields marked as ratio
897
- if (typeof displayVal === 'number' && field.ratio) {
898
- displayVal = displayVal * 100;
899
- }
900
- value = typeof displayVal === 'number' ? displayVal.toFixed(2) : String(displayVal);
901
- }
902
-
903
- html += `<tr class="${status}">
904
- <td>
905
- <span class="canonical">${field.canonical}</span>
906
- ${isRequired ? '<span class="required">*</span>' : ''}
907
- <span style="color:#666;font-size:0.7rem;margin-left:0.5rem">${field.desc}</span>
908
- </td>
909
- <td><span class="raw">${rawField}</span></td>
910
- <td class="field-value">${value}${field.unit ? `<span class="field-unit">${field.unit}</span>` : ''}</td>
911
- <td style="text-align:right"><span class="badge ${status}">${status}</span></td>
912
- </tr>`;
913
- }
914
- html += '</tbody></table>';
915
- }
916
-
917
- document.getElementById('categories').innerHTML = html;
918
- }
919
-
920
- // ===========================================
921
- // CONNECTION
922
- // ===========================================
923
- async function startValidation() {
924
- const userId = document.getElementById('user-id').value.trim();
925
- if (!userId) { showError('Enter user ID'); return; }
926
-
927
- try {
928
- showError('');
929
- setStatus('listening', 'Connecting...');
930
-
931
- // Build reverse mappings from schema (canonical -> raw)
932
- reverseMappings = buildReverseMappings(currentGameId);
933
-
934
- listener = await ggClient.createListener({ userId, gameId: currentGameId });
935
- // Use full event - SDK provides both evt.raw and evt.data (normalized via @gameglue/schemas)
936
- listener.on('update', evt => processUpdate(evt));
937
-
938
- // Key events (landing, takeoff, flight phase changes)
939
- listener.on('landing', data => processKeyEvent('landing', data));
940
- listener.on('takeoff', data => processKeyEvent('takeoff', data));
941
- listener.on('flight_phase', data => processKeyEvent('flight_phase', data));
942
-
943
- setStatus('listening', 'Listening');
944
- document.getElementById('connect-btn').classList.add('hidden');
945
- document.getElementById('stop-btn').classList.remove('hidden');
946
- document.getElementById('reset-btn').classList.remove('hidden');
947
- document.getElementById('export-btn').classList.remove('hidden');
948
- document.getElementById('tabs-section').classList.remove('hidden');
949
- document.getElementById('stats-section').classList.remove('hidden');
950
- document.getElementById('key-events-section').classList.remove('hidden');
951
- document.getElementById('waiting-section').classList.remove('hidden');
952
- document.getElementById('not-listening-section').classList.add('hidden');
953
- document.getElementById('game-select').disabled = true;
954
- document.getElementById('user-id').disabled = true;
955
- renderCommands();
956
- } catch (err) {
957
- showError(err.message);
958
- setStatus('disconnected', 'Disconnected');
959
- }
960
- }
961
-
962
- function stopValidation() {
963
- listener = null;
964
- setStatus('disconnected', 'Stopped');
965
- document.getElementById('connect-btn').classList.remove('hidden');
966
- document.getElementById('stop-btn').classList.add('hidden');
967
- document.getElementById('reset-btn').classList.add('hidden');
968
- document.getElementById('export-btn').classList.add('hidden');
969
- document.getElementById('tabs-section').classList.add('hidden');
970
- document.getElementById('key-events-section').classList.add('hidden');
971
- document.getElementById('commands-section').classList.add('hidden');
972
- document.getElementById('game-select').disabled = false;
973
- document.getElementById('user-id').disabled = false;
974
- // Reset to telemetry tab
975
- currentTab = 'telemetry';
976
- document.querySelectorAll('.tab').forEach(el => {
977
- el.classList.toggle('active', el.textContent.toLowerCase() === 'telemetry');
978
- });
979
- }
980
-
981
- function resetStats() {
982
- state.totalUpdates = 0;
983
- state.timestamps = [];
984
- state.fieldStats = {};
985
- state.unmappedFields = new Set();
986
- state.keyEvents = [];
987
- commandLog = [];
988
- updateUI();
989
- updateKeyEventsUI();
990
- renderCommandLog();
991
- }
992
-
993
- function onGameChange() {
994
- currentGameId = document.getElementById('game-select').value;
995
- reverseMappings = buildReverseMappings(currentGameId);
996
- resetStats();
997
- }
998
-
999
- function getGameName() {
1000
- const opt = GAME_OPTIONS.find(g => g.gameId === currentGameId);
1001
- return opt?.name || currentGameId;
1002
- }
1003
-
1004
- function setStatus(s, text) {
1005
- const el = document.getElementById('status-badge');
1006
- el.className = 'status-badge ' + s;
1007
- el.textContent = text;
1008
- }
1009
-
1010
- function showError(msg) {
1011
- const el = document.getElementById('error-msg');
1012
- el.textContent = msg;
1013
- el.classList.toggle('hidden', !msg);
1014
- }
1015
-
1016
- function showToast(message) {
1017
- const toast = document.getElementById('toast');
1018
- toast.textContent = message;
1019
- toast.classList.add('show');
1020
- setTimeout(() => toast.classList.remove('show'), 2500);
1021
- }
1022
-
1023
- // ===========================================
1024
- // EXPORT REPORT
1025
- // ===========================================
1026
- function exportReport() {
1027
- const rate = getRate();
1028
- const totalCanonical = Object.keys(CANONICAL.fields).length;
1029
- const received = Object.keys(state.fieldStats).length;
1030
- const requiredReceived = CANONICAL.requiredFields.filter(f => state.fieldStats[f]).length;
1031
- const totalRequired = CANONICAL.requiredFields.length;
1032
-
1033
- // Group fields by category
1034
- const groups = {};
1035
- for (const [canonical, def] of Object.entries(CANONICAL.fields)) {
1036
- const g = def.group || 'Other';
1037
- if (!groups[g]) groups[g] = { total: 0, received: 0, fields: [] };
1038
- groups[g].total++;
1039
- const stats = state.fieldStats[canonical];
1040
- if (stats) {
1041
- groups[g].received++;
1042
- // Convert ratio (0-1) to percentage (0-100) for display
1043
- let displayValue = stats.lastValue;
1044
- if (typeof displayValue === 'number' && def.ratio) {
1045
- displayValue = displayValue * 100;
1046
- }
1047
- groups[g].fields.push({
1048
- canonical,
1049
- raw: stats.rawField,
1050
- value: displayValue,
1051
- unit: def.unit || ''
1052
- });
1053
- } else {
1054
- groups[g].fields.push({ canonical, raw: null, value: null, unit: def.unit || '' });
1055
- }
1056
- }
1057
-
1058
- // Build report
1059
- let report = `# Telemetry Validator Report\n\n`;
1060
- report += `**Game:** ${getGameName()} (\`${currentGameId}\`)\n`;
1061
- report += `**Timestamp:** ${new Date().toISOString()}\n\n`;
1062
-
1063
- report += `## Summary\n\n`;
1064
- report += `| Metric | Value |\n`;
1065
- report += `|--------|-------|\n`;
1066
- report += `| Update Rate | ${rate} Hz |\n`;
1067
- report += `| Total Updates | ${state.totalUpdates} |\n`;
1068
- report += `| Field Coverage | ${received}/${totalCanonical} (${Math.round(received/totalCanonical*100)}%) |\n`;
1069
- report += `| Required Fields | ${requiredReceived}/${totalRequired} (${requiredReceived === totalRequired ? 'PASS' : 'FAIL'}) |\n`;
1070
- report += `| Key Events | ${state.keyEvents.length} |\n`;
1071
- report += `| Unmapped Fields | ${state.unmappedFields.size} |\n\n`;
1072
-
1073
- // Required fields status
1074
- report += `## Required Fields\n\n`;
1075
- report += `| Field | Status | Raw Name | Value |\n`;
1076
- report += `|-------|--------|----------|-------|\n`;
1077
- for (const field of CANONICAL.requiredFields) {
1078
- const stats = state.fieldStats[field];
1079
- const status = stats ? 'OK' : 'MISSING';
1080
- const raw = stats?.rawField || '-';
1081
- const value = stats ? formatValue(stats.lastValue) : '-';
1082
- report += `| \`${field}\` | ${status} | \`${raw}\` | ${value} |\n`;
1083
- }
1084
- report += '\n';
1085
-
1086
- // Coverage by category
1087
- report += `## Field Coverage by Category\n\n`;
1088
- for (const [group, data] of Object.entries(groups)) {
1089
- const pct = Math.round(data.received / data.total * 100);
1090
- report += `### ${group} (${data.received}/${data.total} - ${pct}%)\n\n`;
1091
- report += `| Canonical | Raw | Value |\n`;
1092
- report += `|-----------|-----|-------|\n`;
1093
- for (const f of data.fields) {
1094
- const raw = f.raw ? `\`${f.raw}\`` : '-';
1095
- const val = f.value !== null ? formatValue(f.value) + (f.unit ? ` ${f.unit}` : '') : '-';
1096
- report += `| \`${f.canonical}\` | ${raw} | ${val} |\n`;
1097
- }
1098
- report += '\n';
1099
- }
1100
-
1101
- // Key Events
1102
- if (state.keyEvents.length > 0) {
1103
- report += `## Key Events\n\n`;
1104
- report += `| Time | Type | Details |\n`;
1105
- report += `|------|------|--------|\n`;
1106
- for (const evt of state.keyEvents) {
1107
- const time = new Date(evt.receivedAt).toLocaleTimeString();
1108
- const type = formatEventType(evt.eventType);
1109
- let details = '';
1110
- switch (evt.eventType) {
1111
- case 'landing':
1112
- const bounces = evt.data.bounce_count || 0;
1113
- details = `${evt.data.quality || 'unknown'} (${Math.abs(evt.data.landing_rate || 0).toFixed(0)} fpm)${bounces > 0 ? `, ${bounces} bounce${bounces > 1 ? 's' : ''}` : ''}`;
1114
- break;
1115
- case 'takeoff':
1116
- details = `Vr: ${(evt.data.rotation_speed || 0).toFixed(0)} kts, Pitch: ${(evt.data.pitch_at_liftoff || 0).toFixed(1)}°`;
1117
- break;
1118
- case 'flight_phase':
1119
- details = `${formatPhase(evt.data.previous_phase)} → ${formatPhase(evt.data.phase)}`;
1120
- break;
1121
- default:
1122
- details = JSON.stringify(evt.data);
1123
- }
1124
- report += `| ${time} | ${type} | ${details} |\n`;
1125
- }
1126
- report += '\n';
1127
- }
1128
-
1129
- // Unmapped fields
1130
- if (state.unmappedFields.size > 0) {
1131
- report += `## Unmapped Raw Fields\n\n`;
1132
- report += `These fields are being sent but don't have canonical mappings:\n\n`;
1133
- report += `\`\`\`\n${Array.from(state.unmappedFields).join(', ')}\n\`\`\`\n`;
1134
- }
1135
-
1136
- // Copy to clipboard
1137
- navigator.clipboard.writeText(report).then(() => {
1138
- showToast('Report copied to clipboard!');
1139
- }).catch(err => {
1140
- console.error('Failed to copy:', err);
1141
- showToast('Failed to copy report');
1142
- });
1143
- }
1144
-
1145
- function formatValue(val) {
1146
- if (typeof val === 'number') {
1147
- return Number.isInteger(val) ? val.toString() : val.toFixed(2);
1148
- }
1149
- if (typeof val === 'boolean') return val ? 'true' : 'false';
1150
- return String(val);
1151
- }
1152
-
1153
- // ===========================================
1154
- // COMMANDS
1155
- // ===========================================
1156
- const COMMAND_CATEGORIES = {
1157
- 'Autopilot': [
1158
- { cmd: 'autopilot_on', label: 'AP On' },
1159
- { cmd: 'autopilot_off', label: 'AP Off' },
1160
- { cmd: 'autopilot_toggle', label: 'AP Toggle' },
1161
- { cmd: 'autopilot_altitude_hold_on', label: 'ALT Hold On' },
1162
- { cmd: 'autopilot_altitude_hold_off', label: 'ALT Hold Off' },
1163
- { cmd: 'autopilot_heading_hold_on', label: 'HDG Hold On' },
1164
- { cmd: 'autopilot_heading_hold_off', label: 'HDG Hold Off' },
1165
- { cmd: 'autopilot_vs_hold_on', label: 'VS Hold On' },
1166
- { cmd: 'autopilot_vs_hold_off', label: 'VS Hold Off' },
1167
- { cmd: 'autopilot_nav_on', label: 'NAV On' },
1168
- { cmd: 'autopilot_nav_off', label: 'NAV Off' },
1169
- { cmd: 'autopilot_approach_on', label: 'APR On' },
1170
- { cmd: 'autopilot_approach_off', label: 'APR Off' },
1171
- { cmd: 'flight_director_on', label: 'FD On' },
1172
- { cmd: 'flight_director_off', label: 'FD Off' },
1173
- { cmd: 'autopilot_flc_on', label: 'FLC On' },
1174
- { cmd: 'autopilot_flc_off', label: 'FLC Off' },
1175
- { cmd: 'autopilot_flc_toggle', label: 'FLC Toggle' },
1176
- ],
1177
- 'AP Targets': [
1178
- { cmd: 'set_autopilot_altitude', label: 'Set Altitude', input: true, unit: 'ft', default: 10000 },
1179
- { cmd: 'set_autopilot_heading', label: 'Set Heading', input: true, unit: '°', default: 360 },
1180
- { cmd: 'set_autopilot_vs', label: 'Set VS', input: true, unit: 'fpm', default: 1000 },
1181
- { cmd: 'set_autopilot_speed', label: 'Set Speed', input: true, unit: 'kts', default: 250 },
1182
- ],
1183
- 'Gear & Flaps': [
1184
- { cmd: 'gear_up', label: 'Gear Up' },
1185
- { cmd: 'gear_down', label: 'Gear Down' },
1186
- { cmd: 'gear_toggle', label: 'Gear Toggle' },
1187
- { cmd: 'flaps_up', label: 'Flaps Up' },
1188
- { cmd: 'flaps_down', label: 'Flaps Down' },
1189
- { cmd: 'flaps_full', label: 'Flaps Full' },
1190
- { cmd: 'flaps_retract', label: 'Flaps Retract' },
1191
- { cmd: 'set_flaps', label: 'Set Flaps', input: true, unit: '%', default: 50 },
1192
- ],
1193
- 'Spoilers': [
1194
- { cmd: 'spoilers_arm', label: 'Arm' },
1195
- { cmd: 'spoilers_deploy', label: 'Deploy' },
1196
- { cmd: 'spoilers_retract', label: 'Retract' },
1197
- { cmd: 'spoilers_toggle', label: 'Toggle' },
1198
- ],
1199
- 'Parking Brake': [
1200
- { cmd: 'parking_brake_on', label: 'Set' },
1201
- { cmd: 'parking_brake_off', label: 'Release' },
1202
- { cmd: 'parking_brake_toggle', label: 'Toggle' },
1203
- ],
1204
- 'Throttle': [
1205
- { cmd: 'throttle_full', label: 'Full' },
1206
- { cmd: 'throttle_idle', label: 'Idle' },
1207
- { cmd: 'throttle_cutoff', label: 'Cutoff' },
1208
- { cmd: 'set_throttle', label: 'Set All', input: true, unit: '%', default: 50 },
1209
- { cmd: 'set_throttle_0', label: 'Set Eng 1', input: true, unit: '%', default: 50 },
1210
- { cmd: 'set_throttle_1', label: 'Set Eng 2', input: true, unit: '%', default: 50 },
1211
- ],
1212
- 'Landing Lights': [
1213
- { cmd: 'landing_lights_on', label: 'On' },
1214
- { cmd: 'landing_lights_off', label: 'Off' },
1215
- { cmd: 'landing_lights_toggle', label: 'Toggle' },
1216
- ],
1217
- 'Taxi Lights': [
1218
- { cmd: 'taxi_lights_on', label: 'On' },
1219
- { cmd: 'taxi_lights_off', label: 'Off' },
1220
- { cmd: 'taxi_lights_toggle', label: 'Toggle' },
1221
- ],
1222
- 'Nav Lights': [
1223
- { cmd: 'nav_lights_on', label: 'On' },
1224
- { cmd: 'nav_lights_off', label: 'Off' },
1225
- { cmd: 'nav_lights_toggle', label: 'Toggle' },
1226
- ],
1227
- 'Beacon': [
1228
- { cmd: 'beacon_lights_on', label: 'On' },
1229
- { cmd: 'beacon_lights_off', label: 'Off' },
1230
- { cmd: 'beacon_lights_toggle', label: 'Toggle' },
1231
- ],
1232
- 'Strobes': [
1233
- { cmd: 'strobe_lights_on', label: 'On' },
1234
- { cmd: 'strobe_lights_off', label: 'Off' },
1235
- { cmd: 'strobe_lights_toggle', label: 'Toggle' },
1236
- ],
1237
- 'Simulation': [
1238
- { cmd: 'pause', label: 'Pause' },
1239
- { cmd: 'unpause', label: 'Unpause' },
1240
- { cmd: 'pause_toggle', label: 'Toggle Pause' },
1241
- ],
1242
- };
1243
-
1244
- let commandLog = [];
1245
-
1246
- function renderCommands() {
1247
- const grid = document.getElementById('commands-grid');
1248
- const isConnected = listener !== null;
1249
-
1250
- let html = '';
1251
- for (const [category, commands] of Object.entries(COMMAND_CATEGORIES)) {
1252
- html += `<div class="command-category"><h3>${category}</h3><div class="command-list">`;
1253
- for (const cmd of commands) {
1254
- if (cmd.input) {
1255
- html += `
1256
- <div class="command-item">
1257
- <button class="command-btn" onclick="sendCommandWithInput('${cmd.cmd}', '${cmd.cmd}-input')" ${!isConnected ? 'disabled' : ''}>
1258
- ${cmd.label}
1259
- </button>
1260
- <input type="number" id="${cmd.cmd}-input" class="command-input" value="${cmd.default || 0}" ${!isConnected ? 'disabled' : ''}>
1261
- <span class="command-unit">${cmd.unit || ''}</span>
1262
- </div>
1263
- `;
1264
- } else {
1265
- html += `
1266
- <div class="command-item">
1267
- <button class="command-btn" onclick="sendCommand('${cmd.cmd}')" ${!isConnected ? 'disabled' : ''}>
1268
- ${cmd.label}
1269
- </button>
1270
- </div>
1271
- `;
1272
- }
1273
- }
1274
- html += '</div></div>';
1275
- }
1276
- grid.innerHTML = html;
1277
- }
1278
-
1279
- async function sendCommand(cmd) {
1280
- if (!listener) {
1281
- showToast('Not connected');
1282
- return;
1283
- }
1284
- try {
1285
- await listener.sendCommand(cmd, true);
1286
- logCommand(cmd, true);
1287
- showToast(`Sent: ${cmd}`);
1288
- } catch (err) {
1289
- console.error('Command failed:', err);
1290
- showToast(`Failed: ${err.message}`);
1291
- }
1292
- }
1293
-
1294
- async function sendCommandWithInput(cmd, inputId) {
1295
- if (!listener) {
1296
- showToast('Not connected');
1297
- return;
1298
- }
1299
- const input = document.getElementById(inputId);
1300
- let value = parseFloat(input.value);
1301
-
1302
- // Convert percentage to ratio for throttle/flaps commands
1303
- if (cmd.startsWith('set_throttle') || cmd === 'set_flaps') {
1304
- value = value / 100;
1305
- }
1306
-
1307
- try {
1308
- await listener.sendCommand(cmd, value);
1309
- logCommand(cmd, value);
1310
- showToast(`Sent: ${cmd} = ${input.value}`);
1311
- } catch (err) {
1312
- console.error('Command failed:', err);
1313
- showToast(`Failed: ${err.message}`);
1314
- }
1315
- }
1316
-
1317
- function logCommand(cmd, value) {
1318
- commandLog.unshift({
1319
- cmd,
1320
- value,
1321
- time: new Date().toLocaleTimeString()
1322
- });
1323
- if (commandLog.length > 20) commandLog.pop();
1324
- renderCommandLog();
1325
- }
1326
-
1327
- function renderCommandLog() {
1328
- const container = document.getElementById('command-log-entries');
1329
- if (commandLog.length === 0) {
1330
- container.innerHTML = '<div class="command-log-empty">No commands sent yet</div>';
1331
- return;
1332
- }
1333
- container.innerHTML = commandLog.map(entry => `
1334
- <div class="command-log-entry">
1335
- <span class="command-log-time">${entry.time}</span>
1336
- <span class="command-log-cmd">${entry.cmd}</span>
1337
- <span class="command-log-value">${typeof entry.value === 'number' ? entry.value.toFixed(2) : entry.value}</span>
1338
- </div>
1339
- `).join('');
1340
- }
1341
-
1342
- // ===========================================
1343
- // TABS
1344
- // ===========================================
1345
- let currentTab = 'telemetry';
1346
-
1347
- function switchTab(tab) {
1348
- currentTab = tab;
1349
-
1350
- // Update tab buttons
1351
- document.querySelectorAll('.tab').forEach(el => {
1352
- el.classList.toggle('active', el.textContent.trim().toLowerCase() === tab);
1353
- });
1354
-
1355
- // Show/hide tab content
1356
- const telemetrySections = ['stats-section', 'key-events-section', 'waiting-section', 'unexpected-section', 'coverage-section'];
1357
-
1358
- if (tab === 'telemetry') {
1359
- // Show telemetry sections based on state
1360
- telemetrySections.forEach(id => {
1361
- const el = document.getElementById(id);
1362
- if (!el) return;
1363
- if (id === 'stats-section') el.classList.remove('hidden');
1364
- else if (id === 'key-events-section') el.classList.remove('hidden');
1365
- else if (id === 'waiting-section' && state.totalUpdates === 0) el.classList.remove('hidden');
1366
- else if (id === 'coverage-section' && state.totalUpdates > 0) el.classList.remove('hidden');
1367
- else if (id === 'unexpected-section' && state.unmappedFields.size > 0) el.classList.remove('hidden');
1368
- else el.classList.add('hidden');
1369
- });
1370
- document.getElementById('commands-section').classList.add('hidden');
1371
- } else if (tab === 'commands') {
1372
- // Hide ALL telemetry sections
1373
- telemetrySections.forEach(id => {
1374
- const el = document.getElementById(id);
1375
- if (el) el.classList.add('hidden');
1376
- });
1377
- // Show commands section
1378
- document.getElementById('commands-section').classList.remove('hidden');
1379
- renderCommands();
1380
- }
1381
- }
1382
-
1383
- // ===========================================
1384
- // AUTH
1385
- // ===========================================
1386
- function doLogin() { ggClient.login(); }
1387
- function doLogout() {
1388
- ggClient.logout({ redirect: false });
1389
- document.getElementById('auth-section').classList.remove('hidden');
1390
- document.getElementById('dashboard').classList.add('hidden');
1391
- }
1392
-
1393
- async function init() {
1394
- // Build CANONICAL from @gameglue/schemas
1395
- CANONICAL = buildCanonicalFromSchema();
1396
- console.log(`Loaded ${Object.keys(CANONICAL.fields).length} canonical fields from schema`);
1397
-
1398
- try {
1399
- const isAuthed = await ggClient.isAuthenticated();
1400
- if (!isAuthed) return;
1401
- document.getElementById('user-id').value = ggClient.getUser();
1402
- document.getElementById('auth-section').classList.add('hidden');
1403
- document.getElementById('dashboard').classList.remove('hidden');
1404
- } catch (err) { console.error(err); }
1405
- }
1406
-
1407
- window.onload = init;
1408
- </script>
1409
- </body>
1410
- </html>
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>GameGlue Telemetry Validator</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body {
10
+ font-family: 'SF Mono', 'Consolas', monospace;
11
+ background: #0a0a0a;
12
+ color: #e2e8f0;
13
+ min-height: 100vh;
14
+ padding: 1.5rem;
15
+ }
16
+ .container { max-width: 1400px; margin: 0 auto; }
17
+ h1 { font-size: 1.25rem; margin-bottom: 0.25rem; color: #00ff88; }
18
+ .subtitle { color: #666; margin-bottom: 1.5rem; font-size: 0.875rem; }
19
+ .card {
20
+ background: #111;
21
+ border: 1px solid #222;
22
+ border-radius: 0.5rem;
23
+ padding: 1rem;
24
+ margin-bottom: 1rem;
25
+ }
26
+ .card h2 {
27
+ font-size: 0.75rem;
28
+ color: #666;
29
+ text-transform: uppercase;
30
+ letter-spacing: 0.1em;
31
+ margin-bottom: 0.75rem;
32
+ }
33
+
34
+ /* Stats */
35
+ .stats-grid {
36
+ display: grid;
37
+ grid-template-columns: repeat(6, 1fr);
38
+ gap: 1rem;
39
+ margin-bottom: 1rem;
40
+ }
41
+ .stat-card {
42
+ background: #111;
43
+ border: 1px solid #222;
44
+ border-radius: 0.5rem;
45
+ padding: 1rem;
46
+ text-align: center;
47
+ }
48
+ .stat-value { font-size: 1.5rem; font-weight: bold; color: #fff; }
49
+ .stat-value.success { color: #00ff88; }
50
+ .stat-value.warning { color: #fbbf24; }
51
+ .stat-value.error { color: #ef4444; }
52
+ .stat-label { font-size: 0.7rem; color: #666; text-transform: uppercase; margin-top: 0.25rem; }
53
+
54
+ /* Form */
55
+ .form-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 1rem; align-items: end; }
56
+ .form-group { display: flex; flex-direction: column; gap: 0.25rem; }
57
+ .form-group label { font-size: 0.7rem; color: #666; text-transform: uppercase; }
58
+ .form-group input, .form-group select {
59
+ background: #0a0a0a;
60
+ border: 1px solid #333;
61
+ color: #fff;
62
+ padding: 0.5rem 0.75rem;
63
+ border-radius: 0.25rem;
64
+ font-family: inherit;
65
+ font-size: 0.875rem;
66
+ }
67
+ .form-group input:focus, .form-group select:focus { outline: none; border-color: #00ff88; }
68
+ .btn {
69
+ padding: 0.5rem 1rem;
70
+ border: none;
71
+ border-radius: 0.25rem;
72
+ font-family: inherit;
73
+ font-size: 0.875rem;
74
+ cursor: pointer;
75
+ }
76
+ .btn-primary { background: #00ff88; color: #0a0a0a; }
77
+ .btn-secondary { background: #333; color: #fff; border: 1px solid #444; }
78
+ .btn-danger { background: #ef4444; color: #fff; }
79
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
80
+ .btn-group { display: flex; gap: 0.5rem; }
81
+
82
+ /* Status */
83
+ .status-badge {
84
+ display: inline-block;
85
+ padding: 0.25rem 0.75rem;
86
+ border-radius: 9999px;
87
+ font-size: 0.75rem;
88
+ text-transform: uppercase;
89
+ }
90
+ .status-badge.disconnected { background: #7f1d1d; color: #fca5a5; }
91
+ .status-badge.connected { background: #14532d; color: #86efac; }
92
+ .status-badge.listening { background: #1e3a5f; color: #93c5fd; }
93
+
94
+ /* Field Table */
95
+ .field-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
96
+ .field-table th {
97
+ text-align: left;
98
+ padding: 0.5rem;
99
+ border-bottom: 1px solid #333;
100
+ color: #666;
101
+ font-weight: normal;
102
+ text-transform: uppercase;
103
+ font-size: 0.7rem;
104
+ }
105
+ .field-table td { padding: 0.5rem; border-bottom: 1px solid #222; }
106
+ .field-table tr.ok { background: rgba(0, 255, 136, 0.03); }
107
+ .field-table tr.missing { background: rgba(239, 68, 68, 0.05); }
108
+ .field-table tr.warning { background: rgba(251, 191, 36, 0.05); }
109
+ .canonical { color: #00ff88; }
110
+ .raw { color: #888; font-size: 0.75rem; }
111
+ .arrow { color: #444; margin: 0 0.5rem; }
112
+ .required { color: #ef4444; margin-left: 0.25rem; }
113
+ .field-value { text-align: right; color: #fff; }
114
+ .field-unit { color: #666; margin-left: 0.25rem; }
115
+ .badge {
116
+ display: inline-block;
117
+ padding: 0.125rem 0.5rem;
118
+ border-radius: 0.25rem;
119
+ font-size: 0.65rem;
120
+ text-transform: uppercase;
121
+ border: 1px solid;
122
+ }
123
+ .badge.ok { border-color: #00ff88; color: #00ff88; }
124
+ .badge.missing { border-color: #ef4444; color: #ef4444; }
125
+ .badge.null { border-color: #fbbf24; color: #fbbf24; }
126
+ .badge.new { border-color: #3b82f6; color: #3b82f6; }
127
+
128
+ /* Category */
129
+ .category-header {
130
+ display: flex;
131
+ justify-content: space-between;
132
+ align-items: center;
133
+ padding: 0.75rem 0.5rem;
134
+ background: #0a0a0a;
135
+ margin: 1rem 0 0.5rem 0;
136
+ border-radius: 0.25rem;
137
+ }
138
+ .category-name { color: #fff; font-size: 0.875rem; }
139
+ .category-count { font-size: 0.75rem; }
140
+ .category-count.complete { color: #00ff88; }
141
+ .category-count.incomplete { color: #fbbf24; }
142
+
143
+ /* Unexpected */
144
+ .unexpected-fields { display: flex; flex-wrap: wrap; gap: 0.5rem; }
145
+ .unexpected-field {
146
+ padding: 0.25rem 0.5rem;
147
+ background: #1e3a5f;
148
+ border: 1px solid #3b82f6;
149
+ border-radius: 0.25rem;
150
+ font-size: 0.75rem;
151
+ color: #93c5fd;
152
+ }
153
+
154
+ /* Auth */
155
+ .auth-section { text-align: center; padding: 3rem; }
156
+ .auth-btn {
157
+ background: #00ff88;
158
+ color: #0a0a0a;
159
+ border: none;
160
+ padding: 0.75rem 2rem;
161
+ border-radius: 0.5rem;
162
+ font-size: 1rem;
163
+ font-weight: 600;
164
+ cursor: pointer;
165
+ font-family: inherit;
166
+ }
167
+
168
+ .hidden { display: none !important; }
169
+
170
+ /* Toast notification */
171
+ .toast {
172
+ position: fixed;
173
+ bottom: 2rem;
174
+ right: 2rem;
175
+ background: #14532d;
176
+ color: #86efac;
177
+ padding: 0.75rem 1.25rem;
178
+ border-radius: 0.5rem;
179
+ font-size: 0.875rem;
180
+ opacity: 0;
181
+ transform: translateY(1rem);
182
+ transition: all 0.3s ease;
183
+ z-index: 1000;
184
+ }
185
+ .toast.show {
186
+ opacity: 1;
187
+ transform: translateY(0);
188
+ }
189
+ .waiting { text-align: center; padding: 2rem; color: #666; }
190
+ .spinner {
191
+ display: inline-block;
192
+ width: 24px;
193
+ height: 24px;
194
+ border: 2px solid #333;
195
+ border-top-color: #00ff88;
196
+ border-radius: 50%;
197
+ animation: spin 1s linear infinite;
198
+ margin-bottom: 1rem;
199
+ }
200
+ @keyframes spin { to { transform: rotate(360deg); } }
201
+
202
+ .instructions {
203
+ background: #0a0a0a;
204
+ border: 1px solid #222;
205
+ border-radius: 0.5rem;
206
+ padding: 1rem;
207
+ margin-top: 1rem;
208
+ }
209
+ .instructions h3 {
210
+ font-size: 0.75rem;
211
+ color: #666;
212
+ text-transform: uppercase;
213
+ margin-bottom: 0.5rem;
214
+ }
215
+ .instructions ol { margin-left: 1.25rem; font-size: 0.8rem; color: #888; }
216
+ .instructions li { margin-bottom: 0.25rem; }
217
+
218
+ /* Key Events */
219
+ .key-events-list { display: flex; flex-direction: column; gap: 0.5rem; max-height: 400px; overflow-y: auto; }
220
+ .key-event {
221
+ display: flex;
222
+ align-items: flex-start;
223
+ gap: 0.75rem;
224
+ padding: 0.75rem;
225
+ background: #0a0a0a;
226
+ border: 1px solid #222;
227
+ border-radius: 0.375rem;
228
+ border-left: 3px solid;
229
+ }
230
+ .key-event.landing { border-left-color: #00ff88; }
231
+ .key-event.takeoff { border-left-color: #3b82f6; }
232
+ .key-event.flight_phase { border-left-color: #fbbf24; }
233
+ .key-event-icon {
234
+ width: 32px;
235
+ height: 32px;
236
+ display: flex;
237
+ align-items: center;
238
+ justify-content: center;
239
+ border-radius: 0.25rem;
240
+ font-size: 1rem;
241
+ flex-shrink: 0;
242
+ }
243
+ .key-event.landing .key-event-icon { background: rgba(0, 255, 136, 0.15); }
244
+ .key-event.takeoff .key-event-icon { background: rgba(59, 130, 246, 0.15); }
245
+ .key-event.flight_phase .key-event-icon { background: rgba(251, 191, 36, 0.15); }
246
+ .key-event-content { flex: 1; min-width: 0; }
247
+ .key-event-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.25rem; }
248
+ .key-event-type { font-weight: 600; font-size: 0.875rem; text-transform: capitalize; }
249
+ .key-event.landing .key-event-type { color: #00ff88; }
250
+ .key-event.takeoff .key-event-type { color: #3b82f6; }
251
+ .key-event.flight_phase .key-event-type { color: #fbbf24; }
252
+ .key-event-time { font-size: 0.7rem; color: #666; }
253
+ .key-event-details { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; font-size: 0.75rem; }
254
+ .key-event-detail { display: flex; gap: 0.25rem; }
255
+ .key-event-detail-label { color: #666; }
256
+ .key-event-detail-value { color: #fff; }
257
+ .key-event-quality { padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.65rem; text-transform: uppercase; font-weight: 600; }
258
+ .key-event-quality.butter { background: #14532d; color: #86efac; }
259
+ .key-event-quality.smooth { background: #1e3a5f; color: #93c5fd; }
260
+ .key-event-quality.normal { background: #3f3f46; color: #d4d4d8; }
261
+ .key-event-quality.firm { background: #78350f; color: #fcd34d; }
262
+ .key-event-quality.hard { background: #7c2d12; color: #fdba74; }
263
+ .key-event-quality.crash { background: #7f1d1d; color: #fca5a5; }
264
+ .key-events-empty { text-align: center; padding: 2rem; color: #666; font-size: 0.875rem; }
265
+
266
+ /* Tab Navigation */
267
+ .tabs {
268
+ display: flex;
269
+ gap: 0.5rem;
270
+ margin-bottom: 1rem;
271
+ border-bottom: 1px solid #222;
272
+ padding-bottom: 0.5rem;
273
+ }
274
+ .tab {
275
+ padding: 0.5rem 1rem;
276
+ background: transparent;
277
+ border: 1px solid transparent;
278
+ border-radius: 0.25rem 0.25rem 0 0;
279
+ color: #888;
280
+ cursor: pointer;
281
+ font-family: inherit;
282
+ font-size: 0.875rem;
283
+ transition: all 0.2s;
284
+ }
285
+ .tab:hover { color: #fff; }
286
+ .tab.active {
287
+ background: #111;
288
+ border-color: #222;
289
+ border-bottom-color: #111;
290
+ color: #00ff88;
291
+ }
292
+
293
+ /* Commands Section */
294
+ .commands-grid {
295
+ display: grid;
296
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
297
+ gap: 1rem;
298
+ }
299
+ .command-category {
300
+ background: #0a0a0a;
301
+ border: 1px solid #222;
302
+ border-radius: 0.5rem;
303
+ padding: 1rem;
304
+ }
305
+ .command-category h3 {
306
+ font-size: 0.75rem;
307
+ color: #00ff88;
308
+ text-transform: uppercase;
309
+ letter-spacing: 0.05em;
310
+ margin-bottom: 0.75rem;
311
+ padding-bottom: 0.5rem;
312
+ border-bottom: 1px solid #222;
313
+ }
314
+ .command-list { display: flex; flex-direction: column; gap: 0.5rem; }
315
+ .command-item {
316
+ display: flex;
317
+ align-items: center;
318
+ gap: 0.5rem;
319
+ }
320
+ .command-btn {
321
+ flex: 1;
322
+ padding: 0.375rem 0.75rem;
323
+ background: #1a1a1a;
324
+ border: 1px solid #333;
325
+ border-radius: 0.25rem;
326
+ color: #e2e8f0;
327
+ cursor: pointer;
328
+ font-family: inherit;
329
+ font-size: 0.75rem;
330
+ text-align: left;
331
+ transition: all 0.15s;
332
+ }
333
+ .command-btn:hover {
334
+ background: #252525;
335
+ border-color: #444;
336
+ }
337
+ .command-btn:active {
338
+ background: #00ff88;
339
+ color: #0a0a0a;
340
+ }
341
+ .command-btn:disabled {
342
+ opacity: 0.4;
343
+ cursor: not-allowed;
344
+ }
345
+ .command-input {
346
+ width: 80px;
347
+ padding: 0.375rem 0.5rem;
348
+ background: #0a0a0a;
349
+ border: 1px solid #333;
350
+ border-radius: 0.25rem;
351
+ color: #fff;
352
+ font-family: inherit;
353
+ font-size: 0.75rem;
354
+ text-align: right;
355
+ }
356
+ .command-input:focus {
357
+ outline: none;
358
+ border-color: #00ff88;
359
+ }
360
+ .command-unit {
361
+ font-size: 0.65rem;
362
+ color: #666;
363
+ min-width: 30px;
364
+ }
365
+ .command-log {
366
+ max-height: 200px;
367
+ overflow-y: auto;
368
+ font-size: 0.75rem;
369
+ background: #0a0a0a;
370
+ border: 1px solid #222;
371
+ border-radius: 0.25rem;
372
+ padding: 0.5rem;
373
+ margin-top: 1rem;
374
+ }
375
+ .command-log-entry {
376
+ padding: 0.25rem 0;
377
+ border-bottom: 1px solid #1a1a1a;
378
+ display: flex;
379
+ justify-content: space-between;
380
+ }
381
+ .command-log-entry:last-child { border-bottom: none; }
382
+ .command-log-time { color: #666; }
383
+ .command-log-cmd { color: #00ff88; }
384
+ .command-log-value { color: #fff; }
385
+ .command-log-empty { color: #666; text-align: center; padding: 1rem; }
386
+ </style>
387
+ </head>
388
+ <body>
389
+ <div class="container">
390
+ <h1>Telemetry Validator</h1>
391
+ <p class="subtitle">Validate real-time telemetry and see field normalization</p>
392
+
393
+ <!-- Auth -->
394
+ <div id="auth-section" class="card auth-section">
395
+ <p style="margin-bottom: 1rem; color: #888;">Connect to validate telemetry from your broadcaster</p>
396
+ <button class="auth-btn" onclick="doLogin()">Connect with GameGlue</button>
397
+ </div>
398
+
399
+ <!-- Dashboard -->
400
+ <div id="dashboard" class="hidden">
401
+ <!-- Connection -->
402
+ <div class="card">
403
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
404
+ <h2 style="margin: 0;">Connection</h2>
405
+ <span id="status-badge" class="status-badge disconnected">Disconnected</span>
406
+ </div>
407
+ <div class="form-row">
408
+ <div class="form-group">
409
+ <label>Game</label>
410
+ <select id="game-select" onchange="onGameChange()">
411
+ <option value="msfs">Microsoft Flight Simulator</option>
412
+ <option value="xplane">X-Plane 12</option>
413
+ </select>
414
+ </div>
415
+ <div class="form-group">
416
+ <label>User ID (Broadcaster)</label>
417
+ <input type="text" id="user-id" placeholder="Your user ID">
418
+ </div>
419
+ <div class="btn-group">
420
+ <button id="connect-btn" class="btn btn-primary" onclick="startValidation()">Start</button>
421
+ <button id="stop-btn" class="btn btn-danger hidden" onclick="stopValidation()">Stop</button>
422
+ <button id="reset-btn" class="btn btn-secondary hidden" onclick="resetStats()">Reset</button>
423
+ <button id="export-btn" class="btn btn-secondary hidden" onclick="exportReport()">Export</button>
424
+ <button class="btn btn-secondary" onclick="doLogout()">Logout</button>
425
+ </div>
426
+ </div>
427
+ <div id="error-msg" class="hidden" style="color: #ef4444; font-size: 0.8rem; margin-top: 0.5rem;"></div>
428
+ </div>
429
+
430
+ <!-- Tabs -->
431
+ <div id="tabs-section" class="hidden">
432
+ <div class="tabs">
433
+ <button class="tab active" onclick="switchTab('telemetry')">Telemetry</button>
434
+ <button class="tab" onclick="switchTab('commands')">Commands</button>
435
+ </div>
436
+ </div>
437
+
438
+ <!-- Stats -->
439
+ <div id="stats-section" class="hidden">
440
+ <div class="stats-grid">
441
+ <div class="stat-card">
442
+ <div id="stat-rate" class="stat-value">0</div>
443
+ <div class="stat-label">Hz</div>
444
+ </div>
445
+ <div class="stat-card">
446
+ <div id="stat-total" class="stat-value">0</div>
447
+ <div class="stat-label">Updates</div>
448
+ </div>
449
+ <div class="stat-card">
450
+ <div id="stat-fields" class="stat-value">0/0</div>
451
+ <div class="stat-label">Fields</div>
452
+ </div>
453
+ <div class="stat-card">
454
+ <div id="stat-required" class="stat-value">0/0</div>
455
+ <div class="stat-label">Required</div>
456
+ </div>
457
+ <div class="stat-card">
458
+ <div id="stat-key-events" class="stat-value">0</div>
459
+ <div class="stat-label">Key Events</div>
460
+ </div>
461
+ <div class="stat-card">
462
+ <div id="stat-issues" class="stat-value success">0</div>
463
+ <div class="stat-label">Issues</div>
464
+ </div>
465
+ </div>
466
+ </div>
467
+
468
+ <!-- Key Events -->
469
+ <div id="key-events-section" class="hidden">
470
+ <div class="card">
471
+ <h2>Key Events</h2>
472
+ <p style="font-size: 0.75rem; color: #666; margin-bottom: 0.75rem;">
473
+ Computed events from gg-client: landings, takeoffs, and flight phase changes.
474
+ </p>
475
+ <div id="key-events-list" class="key-events-list">
476
+ <div class="key-events-empty">No key events received yet. Fly the aircraft to trigger events.</div>
477
+ </div>
478
+ </div>
479
+ </div>
480
+
481
+ <!-- Waiting -->
482
+ <div id="waiting-section" class="hidden">
483
+ <div class="card waiting">
484
+ <div class="spinner"></div>
485
+ <p>Waiting for telemetry...</p>
486
+ <p style="font-size: 0.75rem; margin-top: 0.5rem;">Make sure gg-client is broadcasting.</p>
487
+ </div>
488
+ </div>
489
+
490
+ <!-- Unexpected Fields -->
491
+ <div id="unexpected-section" class="hidden">
492
+ <div class="card">
493
+ <h2>Unmapped Fields</h2>
494
+ <p style="font-size: 0.75rem; color: #666; margin-bottom: 0.75rem;">
495
+ These raw fields aren't mapped to canonical names yet.
496
+ </p>
497
+ <div id="unexpected-fields" class="unexpected-fields"></div>
498
+ </div>
499
+ </div>
500
+
501
+ <!-- Coverage -->
502
+ <div id="coverage-section" class="hidden">
503
+ <div class="card">
504
+ <h2>Field Coverage</h2>
505
+ <p style="font-size: 0.75rem; color: #666; margin-bottom: 0.75rem;">
506
+ <span class="canonical">Canonical</span> <span class="arrow">&larr;</span> <span class="raw">raw_field</span>
507
+ shows how game fields map to normalized names.
508
+ </p>
509
+ <div id="categories"></div>
510
+ </div>
511
+ </div>
512
+
513
+ <!-- Commands Section -->
514
+ <div id="commands-section" class="hidden">
515
+ <div class="card">
516
+ <h2>Send Commands</h2>
517
+ <p style="font-size: 0.75rem; color: #666; margin-bottom: 0.75rem;">
518
+ Send canonical commands to the simulator. Commands work across both MSFS and X-Plane.
519
+ </p>
520
+ <div id="commands-grid" class="commands-grid"></div>
521
+ <div class="command-log">
522
+ <div id="command-log-entries">
523
+ <div class="command-log-empty">No commands sent yet</div>
524
+ </div>
525
+ </div>
526
+ </div>
527
+ </div>
528
+
529
+ <!-- Not Listening -->
530
+ <div id="not-listening-section">
531
+ <div class="card" style="text-align: center; padding: 2rem;">
532
+ <p style="color: #fff; margin-bottom: 0.5rem;">Ready to validate</p>
533
+ <p style="color: #666; font-size: 0.8rem;">Select a game and click Start to begin.</p>
534
+ <div class="instructions">
535
+ <h3>About Normalization</h3>
536
+ <ol>
537
+ <li>Each sim uses different field names (e.g., MSFS: <code>indicated_airspeed</code>, X-Plane: <code>ias</code>)</li>
538
+ <li>GameGlue normalizes these to canonical names (e.g., <code>indicated_airspeed</code>)</li>
539
+ <li>This lets you write code once that works across all supported sims</li>
540
+ </ol>
541
+ </div>
542
+ </div>
543
+ </div>
544
+ </div>
545
+ </div>
546
+
547
+ <!-- Toast notification -->
548
+ <div id="toast" class="toast">Report copied to clipboard!</div>
549
+
550
+ <script src="../dist/gg.umd.js"></script>
551
+ <script>
552
+ // ===========================================
553
+ // CANONICAL SCHEMA (loaded from @gameglue/schemas)
554
+ // ===========================================
555
+ // Fields that are 0-1 ratios but displayed as percentages
556
+ const RATIO_FIELDS = ['throttle_0', 'throttle_1', 'throttle_2', 'throttle_3', 'flaps', 'spoiler'];
557
+
558
+ // Unit display mapping (schema units -> display symbols)
559
+ const UNIT_DISPLAY = {
560
+ 'degrees': '°',
561
+ 'deg': '°',
562
+ 'ft': 'ft',
563
+ 'kts': 'kts',
564
+ 'fpm': 'fpm',
565
+ 'lbs': 'lbs',
566
+ 'lbs/hr': 'lbs/hr',
567
+ 'rpm': 'RPM',
568
+ '%': '%',
569
+ 'ratio': '%',
570
+ '°C': '°C',
571
+ 'G': 'G',
572
+ };
573
+
574
+ // Build CANONICAL from category schema
575
+ function buildCanonicalFromSchema() {
576
+ const categorySchema = window.GameGlueSchemas?.getCategorySchema('flight_sim');
577
+ if (!categorySchema) {
578
+ console.warn('Category schema not available, using fallback');
579
+ return { requiredFields: [], fields: {} };
580
+ }
581
+
582
+ const fields = {};
583
+ for (const [fieldName, fieldDef] of Object.entries(categorySchema.fields)) {
584
+ fields[fieldName] = {
585
+ group: fieldDef.group || 'Other',
586
+ desc: fieldDef.description || fieldName,
587
+ unit: UNIT_DISPLAY[fieldDef.unit] || fieldDef.unit || '',
588
+ type: fieldDef.type,
589
+ ratio: RATIO_FIELDS.includes(fieldName),
590
+ };
591
+ }
592
+
593
+ return {
594
+ requiredFields: categorySchema.requiredFields || [],
595
+ fields
596
+ };
597
+ }
598
+
599
+ // Initialize after SDK loads
600
+ let CANONICAL = { requiredFields: [], fields: {} };
601
+
602
+ // ===========================================
603
+ // GAME SELECTION (schemas from @gameglue/schemas via SDK)
604
+ // ===========================================
605
+ const GAME_OPTIONS = [
606
+ { gameId: 'msfs', name: 'Microsoft Flight Simulator' },
607
+ { gameId: 'xplane', name: 'X-Plane 12' }
608
+ ];
609
+
610
+ // Build reverse mappings (canonical -> raw) from schema
611
+ function buildReverseMappings(gameId) {
612
+ const schema = window.GameGlueSchemas?.getGameSchema(gameId);
613
+ if (!schema?.fieldMappings) return {};
614
+
615
+ const reverse = {};
616
+ for (const [rawField, mapping] of Object.entries(schema.fieldMappings)) {
617
+ const canonical = mapping.canonical;
618
+ if (!reverse[canonical]) {
619
+ reverse[canonical] = rawField;
620
+ }
621
+ }
622
+ return reverse;
623
+ }
624
+
625
+ // ===========================================
626
+ // STATE
627
+ // ===========================================
628
+ const CLIENT_ID = 'gameglue-sdk-examples';
629
+ const REDIRECT_URI = window.location.href.split('?')[0].split('#')[0];
630
+ // For local development, add socketUrl: 'http://localhost:3031'
631
+ const ggClient = new GameGlue({ clientId: CLIENT_ID, redirect_uri: REDIRECT_URI, scopes: ['msfs:read', 'msfs:write', 'xplane:read', 'xplane:write'] });
632
+
633
+ let listener = null;
634
+ let currentGameId = 'msfs';
635
+ let reverseMappings = {}; // canonical -> raw field name
636
+ let state = {
637
+ totalUpdates: 0,
638
+ timestamps: [],
639
+ fieldStats: {}, // canonical -> { rawField, lastValue, updateCount }
640
+ unmappedFields: new Set(),
641
+ keyEvents: [] // Array of { eventType, data, receivedAt }
642
+ };
643
+
644
+ // ===========================================
645
+ // PROCESS TELEMETRY (using SDK's normalized data)
646
+ // ===========================================
647
+ function processUpdate(evt) {
648
+ const now = Date.now();
649
+ state.totalUpdates++;
650
+ state.timestamps.push(now);
651
+ if (state.timestamps.length > 100) state.timestamps = state.timestamps.slice(-100);
652
+
653
+ const normalizedData = evt.data || {};
654
+ const rawData = evt.raw || {};
655
+
656
+ // Track canonical fields (already normalized by SDK via @gameglue/schemas)
657
+ for (const [canonical, value] of Object.entries(normalizedData)) {
658
+ // Find which raw field produced this canonical field
659
+ const rawField = reverseMappings[canonical] || canonical;
660
+ const existing = state.fieldStats[canonical] || { rawField, updateCount: 0 };
661
+ state.fieldStats[canonical] = {
662
+ rawField,
663
+ lastValue: value,
664
+ updateCount: existing.updateCount + 1
665
+ };
666
+ }
667
+
668
+ // Track unmapped raw fields (not in schema)
669
+ for (const rawField of Object.keys(rawData)) {
670
+ const schema = window.GameGlueSchemas?.getGameSchema(currentGameId);
671
+ if (schema?.fieldMappings && !schema.fieldMappings[rawField]) {
672
+ state.unmappedFields.add(rawField);
673
+ }
674
+ }
675
+
676
+ updateUI();
677
+ }
678
+
679
+ function getRate() {
680
+ const now = Date.now();
681
+ return state.timestamps.filter(t => now - t < 1000).length;
682
+ }
683
+
684
+ // ===========================================
685
+ // KEY EVENTS
686
+ // ===========================================
687
+ function processKeyEvent(eventType, data) {
688
+ state.keyEvents.unshift({
689
+ eventType,
690
+ data,
691
+ receivedAt: Date.now()
692
+ });
693
+ // Keep last 50 events
694
+ if (state.keyEvents.length > 50) {
695
+ state.keyEvents.pop();
696
+ }
697
+ updateKeyEventsUI();
698
+ }
699
+
700
+ function updateKeyEventsUI() {
701
+ const list = document.getElementById('key-events-list');
702
+ const stat = document.getElementById('stat-key-events');
703
+
704
+ stat.textContent = state.keyEvents.length;
705
+ stat.className = 'stat-value ' + (state.keyEvents.length > 0 ? 'success' : '');
706
+
707
+ if (state.keyEvents.length === 0) {
708
+ list.innerHTML = '<div class="key-events-empty">No key events received yet. Fly the aircraft to trigger landings, takeoffs, and phase changes.</div>';
709
+ return;
710
+ }
711
+
712
+ list.innerHTML = state.keyEvents.map(evt => renderKeyEvent(evt)).join('');
713
+ }
714
+
715
+ function renderKeyEvent(evt) {
716
+ const { eventType, data, receivedAt } = evt;
717
+ const time = new Date(receivedAt).toLocaleTimeString();
718
+ const icon = getEventIcon(eventType);
719
+ const details = getEventDetails(eventType, data);
720
+
721
+ return `
722
+ <div class="key-event ${eventType}">
723
+ <div class="key-event-icon">${icon}</div>
724
+ <div class="key-event-content">
725
+ <div class="key-event-header">
726
+ <span class="key-event-type">${formatEventType(eventType)}</span>
727
+ <span class="key-event-time">${time}</span>
728
+ </div>
729
+ <div class="key-event-details">${details}</div>
730
+ </div>
731
+ </div>
732
+ `;
733
+ }
734
+
735
+ function getEventIcon(eventType) {
736
+ switch (eventType) {
737
+ case 'landing': return '&#9992;'; // airplane
738
+ case 'takeoff': return '&#128640;'; // rocket
739
+ case 'flight_phase': return '&#128508;'; // round pushpin
740
+ default: return '&#9679;'; // bullet
741
+ }
742
+ }
743
+
744
+ function formatEventType(eventType) {
745
+ switch (eventType) {
746
+ case 'landing': return 'Landing';
747
+ case 'takeoff': return 'Takeoff';
748
+ case 'flight_phase': return 'Phase Change';
749
+ default: return eventType;
750
+ }
751
+ }
752
+
753
+ function getEventDetails(eventType, data) {
754
+ switch (eventType) {
755
+ case 'landing':
756
+ const bounceCount = data.bounce_count || 0;
757
+ const bounceInfo = bounceCount > 0
758
+ ? `<span class="key-event-detail"><span class="key-event-detail-label">Bounces:</span><span class="key-event-detail-value" style="color:#fbbf24">${bounceCount}</span></span>`
759
+ : '';
760
+ return `
761
+ <span class="key-event-quality ${data.quality || 'normal'}">${data.quality || 'unknown'}</span>
762
+ <span class="key-event-detail">
763
+ <span class="key-event-detail-label">Rate:</span>
764
+ <span class="key-event-detail-value">${Math.abs(data.landing_rate || 0).toFixed(0)} fpm</span>
765
+ </span>
766
+ <span class="key-event-detail">
767
+ <span class="key-event-detail-label">Speed:</span>
768
+ <span class="key-event-detail-value">${(data.speed_at_touchdown || 0).toFixed(0)} kts</span>
769
+ </span>
770
+ <span class="key-event-detail">
771
+ <span class="key-event-detail-label">Pitch:</span>
772
+ <span class="key-event-detail-value">${(data.pitch_at_touchdown || 0).toFixed(1)}°</span>
773
+ </span>
774
+ ${bounceInfo}
775
+ `;
776
+ case 'takeoff':
777
+ return `
778
+ <span class="key-event-detail">
779
+ <span class="key-event-detail-label">Rotation:</span>
780
+ <span class="key-event-detail-value">${(data.rotation_speed || 0).toFixed(0)} kts</span>
781
+ </span>
782
+ <span class="key-event-detail">
783
+ <span class="key-event-detail-label">Pitch:</span>
784
+ <span class="key-event-detail-value">${(data.pitch_at_liftoff || 0).toFixed(1)}°</span>
785
+ </span>
786
+ <span class="key-event-detail">
787
+ <span class="key-event-detail-label">Flaps:</span>
788
+ <span class="key-event-detail-value">${((data.flaps_setting || 0) * 100).toFixed(0)}%</span>
789
+ </span>
790
+ `;
791
+ case 'flight_phase':
792
+ return `
793
+ <span class="key-event-detail">
794
+ <span class="key-event-detail-label">Phase:</span>
795
+ <span class="key-event-detail-value">${formatPhase(data.phase)}</span>
796
+ </span>
797
+ <span class="key-event-detail">
798
+ <span class="key-event-detail-label">From:</span>
799
+ <span class="key-event-detail-value">${formatPhase(data.previous_phase)}</span>
800
+ </span>
801
+ <span class="key-event-detail">
802
+ <span class="key-event-detail-label">MSL:</span>
803
+ <span class="key-event-detail-value">${(data.altitude_msl || 0).toFixed(0)} ft</span>
804
+ </span>
805
+ <span class="key-event-detail">
806
+ <span class="key-event-detail-label">AGL:</span>
807
+ <span class="key-event-detail-value">${(data.altitude_agl || 0).toFixed(0)} ft</span>
808
+ </span>
809
+ <span class="key-event-detail">
810
+ <span class="key-event-detail-label">Speed:</span>
811
+ <span class="key-event-detail-value">${(data.speed || 0).toFixed(0)} kts</span>
812
+ </span>
813
+ `;
814
+ default:
815
+ return `<span class="key-event-detail"><span class="key-event-detail-value">${JSON.stringify(data)}</span></span>`;
816
+ }
817
+ }
818
+
819
+ function formatPhase(phase) {
820
+ if (!phase) return 'unknown';
821
+ return phase.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
822
+ }
823
+
824
+ // ===========================================
825
+ // UI
826
+ // ===========================================
827
+ function updateUI() {
828
+ const rate = getRate();
829
+ const totalCanonical = Object.keys(CANONICAL.fields).length;
830
+ const received = Object.keys(state.fieldStats).length;
831
+ const requiredReceived = CANONICAL.requiredFields.filter(f => state.fieldStats[f]).length;
832
+ const totalRequired = CANONICAL.requiredFields.length;
833
+
834
+ // Update stats (always visible in stats-section)
835
+ document.getElementById('stat-rate').textContent = rate;
836
+ document.getElementById('stat-rate').className = 'stat-value ' + (rate >= 10 ? 'success' : rate > 0 ? 'warning' : '');
837
+ document.getElementById('stat-total').textContent = state.totalUpdates;
838
+ document.getElementById('stat-fields').textContent = `${received}/${totalCanonical}`;
839
+ document.getElementById('stat-fields').className = 'stat-value ' + (received === totalCanonical ? 'success' : 'warning');
840
+ document.getElementById('stat-required').textContent = `${requiredReceived}/${totalRequired}`;
841
+ document.getElementById('stat-required').className = 'stat-value ' + (requiredReceived === totalRequired ? 'success' : 'error');
842
+ document.getElementById('stat-issues').textContent = state.unmappedFields.size;
843
+ document.getElementById('stat-issues').className = 'stat-value ' + (state.unmappedFields.size === 0 ? 'success' : 'warning');
844
+
845
+ // Only update section visibility if on telemetry tab
846
+ if (currentTab !== 'telemetry') return;
847
+
848
+ if (state.totalUpdates === 0) {
849
+ document.getElementById('waiting-section').classList.remove('hidden');
850
+ document.getElementById('coverage-section').classList.add('hidden');
851
+ } else {
852
+ document.getElementById('waiting-section').classList.add('hidden');
853
+ document.getElementById('coverage-section').classList.remove('hidden');
854
+ renderCategories();
855
+ }
856
+
857
+ if (state.unmappedFields.size > 0) {
858
+ document.getElementById('unexpected-section').classList.remove('hidden');
859
+ document.getElementById('unexpected-fields').innerHTML = Array.from(state.unmappedFields)
860
+ .map(f => `<span class="unexpected-field">${f}</span>`).join('');
861
+ } else {
862
+ document.getElementById('unexpected-section').classList.add('hidden');
863
+ }
864
+ }
865
+
866
+ function renderCategories() {
867
+ const groups = {};
868
+ for (const [canonical, def] of Object.entries(CANONICAL.fields)) {
869
+ const g = def.group || 'Other';
870
+ if (!groups[g]) groups[g] = [];
871
+ groups[g].push({ canonical, ...def });
872
+ }
873
+
874
+ let html = '';
875
+ for (const [group, fields] of Object.entries(groups)) {
876
+ const received = fields.filter(f => state.fieldStats[f.canonical]).length;
877
+ const complete = received === fields.length;
878
+
879
+ html += `<div class="category-header">
880
+ <span class="category-name">${group}</span>
881
+ <span class="category-count ${complete ? 'complete' : 'incomplete'}">${received}/${fields.length}</span>
882
+ </div>`;
883
+ html += '<table class="field-table"><thead><tr><th>Canonical Field</th><th>Raw Field</th><th style="text-align:right">Value</th><th style="text-align:right">Status</th></tr></thead><tbody>';
884
+
885
+ for (const field of fields) {
886
+ const stats = state.fieldStats[field.canonical];
887
+ const isRequired = CANONICAL.requiredFields.includes(field.canonical);
888
+ let status = 'missing';
889
+ let rawField = '—';
890
+ let value = '—';
891
+
892
+ if (stats) {
893
+ status = 'ok';
894
+ rawField = stats.rawField;
895
+ let displayVal = stats.lastValue;
896
+ // Convert ratio (0-1) to percentage (0-100) for fields marked as ratio
897
+ if (typeof displayVal === 'number' && field.ratio) {
898
+ displayVal = displayVal * 100;
899
+ }
900
+ value = typeof displayVal === 'number' ? displayVal.toFixed(2) : String(displayVal);
901
+ }
902
+
903
+ html += `<tr class="${status}">
904
+ <td>
905
+ <span class="canonical">${field.canonical}</span>
906
+ ${isRequired ? '<span class="required">*</span>' : ''}
907
+ <span style="color:#666;font-size:0.7rem;margin-left:0.5rem">${field.desc}</span>
908
+ </td>
909
+ <td><span class="raw">${rawField}</span></td>
910
+ <td class="field-value">${value}${field.unit ? `<span class="field-unit">${field.unit}</span>` : ''}</td>
911
+ <td style="text-align:right"><span class="badge ${status}">${status}</span></td>
912
+ </tr>`;
913
+ }
914
+ html += '</tbody></table>';
915
+ }
916
+
917
+ document.getElementById('categories').innerHTML = html;
918
+ }
919
+
920
+ // ===========================================
921
+ // CONNECTION
922
+ // ===========================================
923
+ async function startValidation() {
924
+ const userId = document.getElementById('user-id').value.trim();
925
+ if (!userId) { showError('Enter user ID'); return; }
926
+
927
+ try {
928
+ showError('');
929
+ setStatus('listening', 'Connecting...');
930
+
931
+ // Build reverse mappings from schema (canonical -> raw)
932
+ reverseMappings = buildReverseMappings(currentGameId);
933
+
934
+ listener = await ggClient.createListener({ userId, gameId: currentGameId });
935
+ // Use full event - SDK provides both evt.raw and evt.data (normalized via @gameglue/schemas)
936
+ listener.on('update', evt => processUpdate(evt));
937
+
938
+ // Key events (landing, takeoff, flight phase changes)
939
+ listener.on('landing', data => processKeyEvent('landing', data));
940
+ listener.on('takeoff', data => processKeyEvent('takeoff', data));
941
+ listener.on('flight_phase', data => processKeyEvent('flight_phase', data));
942
+
943
+ setStatus('listening', 'Listening');
944
+ document.getElementById('connect-btn').classList.add('hidden');
945
+ document.getElementById('stop-btn').classList.remove('hidden');
946
+ document.getElementById('reset-btn').classList.remove('hidden');
947
+ document.getElementById('export-btn').classList.remove('hidden');
948
+ document.getElementById('tabs-section').classList.remove('hidden');
949
+ document.getElementById('stats-section').classList.remove('hidden');
950
+ document.getElementById('key-events-section').classList.remove('hidden');
951
+ document.getElementById('waiting-section').classList.remove('hidden');
952
+ document.getElementById('not-listening-section').classList.add('hidden');
953
+ document.getElementById('game-select').disabled = true;
954
+ document.getElementById('user-id').disabled = true;
955
+ renderCommands();
956
+ } catch (err) {
957
+ showError(err.message);
958
+ setStatus('disconnected', 'Disconnected');
959
+ }
960
+ }
961
+
962
+ function stopValidation() {
963
+ listener = null;
964
+ setStatus('disconnected', 'Stopped');
965
+ document.getElementById('connect-btn').classList.remove('hidden');
966
+ document.getElementById('stop-btn').classList.add('hidden');
967
+ document.getElementById('reset-btn').classList.add('hidden');
968
+ document.getElementById('export-btn').classList.add('hidden');
969
+ document.getElementById('tabs-section').classList.add('hidden');
970
+ document.getElementById('key-events-section').classList.add('hidden');
971
+ document.getElementById('commands-section').classList.add('hidden');
972
+ document.getElementById('game-select').disabled = false;
973
+ document.getElementById('user-id').disabled = false;
974
+ // Reset to telemetry tab
975
+ currentTab = 'telemetry';
976
+ document.querySelectorAll('.tab').forEach(el => {
977
+ el.classList.toggle('active', el.textContent.toLowerCase() === 'telemetry');
978
+ });
979
+ }
980
+
981
+ function resetStats() {
982
+ state.totalUpdates = 0;
983
+ state.timestamps = [];
984
+ state.fieldStats = {};
985
+ state.unmappedFields = new Set();
986
+ state.keyEvents = [];
987
+ commandLog = [];
988
+ updateUI();
989
+ updateKeyEventsUI();
990
+ renderCommandLog();
991
+ }
992
+
993
+ function onGameChange() {
994
+ currentGameId = document.getElementById('game-select').value;
995
+ reverseMappings = buildReverseMappings(currentGameId);
996
+ resetStats();
997
+ }
998
+
999
+ function getGameName() {
1000
+ const opt = GAME_OPTIONS.find(g => g.gameId === currentGameId);
1001
+ return opt?.name || currentGameId;
1002
+ }
1003
+
1004
+ function setStatus(s, text) {
1005
+ const el = document.getElementById('status-badge');
1006
+ el.className = 'status-badge ' + s;
1007
+ el.textContent = text;
1008
+ }
1009
+
1010
+ function showError(msg) {
1011
+ const el = document.getElementById('error-msg');
1012
+ el.textContent = msg;
1013
+ el.classList.toggle('hidden', !msg);
1014
+ }
1015
+
1016
+ function showToast(message) {
1017
+ const toast = document.getElementById('toast');
1018
+ toast.textContent = message;
1019
+ toast.classList.add('show');
1020
+ setTimeout(() => toast.classList.remove('show'), 2500);
1021
+ }
1022
+
1023
+ // ===========================================
1024
+ // EXPORT REPORT
1025
+ // ===========================================
1026
+ function exportReport() {
1027
+ const rate = getRate();
1028
+ const totalCanonical = Object.keys(CANONICAL.fields).length;
1029
+ const received = Object.keys(state.fieldStats).length;
1030
+ const requiredReceived = CANONICAL.requiredFields.filter(f => state.fieldStats[f]).length;
1031
+ const totalRequired = CANONICAL.requiredFields.length;
1032
+
1033
+ // Group fields by category
1034
+ const groups = {};
1035
+ for (const [canonical, def] of Object.entries(CANONICAL.fields)) {
1036
+ const g = def.group || 'Other';
1037
+ if (!groups[g]) groups[g] = { total: 0, received: 0, fields: [] };
1038
+ groups[g].total++;
1039
+ const stats = state.fieldStats[canonical];
1040
+ if (stats) {
1041
+ groups[g].received++;
1042
+ // Convert ratio (0-1) to percentage (0-100) for display
1043
+ let displayValue = stats.lastValue;
1044
+ if (typeof displayValue === 'number' && def.ratio) {
1045
+ displayValue = displayValue * 100;
1046
+ }
1047
+ groups[g].fields.push({
1048
+ canonical,
1049
+ raw: stats.rawField,
1050
+ value: displayValue,
1051
+ unit: def.unit || ''
1052
+ });
1053
+ } else {
1054
+ groups[g].fields.push({ canonical, raw: null, value: null, unit: def.unit || '' });
1055
+ }
1056
+ }
1057
+
1058
+ // Build report
1059
+ let report = `# Telemetry Validator Report\n\n`;
1060
+ report += `**Game:** ${getGameName()} (\`${currentGameId}\`)\n`;
1061
+ report += `**Timestamp:** ${new Date().toISOString()}\n\n`;
1062
+
1063
+ report += `## Summary\n\n`;
1064
+ report += `| Metric | Value |\n`;
1065
+ report += `|--------|-------|\n`;
1066
+ report += `| Update Rate | ${rate} Hz |\n`;
1067
+ report += `| Total Updates | ${state.totalUpdates} |\n`;
1068
+ report += `| Field Coverage | ${received}/${totalCanonical} (${Math.round(received/totalCanonical*100)}%) |\n`;
1069
+ report += `| Required Fields | ${requiredReceived}/${totalRequired} (${requiredReceived === totalRequired ? 'PASS' : 'FAIL'}) |\n`;
1070
+ report += `| Key Events | ${state.keyEvents.length} |\n`;
1071
+ report += `| Unmapped Fields | ${state.unmappedFields.size} |\n\n`;
1072
+
1073
+ // Required fields status
1074
+ report += `## Required Fields\n\n`;
1075
+ report += `| Field | Status | Raw Name | Value |\n`;
1076
+ report += `|-------|--------|----------|-------|\n`;
1077
+ for (const field of CANONICAL.requiredFields) {
1078
+ const stats = state.fieldStats[field];
1079
+ const status = stats ? 'OK' : 'MISSING';
1080
+ const raw = stats?.rawField || '-';
1081
+ const value = stats ? formatValue(stats.lastValue) : '-';
1082
+ report += `| \`${field}\` | ${status} | \`${raw}\` | ${value} |\n`;
1083
+ }
1084
+ report += '\n';
1085
+
1086
+ // Coverage by category
1087
+ report += `## Field Coverage by Category\n\n`;
1088
+ for (const [group, data] of Object.entries(groups)) {
1089
+ const pct = Math.round(data.received / data.total * 100);
1090
+ report += `### ${group} (${data.received}/${data.total} - ${pct}%)\n\n`;
1091
+ report += `| Canonical | Raw | Value |\n`;
1092
+ report += `|-----------|-----|-------|\n`;
1093
+ for (const f of data.fields) {
1094
+ const raw = f.raw ? `\`${f.raw}\`` : '-';
1095
+ const val = f.value !== null ? formatValue(f.value) + (f.unit ? ` ${f.unit}` : '') : '-';
1096
+ report += `| \`${f.canonical}\` | ${raw} | ${val} |\n`;
1097
+ }
1098
+ report += '\n';
1099
+ }
1100
+
1101
+ // Key Events
1102
+ if (state.keyEvents.length > 0) {
1103
+ report += `## Key Events\n\n`;
1104
+ report += `| Time | Type | Details |\n`;
1105
+ report += `|------|------|--------|\n`;
1106
+ for (const evt of state.keyEvents) {
1107
+ const time = new Date(evt.receivedAt).toLocaleTimeString();
1108
+ const type = formatEventType(evt.eventType);
1109
+ let details = '';
1110
+ switch (evt.eventType) {
1111
+ case 'landing':
1112
+ const bounces = evt.data.bounce_count || 0;
1113
+ details = `${evt.data.quality || 'unknown'} (${Math.abs(evt.data.landing_rate || 0).toFixed(0)} fpm)${bounces > 0 ? `, ${bounces} bounce${bounces > 1 ? 's' : ''}` : ''}`;
1114
+ break;
1115
+ case 'takeoff':
1116
+ details = `Vr: ${(evt.data.rotation_speed || 0).toFixed(0)} kts, Pitch: ${(evt.data.pitch_at_liftoff || 0).toFixed(1)}°`;
1117
+ break;
1118
+ case 'flight_phase':
1119
+ details = `${formatPhase(evt.data.previous_phase)} → ${formatPhase(evt.data.phase)}`;
1120
+ break;
1121
+ default:
1122
+ details = JSON.stringify(evt.data);
1123
+ }
1124
+ report += `| ${time} | ${type} | ${details} |\n`;
1125
+ }
1126
+ report += '\n';
1127
+ }
1128
+
1129
+ // Unmapped fields
1130
+ if (state.unmappedFields.size > 0) {
1131
+ report += `## Unmapped Raw Fields\n\n`;
1132
+ report += `These fields are being sent but don't have canonical mappings:\n\n`;
1133
+ report += `\`\`\`\n${Array.from(state.unmappedFields).join(', ')}\n\`\`\`\n`;
1134
+ }
1135
+
1136
+ // Copy to clipboard
1137
+ navigator.clipboard.writeText(report).then(() => {
1138
+ showToast('Report copied to clipboard!');
1139
+ }).catch(err => {
1140
+ console.error('Failed to copy:', err);
1141
+ showToast('Failed to copy report');
1142
+ });
1143
+ }
1144
+
1145
+ function formatValue(val) {
1146
+ if (typeof val === 'number') {
1147
+ return Number.isInteger(val) ? val.toString() : val.toFixed(2);
1148
+ }
1149
+ if (typeof val === 'boolean') return val ? 'true' : 'false';
1150
+ return String(val);
1151
+ }
1152
+
1153
+ // ===========================================
1154
+ // COMMANDS
1155
+ // ===========================================
1156
+ const COMMAND_CATEGORIES = {
1157
+ 'Autopilot': [
1158
+ { cmd: 'autopilot_on', label: 'AP On' },
1159
+ { cmd: 'autopilot_off', label: 'AP Off' },
1160
+ { cmd: 'autopilot_toggle', label: 'AP Toggle' },
1161
+ { cmd: 'autopilot_altitude_hold_on', label: 'ALT Hold On' },
1162
+ { cmd: 'autopilot_altitude_hold_off', label: 'ALT Hold Off' },
1163
+ { cmd: 'autopilot_heading_hold_on', label: 'HDG Hold On' },
1164
+ { cmd: 'autopilot_heading_hold_off', label: 'HDG Hold Off' },
1165
+ { cmd: 'autopilot_vs_hold_on', label: 'VS Hold On' },
1166
+ { cmd: 'autopilot_vs_hold_off', label: 'VS Hold Off' },
1167
+ { cmd: 'autopilot_nav_on', label: 'NAV On' },
1168
+ { cmd: 'autopilot_nav_off', label: 'NAV Off' },
1169
+ { cmd: 'autopilot_approach_on', label: 'APR On' },
1170
+ { cmd: 'autopilot_approach_off', label: 'APR Off' },
1171
+ { cmd: 'flight_director_on', label: 'FD On' },
1172
+ { cmd: 'flight_director_off', label: 'FD Off' },
1173
+ { cmd: 'autopilot_flc_on', label: 'FLC On' },
1174
+ { cmd: 'autopilot_flc_off', label: 'FLC Off' },
1175
+ { cmd: 'autopilot_flc_toggle', label: 'FLC Toggle' },
1176
+ ],
1177
+ 'AP Targets': [
1178
+ { cmd: 'set_autopilot_altitude', label: 'Set Altitude', input: true, unit: 'ft', default: 10000 },
1179
+ { cmd: 'set_autopilot_heading', label: 'Set Heading', input: true, unit: '°', default: 360 },
1180
+ { cmd: 'set_autopilot_vs', label: 'Set VS', input: true, unit: 'fpm', default: 1000 },
1181
+ { cmd: 'set_autopilot_speed', label: 'Set Speed', input: true, unit: 'kts', default: 250 },
1182
+ ],
1183
+ 'Gear & Flaps': [
1184
+ { cmd: 'gear_up', label: 'Gear Up' },
1185
+ { cmd: 'gear_down', label: 'Gear Down' },
1186
+ { cmd: 'gear_toggle', label: 'Gear Toggle' },
1187
+ { cmd: 'flaps_up', label: 'Flaps Up' },
1188
+ { cmd: 'flaps_down', label: 'Flaps Down' },
1189
+ { cmd: 'flaps_full', label: 'Flaps Full' },
1190
+ { cmd: 'flaps_retract', label: 'Flaps Retract' },
1191
+ { cmd: 'set_flaps', label: 'Set Flaps', input: true, unit: '%', default: 50 },
1192
+ ],
1193
+ 'Spoilers': [
1194
+ { cmd: 'spoilers_arm', label: 'Arm' },
1195
+ { cmd: 'spoilers_deploy', label: 'Deploy' },
1196
+ { cmd: 'spoilers_retract', label: 'Retract' },
1197
+ { cmd: 'spoilers_toggle', label: 'Toggle' },
1198
+ ],
1199
+ 'Parking Brake': [
1200
+ { cmd: 'parking_brake_on', label: 'Set' },
1201
+ { cmd: 'parking_brake_off', label: 'Release' },
1202
+ { cmd: 'parking_brake_toggle', label: 'Toggle' },
1203
+ ],
1204
+ 'Throttle': [
1205
+ { cmd: 'throttle_full', label: 'Full' },
1206
+ { cmd: 'throttle_idle', label: 'Idle' },
1207
+ { cmd: 'throttle_cutoff', label: 'Cutoff' },
1208
+ { cmd: 'set_throttle', label: 'Set All', input: true, unit: '%', default: 50 },
1209
+ { cmd: 'set_throttle_0', label: 'Set Eng 1', input: true, unit: '%', default: 50 },
1210
+ { cmd: 'set_throttle_1', label: 'Set Eng 2', input: true, unit: '%', default: 50 },
1211
+ ],
1212
+ 'Landing Lights': [
1213
+ { cmd: 'landing_lights_on', label: 'On' },
1214
+ { cmd: 'landing_lights_off', label: 'Off' },
1215
+ { cmd: 'landing_lights_toggle', label: 'Toggle' },
1216
+ ],
1217
+ 'Taxi Lights': [
1218
+ { cmd: 'taxi_lights_on', label: 'On' },
1219
+ { cmd: 'taxi_lights_off', label: 'Off' },
1220
+ { cmd: 'taxi_lights_toggle', label: 'Toggle' },
1221
+ ],
1222
+ 'Nav Lights': [
1223
+ { cmd: 'nav_lights_on', label: 'On' },
1224
+ { cmd: 'nav_lights_off', label: 'Off' },
1225
+ { cmd: 'nav_lights_toggle', label: 'Toggle' },
1226
+ ],
1227
+ 'Beacon': [
1228
+ { cmd: 'beacon_lights_on', label: 'On' },
1229
+ { cmd: 'beacon_lights_off', label: 'Off' },
1230
+ { cmd: 'beacon_lights_toggle', label: 'Toggle' },
1231
+ ],
1232
+ 'Strobes': [
1233
+ { cmd: 'strobe_lights_on', label: 'On' },
1234
+ { cmd: 'strobe_lights_off', label: 'Off' },
1235
+ { cmd: 'strobe_lights_toggle', label: 'Toggle' },
1236
+ ],
1237
+ 'Simulation': [
1238
+ { cmd: 'pause', label: 'Pause' },
1239
+ { cmd: 'unpause', label: 'Unpause' },
1240
+ { cmd: 'pause_toggle', label: 'Toggle Pause' },
1241
+ ],
1242
+ };
1243
+
1244
+ let commandLog = [];
1245
+
1246
+ function renderCommands() {
1247
+ const grid = document.getElementById('commands-grid');
1248
+ const isConnected = listener !== null;
1249
+
1250
+ let html = '';
1251
+ for (const [category, commands] of Object.entries(COMMAND_CATEGORIES)) {
1252
+ html += `<div class="command-category"><h3>${category}</h3><div class="command-list">`;
1253
+ for (const cmd of commands) {
1254
+ if (cmd.input) {
1255
+ html += `
1256
+ <div class="command-item">
1257
+ <button class="command-btn" onclick="sendCommandWithInput('${cmd.cmd}', '${cmd.cmd}-input')" ${!isConnected ? 'disabled' : ''}>
1258
+ ${cmd.label}
1259
+ </button>
1260
+ <input type="number" id="${cmd.cmd}-input" class="command-input" value="${cmd.default || 0}" ${!isConnected ? 'disabled' : ''}>
1261
+ <span class="command-unit">${cmd.unit || ''}</span>
1262
+ </div>
1263
+ `;
1264
+ } else {
1265
+ html += `
1266
+ <div class="command-item">
1267
+ <button class="command-btn" onclick="sendCommand('${cmd.cmd}')" ${!isConnected ? 'disabled' : ''}>
1268
+ ${cmd.label}
1269
+ </button>
1270
+ </div>
1271
+ `;
1272
+ }
1273
+ }
1274
+ html += '</div></div>';
1275
+ }
1276
+ grid.innerHTML = html;
1277
+ }
1278
+
1279
+ async function sendCommand(cmd) {
1280
+ if (!listener) {
1281
+ showToast('Not connected');
1282
+ return;
1283
+ }
1284
+ try {
1285
+ await listener.sendCommand(cmd, true);
1286
+ logCommand(cmd, true);
1287
+ showToast(`Sent: ${cmd}`);
1288
+ } catch (err) {
1289
+ console.error('Command failed:', err);
1290
+ showToast(`Failed: ${err.message}`);
1291
+ }
1292
+ }
1293
+
1294
+ async function sendCommandWithInput(cmd, inputId) {
1295
+ if (!listener) {
1296
+ showToast('Not connected');
1297
+ return;
1298
+ }
1299
+ const input = document.getElementById(inputId);
1300
+ let value = parseFloat(input.value);
1301
+
1302
+ // Convert percentage to ratio for throttle/flaps commands
1303
+ if (cmd.startsWith('set_throttle') || cmd === 'set_flaps') {
1304
+ value = value / 100;
1305
+ }
1306
+
1307
+ try {
1308
+ await listener.sendCommand(cmd, value);
1309
+ logCommand(cmd, value);
1310
+ showToast(`Sent: ${cmd} = ${input.value}`);
1311
+ } catch (err) {
1312
+ console.error('Command failed:', err);
1313
+ showToast(`Failed: ${err.message}`);
1314
+ }
1315
+ }
1316
+
1317
+ function logCommand(cmd, value) {
1318
+ commandLog.unshift({
1319
+ cmd,
1320
+ value,
1321
+ time: new Date().toLocaleTimeString()
1322
+ });
1323
+ if (commandLog.length > 20) commandLog.pop();
1324
+ renderCommandLog();
1325
+ }
1326
+
1327
+ function renderCommandLog() {
1328
+ const container = document.getElementById('command-log-entries');
1329
+ if (commandLog.length === 0) {
1330
+ container.innerHTML = '<div class="command-log-empty">No commands sent yet</div>';
1331
+ return;
1332
+ }
1333
+ container.innerHTML = commandLog.map(entry => `
1334
+ <div class="command-log-entry">
1335
+ <span class="command-log-time">${entry.time}</span>
1336
+ <span class="command-log-cmd">${entry.cmd}</span>
1337
+ <span class="command-log-value">${typeof entry.value === 'number' ? entry.value.toFixed(2) : entry.value}</span>
1338
+ </div>
1339
+ `).join('');
1340
+ }
1341
+
1342
+ // ===========================================
1343
+ // TABS
1344
+ // ===========================================
1345
+ let currentTab = 'telemetry';
1346
+
1347
+ function switchTab(tab) {
1348
+ currentTab = tab;
1349
+
1350
+ // Update tab buttons
1351
+ document.querySelectorAll('.tab').forEach(el => {
1352
+ el.classList.toggle('active', el.textContent.trim().toLowerCase() === tab);
1353
+ });
1354
+
1355
+ // Show/hide tab content
1356
+ const telemetrySections = ['stats-section', 'key-events-section', 'waiting-section', 'unexpected-section', 'coverage-section'];
1357
+
1358
+ if (tab === 'telemetry') {
1359
+ // Show telemetry sections based on state
1360
+ telemetrySections.forEach(id => {
1361
+ const el = document.getElementById(id);
1362
+ if (!el) return;
1363
+ if (id === 'stats-section') el.classList.remove('hidden');
1364
+ else if (id === 'key-events-section') el.classList.remove('hidden');
1365
+ else if (id === 'waiting-section' && state.totalUpdates === 0) el.classList.remove('hidden');
1366
+ else if (id === 'coverage-section' && state.totalUpdates > 0) el.classList.remove('hidden');
1367
+ else if (id === 'unexpected-section' && state.unmappedFields.size > 0) el.classList.remove('hidden');
1368
+ else el.classList.add('hidden');
1369
+ });
1370
+ document.getElementById('commands-section').classList.add('hidden');
1371
+ } else if (tab === 'commands') {
1372
+ // Hide ALL telemetry sections
1373
+ telemetrySections.forEach(id => {
1374
+ const el = document.getElementById(id);
1375
+ if (el) el.classList.add('hidden');
1376
+ });
1377
+ // Show commands section
1378
+ document.getElementById('commands-section').classList.remove('hidden');
1379
+ renderCommands();
1380
+ }
1381
+ }
1382
+
1383
+ // ===========================================
1384
+ // AUTH
1385
+ // ===========================================
1386
+ function doLogin() { ggClient.login(); }
1387
+ function doLogout() {
1388
+ ggClient.logout({ redirect: false });
1389
+ document.getElementById('auth-section').classList.remove('hidden');
1390
+ document.getElementById('dashboard').classList.add('hidden');
1391
+ }
1392
+
1393
+ async function init() {
1394
+ // Build CANONICAL from @gameglue/schemas
1395
+ CANONICAL = buildCanonicalFromSchema();
1396
+ console.log(`Loaded ${Object.keys(CANONICAL.fields).length} canonical fields from schema`);
1397
+
1398
+ try {
1399
+ const isAuthed = await ggClient.isAuthenticated();
1400
+ if (!isAuthed) return;
1401
+ document.getElementById('user-id').value = ggClient.getUser();
1402
+ document.getElementById('auth-section').classList.add('hidden');
1403
+ document.getElementById('dashboard').classList.remove('hidden');
1404
+ } catch (err) { console.error(err); }
1405
+ }
1406
+
1407
+ window.onload = init;
1408
+ </script>
1409
+ </body>
1410
+ </html>