@marineyachtradar/signalk-playback-plugin 0.1.1 → 0.2.0

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.
@@ -0,0 +1,797 @@
1
+ "use strict";
2
+
3
+ export { RANGE_SCALE, formatRangeValue, is_metric, getHeadingMode, getTrueHeading };
4
+
5
+ import {
6
+ loadRadar,
7
+ registerRadarCallback,
8
+ registerControlCallback,
9
+ setCurrentRange,
10
+ getPowerState,
11
+ getOperatingHours,
12
+ hasHoursCapability,
13
+ } from "./control.js";
14
+ import { isStandaloneMode, detectMode } from "./api.js";
15
+ import "./protobuf/protobuf.min.js";
16
+
17
+ import { render_webgpu } from "./render_webgpu.js";
18
+
19
+ var webSocket;
20
+ var headingSocket;
21
+ var RadarMessage;
22
+ var renderer;
23
+ var noTransmitAngles = Array();
24
+
25
+ // Heading mode: "headingUp" or "northUp"
26
+ var headingMode = "headingUp";
27
+ var trueHeading = 0; // in radians
28
+
29
+ function divides_near(a, b) {
30
+ let remainder = a % b;
31
+ let r = remainder <= 1.0 || remainder >= b - 1;
32
+ return r;
33
+ }
34
+
35
+ function is_metric(v) {
36
+ if (v <= 100) {
37
+ return divides_near(v, 25);
38
+ } else if (v <= 750) {
39
+ return divides_near(v, 50);
40
+ }
41
+ return divides_near(v, 500);
42
+ }
43
+
44
+ const NAUTICAL_MILE = 1852.0;
45
+
46
+ function formatRangeValue(metric, v) {
47
+ if (metric) {
48
+ // Metric
49
+ v = Math.round(v);
50
+ if (v >= 1000) {
51
+ return v / 1000 + " km";
52
+ } else {
53
+ return v + " m";
54
+ }
55
+ } else {
56
+ if (v >= NAUTICAL_MILE - 1) {
57
+ if (divides_near(v, NAUTICAL_MILE)) {
58
+ return Math.floor((v + 1) / NAUTICAL_MILE) + " nm";
59
+ } else {
60
+ return v / NAUTICAL_MILE + " nm";
61
+ }
62
+ } else if (divides_near(v, NAUTICAL_MILE / 2)) {
63
+ return Math.floor((v + 1) / (NAUTICAL_MILE / 2)) + "/2 nm";
64
+ } else if (divides_near(v, NAUTICAL_MILE / 4)) {
65
+ return Math.floor((v + 1) / (NAUTICAL_MILE / 4)) + "/4 nm";
66
+ } else if (divides_near(v, NAUTICAL_MILE / 8)) {
67
+ return Math.floor((v + 1) / (NAUTICAL_MILE / 8)) + "/8 nm";
68
+ } else if (divides_near(v, NAUTICAL_MILE / 16)) {
69
+ return Math.floor((v + 1) / (NAUTICAL_MILE / 16)) + "/16 nm";
70
+ } else if (divides_near(v, NAUTICAL_MILE / 32)) {
71
+ return Math.floor((v + 1) / (NAUTICAL_MILE / 32)) + "/32 nm";
72
+ } else if (divides_near(v, NAUTICAL_MILE / 64)) {
73
+ return Math.floor((v + 1) / (NAUTICAL_MILE / 64)) + "/64 nm";
74
+ } else if (divides_near(v, NAUTICAL_MILE / 128)) {
75
+ return Math.floor((v + 1) / (NAUTICAL_MILE / 128)) + "/128 nm";
76
+ } else {
77
+ return v / NAUTICAL_MILE + " nm";
78
+ }
79
+ }
80
+ }
81
+
82
+ const RANGE_SCALE = 0.9; // Factor by which we fill the (w,h) canvas with the outer radar range ring
83
+
84
+ registerRadarCallback(radarLoaded);
85
+ registerControlCallback(controlUpdate);
86
+
87
+ window.onload = async function () {
88
+ const urlParams = new URLSearchParams(window.location.search);
89
+ const id = urlParams.get("id");
90
+
91
+ // Check WebGPU availability
92
+ const webgpuAvailable = await checkWebGPU();
93
+ if (!webgpuAvailable) {
94
+ return; // Error message already shown
95
+ }
96
+
97
+ // Load protobuf definition - must complete before websocket can process messages
98
+ const protobufPromise = new Promise((resolve, reject) => {
99
+ protobuf.load("./proto/RadarMessage.proto", function (err, root) {
100
+ if (err) {
101
+ reject(err);
102
+ return;
103
+ }
104
+ RadarMessage = root.lookupType(".RadarMessage");
105
+ console.log("RadarMessage protobuf loaded successfully");
106
+ resolve();
107
+ });
108
+ });
109
+
110
+ // WebGPU only
111
+ renderer = new render_webgpu(
112
+ document.getElementById("myr_canvas_webgl"),
113
+ document.getElementById("myr_canvas_background"),
114
+ drawBackground
115
+ );
116
+
117
+ // Wait for both WebGPU initialization AND protobuf loading before proceeding
118
+ // (radarLoaded callback needs renderer to be ready and protobuf for websocket messages)
119
+ await Promise.all([renderer.initPromise, protobufPromise]);
120
+ console.log("Both WebGPU and protobuf ready");
121
+
122
+ // Debug: expose renderer globally for console debugging
123
+ window.renderer = renderer;
124
+
125
+ // Process any pending radar data that arrived before renderer was ready
126
+ // (the callback might have been triggered by control.js before window.onload)
127
+ if (pendingRadarData) {
128
+ console.log("Processing deferred radar data");
129
+ radarLoaded(pendingRadarData);
130
+ pendingRadarData = null;
131
+ } else {
132
+ // No pending data - load radar now
133
+ loadRadar(id);
134
+ }
135
+
136
+ // Ensure mode is detected before checking isStandaloneMode()
137
+ await detectMode();
138
+
139
+ // Subscribe to SignalK heading delta (only in SignalK mode)
140
+ subscribeToHeading();
141
+
142
+ // Create heading mode toggle button
143
+ createHeadingModeToggle();
144
+
145
+ window.onresize = function () {
146
+ renderer.redrawCanvas();
147
+ };
148
+ };
149
+
150
+ // Subscribe to navigation.headingTrue via SignalK WebSocket
151
+ function subscribeToHeading() {
152
+ // In standalone mode, SignalK is not available - skip heading subscription
153
+ if (isStandaloneMode()) {
154
+ console.log("Standalone mode: heading subscription disabled (no SignalK)");
155
+ return;
156
+ }
157
+
158
+ const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
159
+ const streamUrl = `${wsProtocol}//${window.location.host}/signalk/v1/stream?subscribe=none`;
160
+
161
+ headingSocket = new WebSocket(streamUrl);
162
+
163
+ headingSocket.onopen = () => {
164
+ console.log("Heading WebSocket connected");
165
+ // Subscribe to headingTrue
166
+ const subscription = {
167
+ context: "vessels.self",
168
+ subscribe: [
169
+ {
170
+ path: "navigation.headingTrue",
171
+ period: 200,
172
+ },
173
+ ],
174
+ };
175
+ headingSocket.send(JSON.stringify(subscription));
176
+ };
177
+
178
+ headingSocket.onmessage = (event) => {
179
+ try {
180
+ const data = JSON.parse(event.data);
181
+ if (data.updates) {
182
+ for (const update of data.updates) {
183
+ if (update.values) {
184
+ for (const value of update.values) {
185
+ if (value.path === "navigation.headingTrue") {
186
+ trueHeading = value.value; // Already in radians
187
+ updateHeadingDisplay();
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+ } catch (e) {
194
+ // Ignore parse errors (e.g., hello message)
195
+ }
196
+ };
197
+
198
+ headingSocket.onerror = (e) => {
199
+ console.log("Heading WebSocket error:", e);
200
+ };
201
+
202
+ headingSocket.onclose = () => {
203
+ console.log("Heading WebSocket closed, reconnecting in 5s...");
204
+ setTimeout(subscribeToHeading, 5000);
205
+ };
206
+ }
207
+
208
+ // Update renderer with current heading based on mode
209
+ function updateHeadingDisplay() {
210
+ if (renderer) {
211
+ if (headingMode === "northUp") {
212
+ // North Up: rotate radar by -heading so north is at top
213
+ renderer.setHeadingRotation(-trueHeading);
214
+ } else {
215
+ // Heading Up: no rotation, heading is always at top
216
+ renderer.setHeadingRotation(0);
217
+ }
218
+ }
219
+ }
220
+
221
+ // Getters for heading state (used by renderer)
222
+ function getHeadingMode() {
223
+ return headingMode;
224
+ }
225
+
226
+ function getTrueHeading() {
227
+ return trueHeading;
228
+ }
229
+
230
+ // Create the heading mode toggle button
231
+ function createHeadingModeToggle() {
232
+ const container = document.querySelector(".myr_ppi");
233
+ if (!container) return;
234
+
235
+ const toggleBtn = document.createElement("div");
236
+ toggleBtn.id = "myr_heading_toggle";
237
+ toggleBtn.className = "myr_heading_toggle";
238
+ toggleBtn.innerHTML = "H Up";
239
+ toggleBtn.title = "Click to toggle: Heading Up / North Up";
240
+
241
+ toggleBtn.addEventListener("click", () => {
242
+ if (headingMode === "headingUp") {
243
+ headingMode = "northUp";
244
+ toggleBtn.innerHTML = "N Up";
245
+ } else {
246
+ headingMode = "headingUp";
247
+ toggleBtn.innerHTML = "H Up";
248
+ }
249
+ updateHeadingDisplay();
250
+ renderer.redrawCanvas();
251
+ });
252
+
253
+ container.appendChild(toggleBtn);
254
+ }
255
+
256
+ // Check WebGPU and show error if not available
257
+ async function checkWebGPU() {
258
+ const hasWebGPUApi = !!navigator.gpu;
259
+ const isSecure = window.isSecureContext;
260
+
261
+ if (!hasWebGPUApi) {
262
+ showWebGPUError("no-api", hasWebGPUApi, isSecure);
263
+ return false;
264
+ }
265
+
266
+ try {
267
+ const adapter = await navigator.gpu.requestAdapter();
268
+ if (!adapter) {
269
+ showWebGPUError("no-adapter", hasWebGPUApi, isSecure);
270
+ return false;
271
+ }
272
+ return true;
273
+ } catch (e) {
274
+ showWebGPUError("adapter-error", hasWebGPUApi, isSecure);
275
+ return false;
276
+ }
277
+ }
278
+
279
+ function showWebGPUError(failureReason, hasWebGPUApi, isSecure) {
280
+ const container = document.querySelector('.myr_container');
281
+ if (!container) return;
282
+
283
+ const os = detectOS();
284
+ const browser = detectBrowser();
285
+ const hostname = window.location.hostname;
286
+ const port = window.location.port || '80';
287
+
288
+ // Build error message based on failure reason
289
+ let errorMessage = '';
290
+ if (failureReason === 'no-api' && !isSecure) {
291
+ errorMessage = 'WebGPU API not available - likely due to insecure context.';
292
+ } else if (failureReason === 'no-api') {
293
+ errorMessage = 'WebGPU API not available in this browser.';
294
+ } else if (failureReason === 'no-adapter') {
295
+ errorMessage = 'No WebGPU adapter found. Your GPU may not support WebGPU.';
296
+ } else {
297
+ errorMessage = 'WebGPU initialization failed.';
298
+ }
299
+
300
+ container.innerHTML = `
301
+ <div class="myr_webgpu_error">
302
+ <h2>WebGPU Required</h2>
303
+ <p class="myr_error_message">${errorMessage}</p>
304
+
305
+ ${!isSecure ? `
306
+ <div class="myr_error_section">
307
+ <h3>Secure Context Required</h3>
308
+ <p>WebGPU requires a secure context. You are accessing via HTTP on "${hostname}".</p>
309
+ ${getSecureContextOptionsHTML(browser, os, port)}
310
+ </div>
311
+ ` : ''}
312
+
313
+ <div class="myr_error_section">
314
+ <h3>Enable WebGPU / Hardware Acceleration</h3>
315
+ ${getBrowserInstructionsHTML(browser, os)}
316
+ </div>
317
+
318
+ <div class="myr_error_actions">
319
+ <a href="index.html" class="myr_back_link">Back to Radar List</a>
320
+ <button onclick="location.reload()" class="myr_retry_button">Retry</button>
321
+ </div>
322
+ </div>
323
+ `;
324
+ }
325
+
326
+ function detectOS() {
327
+ const ua = navigator.userAgent.toLowerCase();
328
+ const platform = navigator.platform?.toLowerCase() || '';
329
+
330
+ // Check mobile/tablet FIRST (iPadOS reports as macOS in Safari)
331
+ if (ua.includes('iphone') || ua.includes('ipad')) return 'ios';
332
+ // Also detect iPad via touch + macOS combination (iPadOS 13+ desktop mode)
333
+ if (navigator.maxTouchPoints > 1 && (ua.includes('mac') || platform.includes('mac'))) return 'ios';
334
+ if (ua.includes('android')) return 'android';
335
+
336
+ // Desktop OS detection
337
+ if (ua.includes('win') || platform.includes('win')) return 'windows';
338
+ if (ua.includes('mac') || platform.includes('mac')) return 'macos';
339
+ if (ua.includes('linux') || platform.includes('linux')) return 'linux';
340
+ return 'unknown';
341
+ }
342
+
343
+ function detectBrowser() {
344
+ const ua = navigator.userAgent.toLowerCase();
345
+ if (ua.includes('edg/')) return 'edge';
346
+ if (ua.includes('chrome')) return 'chrome';
347
+ if (ua.includes('firefox')) return 'firefox';
348
+ if (ua.includes('safari') && !ua.includes('chrome')) return 'safari';
349
+ return 'unknown';
350
+ }
351
+
352
+ function getSecureContextOptionsHTML(browser, os, port) {
353
+ const origin = window.location.origin;
354
+ const isMobile = (os === 'ios' || os === 'android');
355
+
356
+ let options = '';
357
+
358
+ // Only show localhost option for desktop
359
+ if (!isMobile) {
360
+ options += `
361
+ <p><strong>Option 1 (easiest):</strong> Access via localhost instead:</p>
362
+ <div class="myr_code_instructions">
363
+ <p><code>http://localhost:${port}</code> or <code>http://127.0.0.1:${port}</code></p>
364
+ <p class="myr_note">Browsers treat localhost as a secure context</p>
365
+ </div>
366
+ `;
367
+ }
368
+
369
+ const optNum = isMobile ? 1 : 2;
370
+ options += `
371
+ <p><strong>Option ${optNum}:</strong> Add this site to browser exceptions:</p>
372
+ ${getInsecureOriginHTML(browser, os)}
373
+ <p><strong>Option ${optNum + 1}:</strong> Use HTTPS (requires server configuration)</p>
374
+ `;
375
+
376
+ return options;
377
+ }
378
+
379
+ function getInsecureOriginHTML(browser, os) {
380
+ const origin = window.location.origin;
381
+ const hostname = window.location.hostname;
382
+
383
+ // iOS Safari has no way to add insecure origin exceptions
384
+ if (os === 'ios') {
385
+ return `
386
+ <div class="myr_code_instructions">
387
+ <p>Safari on iOS/iPadOS does not support insecure origin exceptions.</p>
388
+ <p>Alternatives:</p>
389
+ <p>• Configure HTTPS on your SignalK server</p>
390
+ <p>• Use a tunneling service (e.g., ngrok) to get an HTTPS URL</p>
391
+ <p>• Access from a desktop browser where you can set the flag</p>
392
+ </div>
393
+ `;
394
+ }
395
+
396
+ // Android Chrome
397
+ if (os === 'android' && browser === 'chrome') {
398
+ return `
399
+ <div class="myr_code_instructions">
400
+ <p>1. Open Chrome on your Android device</p>
401
+ <p>2. Go to: <code>chrome://flags/#unsafely-treat-insecure-origin-as-secure</code></p>
402
+ <p>3. Add: <code>${origin}</code></p>
403
+ <p>4. Set to "Enabled"</p>
404
+ <p>5. Tap "Relaunch"</p>
405
+ </div>
406
+ `;
407
+ }
408
+
409
+ if (browser === 'chrome' || browser === 'edge') {
410
+ const flagPrefix = browser === 'edge' ? 'edge' : 'chrome';
411
+ const flagUrl = `${flagPrefix}://flags/#unsafely-treat-insecure-origin-as-secure`;
412
+ return `
413
+ <div class="myr_code_instructions">
414
+ <p>1. Copy and paste this into your address bar:</p>
415
+ <p><a href="${flagUrl}" class="myr_flag_link"><code>${flagUrl}</code></a></p>
416
+ <p>2. In the text field, add: <code>${origin}</code></p>
417
+ <p>3. Set dropdown to "Enabled"</p>
418
+ <p>4. Click "Relaunch" at the bottom</p>
419
+ </div>
420
+ `;
421
+ }
422
+ if (browser === 'firefox') {
423
+ return `
424
+ <div class="myr_code_instructions">
425
+ <p>1. Open: <a href="about:config" class="myr_flag_link"><code>about:config</code></a></p>
426
+ <p>2. Click "Accept the Risk and Continue"</p>
427
+ <p>3. Search for: <code>dom.securecontext.allowlist</code></p>
428
+ <p>4. Click the + button to add: <code>${hostname}</code></p>
429
+ <p>5. Restart Firefox</p>
430
+ </div>
431
+ `;
432
+ }
433
+ return `<p>Check your browser settings for allowing insecure origins.</p>`;
434
+ }
435
+
436
+ function getBrowserInstructionsHTML(browser, os) {
437
+ // iOS/iPadOS Safari
438
+ if (browser === 'safari' && os === 'ios') {
439
+ return `
440
+ <div class="myr_code_instructions">
441
+ <p>Safari on iOS/iPadOS 17+:</p>
442
+ <p>1. Open the <strong>Settings</strong> app</p>
443
+ <p>2. Scroll down and tap <strong>Safari</strong></p>
444
+ <p>3. Scroll down and tap <strong>Advanced</strong></p>
445
+ <p>4. Tap <strong>Feature Flags</strong></p>
446
+ <p>5. Enable <strong>WebGPU</strong></p>
447
+ <p>6. Return to Safari and reload this page</p>
448
+ <p class="myr_note">Note: Requires iOS/iPadOS 17 or later.</p>
449
+ </div>
450
+ `;
451
+ }
452
+
453
+ switch (browser) {
454
+ case 'chrome':
455
+ return `
456
+ <div class="myr_code_instructions">
457
+ <p>Chrome should have WebGPU enabled by default (v113+).</p>
458
+ <p>If not working:</p>
459
+ <p>1. Open: <code>chrome://flags/#enable-unsafe-webgpu</code></p>
460
+ <p>2. Set to "Enabled"</p>
461
+ <p>3. Relaunch Chrome</p>
462
+ ${os === 'linux' ? '<p class="myr_note">Linux: Vulkan drivers required.</p>' : ''}
463
+ </div>
464
+ `;
465
+ case 'edge':
466
+ return `
467
+ <div class="myr_code_instructions">
468
+ <p>Edge should have WebGPU enabled by default.</p>
469
+ <p>If not working:</p>
470
+ <p>1. Open: <code>edge://flags/#enable-unsafe-webgpu</code></p>
471
+ <p>2. Set to "Enabled"</p>
472
+ <p>3. Relaunch Edge</p>
473
+ </div>
474
+ `;
475
+ case 'firefox':
476
+ return `
477
+ <div class="myr_code_instructions">
478
+ <p>Firefox WebGPU (experimental):</p>
479
+ <p>1. Open: <code>about:config</code></p>
480
+ <p>2. Search: <code>dom.webgpu.enabled</code></p>
481
+ <p>3. Set to: <code>true</code></p>
482
+ <p>4. Restart Firefox</p>
483
+ </div>
484
+ `;
485
+ case 'safari':
486
+ return `
487
+ <div class="myr_code_instructions">
488
+ <p>Safari WebGPU (macOS 14+):</p>
489
+ <p>1. Open Safari menu > Settings</p>
490
+ <p>2. Go to Advanced tab</p>
491
+ <p>3. Check "Show features for web developers"</p>
492
+ <p>4. Go to Feature Flags tab</p>
493
+ <p>5. Enable "WebGPU"</p>
494
+ <p>6. Restart Safari</p>
495
+ </div>
496
+ `;
497
+ default:
498
+ return `
499
+ <div class="myr_code_instructions">
500
+ <p>WebGPU requires:</p>
501
+ <p>- Chrome 113+ (recommended)</p>
502
+ <p>- Edge 113+</p>
503
+ <p>- Safari 17+</p>
504
+ <p>- Firefox (experimental)</p>
505
+ </div>
506
+ `;
507
+ }
508
+ }
509
+
510
+ function getHardwareAccelerationHTML(browser, os) {
511
+ // iOS/iPadOS - no hardware acceleration toggle
512
+ if (os === 'ios') {
513
+ return `
514
+ <div class="myr_code_instructions">
515
+ <p>On iOS/iPadOS, hardware acceleration cannot be disabled.</p>
516
+ <p>If WebGPU is not working:</p>
517
+ <p>• Ensure you have iOS/iPadOS 17 or later</p>
518
+ <p>• Try closing and reopening Safari</p>
519
+ <p>• Restart your device</p>
520
+ </div>
521
+ `;
522
+ }
523
+
524
+ switch (browser) {
525
+ case 'chrome':
526
+ return `
527
+ <div class="myr_code_instructions">
528
+ <p>1. Open: <code>chrome://settings/system</code></p>
529
+ <p>2. Enable "Use graphics acceleration when available"</p>
530
+ <p>3. Relaunch Chrome</p>
531
+ </div>
532
+ `;
533
+ case 'edge':
534
+ return `
535
+ <div class="myr_code_instructions">
536
+ <p>1. Open: <code>edge://settings/system</code></p>
537
+ <p>2. Enable "Use graphics acceleration when available"</p>
538
+ <p>3. Relaunch Edge</p>
539
+ </div>
540
+ `;
541
+ case 'firefox':
542
+ return `
543
+ <div class="myr_code_instructions">
544
+ <p>1. Open: <code>about:preferences</code></p>
545
+ <p>2. Scroll to "Performance"</p>
546
+ <p>3. Uncheck "Use recommended performance settings"</p>
547
+ <p>4. Check "Use hardware acceleration when available"</p>
548
+ <p>5. Restart Firefox</p>
549
+ </div>
550
+ `;
551
+ case 'safari':
552
+ return `
553
+ <div class="myr_code_instructions">
554
+ <p>Safari uses hardware acceleration by default on macOS.</p>
555
+ <p>If WebGPU is not working:</p>
556
+ <p>• Ensure you have macOS 14 (Sonoma) or later</p>
557
+ <p>• Check that WebGPU is enabled in Feature Flags</p>
558
+ <p>• Try restarting Safari</p>
559
+ </div>
560
+ `;
561
+ default:
562
+ return `
563
+ <div class="myr_code_instructions">
564
+ <p>Check your browser settings for "Hardware acceleration"</p>
565
+ <p>or "Use GPU" and ensure it is enabled.</p>
566
+ <p>Then restart the browser.</p>
567
+ </div>
568
+ `;
569
+ }
570
+ }
571
+
572
+ function restart(id) {
573
+ setTimeout(loadRadar, 15000, id);
574
+ }
575
+
576
+ // Pending radar data if callback arrives before renderer is ready
577
+ var pendingRadarData = null;
578
+
579
+ function radarLoaded(r) {
580
+ let maxSpokeLen = r.maxSpokeLen;
581
+ let spokesPerRevolution = r.spokesPerRevolution;
582
+ let prev_angle = -1;
583
+
584
+ if (r === undefined || r.controls === undefined) {
585
+ return;
586
+ }
587
+
588
+ // If renderer isn't ready yet, store data and return
589
+ // It will be processed when renderer.initPromise resolves
590
+ if (!renderer || !renderer.ready) {
591
+ pendingRadarData = r;
592
+ return;
593
+ }
594
+
595
+ renderer.setLegend(buildMayaraLegend());
596
+ renderer.setSpokes(spokesPerRevolution, maxSpokeLen);
597
+
598
+ // Check initial power state and set standby mode if needed
599
+ const initialPowerState = getPowerState();
600
+ const isStandby = initialPowerState === 'standby' || initialPowerState === 'off';
601
+ if (isStandby) {
602
+ const hours = getOperatingHours();
603
+ const hoursCap = hasHoursCapability();
604
+ renderer.setStandbyMode(true, hours.onTime, hours.txTime, hoursCap.hasOnTime, hoursCap.hasTxTime);
605
+ }
606
+
607
+ // Use provided streamUrl or construct SignalK stream URL
608
+ let streamUrl = r.streamUrl;
609
+ if (!streamUrl || streamUrl === "undefined" || streamUrl === "null") {
610
+ const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
611
+ streamUrl = `${wsProtocol}//${window.location.host}/signalk/v2/api/vessels/self/radars/${r.id}/stream`;
612
+ }
613
+ console.log("Connecting to radar stream:", streamUrl);
614
+ webSocket = new WebSocket(streamUrl);
615
+ webSocket.binaryType = "arraybuffer";
616
+
617
+ webSocket.onopen = (e) => {
618
+ console.log("websocket open: " + JSON.stringify(e));
619
+ };
620
+ webSocket.onclose = (e) => {
621
+ console.log("websocket close: code=" + e.code + ", reason=" + e.reason + ", wasClean=" + e.wasClean);
622
+ restart(r.id);
623
+ };
624
+ webSocket.onerror = (e) => {
625
+ console.log("websocket error:", e);
626
+ };
627
+ webSocket.onmessage = (e) => {
628
+ try {
629
+ const dataSize = e.data?.byteLength || e.data?.length || 0;
630
+ if (dataSize === 0) {
631
+ console.warn("WS message received with 0 bytes");
632
+ return;
633
+ }
634
+ if (!RadarMessage) {
635
+ console.warn("RadarMessage not loaded yet, dropping message");
636
+ return;
637
+ }
638
+ let buf = e.data;
639
+ let bytes = new Uint8Array(buf);
640
+ var message = RadarMessage.decode(bytes);
641
+ if (message.spokes && message.spokes.length > 0) {
642
+ for (let i = 0; i < message.spokes.length; i++) {
643
+ let spoke = message.spokes[i];
644
+
645
+ // Gap-filling disabled for high spoke counts (8192) - not needed
646
+ // The texture-based renderers handle sparse data well
647
+ renderer.drawSpoke(spoke);
648
+ prev_angle = spoke.angle;
649
+ // Update range from spoke data - this is the actual radar range
650
+ // Only update if spoke.range is valid (non-zero) and different from current
651
+ if (spoke.range > 0 && spoke.range !== renderer.range) {
652
+ console.log("Range update from spoke:", spoke.range, "m");
653
+ renderer.setRange(spoke.range);
654
+ }
655
+ // Also update control.js for range display and index tracking
656
+ if (spoke.range > 0) {
657
+ setCurrentRange(spoke.range);
658
+ }
659
+ }
660
+ renderer.render();
661
+ }
662
+ } catch (err) {
663
+ console.error("Error processing WebSocket message:", err);
664
+ }
665
+ };
666
+ }
667
+
668
+ // Build 256-color MaYaRa palette for radar PPI display
669
+ // Smooth color gradient: Dark Green → Green → Yellow → Red
670
+ // Designed for 6-bit radar data (0-63 intensity values)
671
+ // This is a client-side rendering concern - not part of the radar API
672
+ function buildMayaraLegend() {
673
+ const legend = [];
674
+ for (let i = 0; i < 256; i++) {
675
+ let r, g, b;
676
+ if (i === 0) {
677
+ // Index 0: transparent/black (noise floor)
678
+ r = g = b = 0;
679
+ } else if (i <= 15) {
680
+ // 1-15: dark green → brighter green (weak returns)
681
+ const t = (i - 1) / 14;
682
+ r = 0;
683
+ g = Math.floor(50 + t * 100);
684
+ b = 0;
685
+ } else if (i <= 31) {
686
+ // 16-31: green → yellow-green (moderate returns)
687
+ const t = (i - 16) / 15;
688
+ r = Math.floor(t * 200);
689
+ g = Math.floor(150 + t * 55);
690
+ b = 0;
691
+ } else if (i <= 47) {
692
+ // 32-47: yellow → yellow-red (stronger returns)
693
+ const t = (i - 32) / 15;
694
+ r = Math.floor(200 + t * 55);
695
+ g = Math.floor(205 - t * 125);
696
+ b = 0;
697
+ } else if (i <= 63) {
698
+ // 48-63: red (strong returns / land)
699
+ const t = (i - 48) / 15;
700
+ r = 255;
701
+ g = Math.max(0, Math.floor(80 - t * 80));
702
+ b = 0;
703
+ } else {
704
+ // >63: saturated red (overflow)
705
+ r = 255;
706
+ g = 0;
707
+ b = 0;
708
+ }
709
+ // RGBA: alpha is 0 for index 0 (transparent), 255 for others
710
+ legend.push([r, g, b, i === 0 ? 0 : 255]);
711
+ }
712
+ return legend;
713
+ }
714
+
715
+ function hexToRGBA(hex) {
716
+ let a = Array();
717
+ for (let i = 1; i < hex.length; i += 2) {
718
+ a.push(parseInt(hex.slice(i, i + 2), 16));
719
+ }
720
+ while (a.length < 3) {
721
+ a.push(0);
722
+ }
723
+ while (a.length < 4) {
724
+ a.push(255);
725
+ }
726
+
727
+ return a;
728
+ }
729
+
730
+ function controlUpdate(control, controlValue) {
731
+ if (control && control.name == "Range") {
732
+ let range = parseFloat(controlValue.value);
733
+ if (renderer && renderer.setRange) {
734
+ renderer.setRange(range);
735
+ }
736
+ }
737
+ if (control && control.name && control.name.startsWith("No Transmit")) {
738
+ let value = parseFloat(controlValue.value);
739
+ let idx = extractNoTxZone(control.name);
740
+ let start_or_end = extractStartOrEnd(control.name);
741
+ if (controlValue.enabled) {
742
+ noTransmitAngles[idx][start_or_end] = value;
743
+ } else {
744
+ noTransmitAngles[idx] = null;
745
+ }
746
+ }
747
+ // Handle power state changes
748
+ if (controlValue && controlValue.id === 'power') {
749
+ const isStandby = controlValue.value === 'standby' || controlValue.value === 'off';
750
+ if (renderer) {
751
+ const hours = getOperatingHours();
752
+ const hoursCap = hasHoursCapability();
753
+ renderer.setStandbyMode(isStandby, hours.onTime, hours.txTime, hoursCap.hasOnTime, hoursCap.hasTxTime);
754
+ }
755
+ }
756
+ }
757
+
758
+ function extractNoTxZone(name) {
759
+ const re = /(\d+)/;
760
+ let match = name.match(re);
761
+ if (match) {
762
+ return parseInt(match[1]);
763
+ }
764
+ return 0;
765
+ }
766
+
767
+ function extractStartOrEnd(name) {
768
+ return name.includes("start") ? 0 : 1;
769
+ }
770
+
771
+ function drawBackground(obj, txt) {
772
+ obj.background_ctx.setTransform(1, 0, 0, 1, 0, 0);
773
+ obj.background_ctx.clearRect(0, 0, obj.width, obj.height);
774
+
775
+ // No transmit zones (drawn on background, behind radar)
776
+ obj.background_ctx.fillStyle = "lightgrey";
777
+ if (typeof noTransmitAngles == "array") {
778
+ noTransmitAngles.forEach((e) => {
779
+ if (e && e[0]) {
780
+ obj.background_ctx.beginPath();
781
+ obj.background_ctx.arc(
782
+ obj.center_x,
783
+ obj.center_y,
784
+ obj.beam_length * 2,
785
+ (2 * Math.PI * e[0]) / obj.spokesPerRevolution,
786
+ (2 * Math.PI * e[1]) / obj.spokesPerRevolution
787
+ );
788
+ obj.background_ctx.fill();
789
+ }
790
+ });
791
+ }
792
+
793
+ // Title text
794
+ obj.background_ctx.fillStyle = "lightblue";
795
+ obj.background_ctx.font = "bold 16px/1 Verdana, Geneva, sans-serif";
796
+ obj.background_ctx.fillText(txt, 5, 20);
797
+ }