@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.
- package/README.md +8 -13
- package/package.json +3 -3
- package/public/api.js +402 -0
- package/public/assets/MaYaRa_RED.png +0 -0
- package/public/base.css +91 -0
- package/public/control.html +23 -0
- package/public/control.js +1155 -0
- package/public/controls.css +538 -0
- package/public/discovery.css +478 -0
- package/public/favicon.ico +0 -0
- package/public/index.html +10 -0
- package/public/layout.css +87 -0
- package/public/mayara.js +510 -0
- package/public/playback.html +572 -0
- package/public/proto/RadarMessage.proto +41 -0
- package/public/protobuf/protobuf.js +9112 -0
- package/public/protobuf/protobuf.js.map +1 -0
- package/public/protobuf/protobuf.min.js +8 -0
- package/public/protobuf/protobuf.min.js.map +1 -0
- package/public/radar.svg +29 -0
- package/public/render_webgpu.js +886 -0
- package/public/responsive.css +29 -0
- package/public/van-1.5.2.debug.js +126 -0
- package/public/van-1.5.2.js +140 -0
- package/public/van-1.5.2.min.js +1 -0
- package/public/viewer.html +30 -0
- package/public/viewer.js +797 -0
- package/build.js +0 -248
package/public/viewer.js
ADDED
|
@@ -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
|
+
}
|