@marineyachtradar/signalk-playback-plugin 0.1.2 → 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,886 @@
1
+ export { render_webgpu };
2
+
3
+ import { RANGE_SCALE, formatRangeValue, is_metric, getHeadingMode, getTrueHeading } from "./viewer.js";
4
+
5
+ class render_webgpu {
6
+ constructor(canvas_dom, canvas_background_dom, drawBackground) {
7
+ this.dom = canvas_dom;
8
+ this.background_dom = canvas_background_dom;
9
+ this.background_ctx = this.background_dom.getContext("2d");
10
+ // Overlay canvas for range rings (on top of radar)
11
+ this.overlay_dom = document.getElementById("myr_canvas_overlay");
12
+ this.overlay_ctx = this.overlay_dom ? this.overlay_dom.getContext("2d") : null;
13
+ this.drawBackgroundCallback = drawBackground;
14
+
15
+ this.actual_range = 0;
16
+ this.ready = false;
17
+ this.pendingLegend = null;
18
+ this.pendingSpokes = null;
19
+
20
+ // Rotation tracking for neighbor enhancement
21
+ this.rotationCount = 0;
22
+ this.lastSpokeAngle = -1;
23
+ this.fillRotations = 4; // Number of rotations to use neighbor enhancement
24
+
25
+ // Buffer flush - wait for full rotation after standby/range change
26
+ // This ensures we only draw fresh data, not stale buffered spokes
27
+ this.waitForRotation = false; // True when waiting for angle wraparound
28
+ this.waitStartAngle = -1; // Angle when we started waiting
29
+ this.seenAngleWrap = false; // True once we've seen angle decrease (wrap)
30
+
31
+ // Heading rotation for North Up mode (in radians)
32
+ this.headingRotation = 0;
33
+
34
+ // Standby mode state
35
+ this.standbyMode = false;
36
+ this.onTimeHours = 0;
37
+ this.txTimeHours = 0;
38
+ this.hasOnTimeCapability = false;
39
+ this.hasTxTimeCapability = false;
40
+
41
+ // Start async initialization
42
+ this.initPromise = this.#initWebGPU();
43
+ }
44
+
45
+ async #initWebGPU() {
46
+ if (!navigator.gpu) {
47
+ throw new Error("WebGPU not supported");
48
+ }
49
+
50
+ const adapter = await navigator.gpu.requestAdapter();
51
+ if (!adapter) {
52
+ throw new Error("No WebGPU adapter found");
53
+ }
54
+
55
+ this.device = await adapter.requestDevice();
56
+ this.context = this.dom.getContext("webgpu");
57
+
58
+ this.canvasFormat = navigator.gpu.getPreferredCanvasFormat();
59
+ this.context.configure({
60
+ device: this.device,
61
+ format: this.canvasFormat,
62
+ alphaMode: "premultiplied",
63
+ });
64
+
65
+ // Create sampler for polar data (linear for smooth display like TZ Pro)
66
+ this.sampler = this.device.createSampler({
67
+ magFilter: "linear",
68
+ minFilter: "linear",
69
+ addressModeU: "clamp-to-edge",
70
+ addressModeV: "repeat", // Wrap around for angles
71
+ });
72
+
73
+ // Create uniform buffer for parameters
74
+ this.uniformBuffer = this.device.createBuffer({
75
+ size: 32, // scaleX, scaleY, spokesPerRev, maxSpokeLen + padding
76
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
77
+ });
78
+
79
+ // Create vertex buffer for fullscreen quad
80
+ const vertices = new Float32Array([
81
+ -1.0, -1.0, 0.0, 0.0,
82
+ 1.0, -1.0, 1.0, 0.0,
83
+ -1.0, 1.0, 0.0, 1.0,
84
+ 1.0, 1.0, 1.0, 1.0,
85
+ ]);
86
+
87
+ this.vertexBuffer = this.device.createBuffer({
88
+ size: vertices.byteLength,
89
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
90
+ });
91
+ this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices);
92
+
93
+ // Create render pipeline
94
+ await this.#createRenderPipeline();
95
+
96
+ this.ready = true;
97
+ this.redrawCanvas();
98
+
99
+ if (this.pendingSpokes) {
100
+ this.setSpokes(this.pendingSpokes.spokesPerRevolution, this.pendingSpokes.max_spoke_len);
101
+ this.pendingSpokes = null;
102
+ }
103
+ if (this.pendingLegend) {
104
+ this.setLegend(this.pendingLegend);
105
+ this.pendingLegend = null;
106
+ }
107
+ console.log("WebGPU initialized (direct polar rendering)");
108
+ }
109
+
110
+ async #createRenderPipeline() {
111
+ const shaderModule = this.device.createShaderModule({
112
+ code: shaderCode,
113
+ });
114
+
115
+ this.bindGroupLayout = this.device.createBindGroupLayout({
116
+ entries: [
117
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } }, // polar data
118
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: "float" } }, // color table
119
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: "filtering" } },
120
+ { binding: 3, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform" } },
121
+ ],
122
+ });
123
+
124
+ this.renderPipeline = this.device.createRenderPipeline({
125
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [this.bindGroupLayout] }),
126
+ vertex: {
127
+ module: shaderModule,
128
+ entryPoint: "vertexMain",
129
+ buffers: [{
130
+ arrayStride: 16,
131
+ attributes: [
132
+ { shaderLocation: 0, offset: 0, format: "float32x2" },
133
+ { shaderLocation: 1, offset: 8, format: "float32x2" },
134
+ ],
135
+ }],
136
+ },
137
+ fragment: {
138
+ module: shaderModule,
139
+ entryPoint: "fragmentMain",
140
+ targets: [{ format: this.canvasFormat }],
141
+ },
142
+ primitive: { topology: "triangle-strip" },
143
+ });
144
+ }
145
+
146
+ setSpokes(spokesPerRevolution, max_spoke_len) {
147
+ if (!this.ready) {
148
+ this.pendingSpokes = { spokesPerRevolution, max_spoke_len };
149
+ this.spokesPerRevolution = spokesPerRevolution;
150
+ this.max_spoke_len = max_spoke_len;
151
+ this.data = new Uint8Array(spokesPerRevolution * max_spoke_len);
152
+ return;
153
+ }
154
+
155
+ this.spokesPerRevolution = spokesPerRevolution;
156
+ this.max_spoke_len = max_spoke_len;
157
+ this.data = new Uint8Array(spokesPerRevolution * max_spoke_len);
158
+
159
+ // Create polar data texture (width = range samples, height = angles)
160
+ this.polarTexture = this.device.createTexture({
161
+ size: [max_spoke_len, spokesPerRevolution],
162
+ format: "r8unorm",
163
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
164
+ });
165
+
166
+ this.#createBindGroup();
167
+ }
168
+
169
+ setRange(range) {
170
+ this.range = range;
171
+ // Clear spoke data when range changes - old data is no longer valid
172
+ if (this.data) {
173
+ this.data.fill(0);
174
+ }
175
+ this.redrawCanvas();
176
+ }
177
+
178
+ setHeadingRotation(radians) {
179
+ this.headingRotation = radians;
180
+ if (this.ready) {
181
+ this.#updateUniforms();
182
+ }
183
+ }
184
+
185
+ setStandbyMode(isStandby, onTimeHours, txTimeHours, hasOnTimeCap, hasTxTimeCap) {
186
+ const wasStandby = this.standbyMode;
187
+ this.standbyMode = isStandby;
188
+ this.onTimeHours = onTimeHours || 0;
189
+ this.txTimeHours = txTimeHours || 0;
190
+ this.hasOnTimeCapability = hasOnTimeCap || false;
191
+ this.hasTxTimeCapability = hasTxTimeCap || false;
192
+
193
+ if (isStandby && !wasStandby) {
194
+ // Entering standby - clear spoke data and force GPU texture update
195
+ this.clearRadarDisplay();
196
+ } else if (!isStandby && wasStandby) {
197
+ // Exiting standby (entering transmit) - clear any stale data and reset state
198
+ this.clearRadarDisplay();
199
+ }
200
+
201
+ // Redraw to show/hide standby overlay
202
+ this.redrawCanvas();
203
+ }
204
+
205
+ // Clear all radar data from display (used when entering standby or changing range)
206
+ clearRadarDisplay() {
207
+ if (this.data) {
208
+ this.data.fill(0);
209
+ }
210
+ // Reset rotation counter and tracking
211
+ this.rotationCount = 0;
212
+ this.lastSpokeAngle = -1;
213
+ this.firstSpokeAngle = -1;
214
+
215
+ // Wait for full rotation to flush any buffered stale spokes
216
+ this.waitForRotation = true;
217
+ this.waitStartAngle = -1;
218
+ this.seenAngleWrap = false;
219
+
220
+ // Upload cleared data to GPU and render
221
+ if (this.ready && this.polarTexture && this.data) {
222
+ this.device.queue.writeTexture(
223
+ { texture: this.polarTexture },
224
+ this.data,
225
+ { bytesPerRow: this.max_spoke_len },
226
+ { width: this.max_spoke_len, height: this.spokesPerRevolution }
227
+ );
228
+ this.render();
229
+ }
230
+ }
231
+
232
+ setLegend(l) {
233
+ if (!this.ready) {
234
+ this.pendingLegend = l;
235
+ return;
236
+ }
237
+
238
+ const colorTableData = new Uint8Array(256 * 4);
239
+ for (let i = 0; i < l.length; i++) {
240
+ colorTableData[i * 4] = l[i][0];
241
+ colorTableData[i * 4 + 1] = l[i][1];
242
+ colorTableData[i * 4 + 2] = l[i][2];
243
+ colorTableData[i * 4 + 3] = l[i][3];
244
+ }
245
+
246
+ this.colorTexture = this.device.createTexture({
247
+ size: [256, 1],
248
+ format: "rgba8unorm",
249
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
250
+ });
251
+
252
+ this.device.queue.writeTexture(
253
+ { texture: this.colorTexture },
254
+ colorTableData,
255
+ { bytesPerRow: 256 * 4 },
256
+ { width: 256, height: 1 }
257
+ );
258
+
259
+ if (this.polarTexture) {
260
+ this.#createBindGroup();
261
+ }
262
+ }
263
+
264
+ #createBindGroup() {
265
+ if (!this.polarTexture || !this.colorTexture) return;
266
+
267
+ this.bindGroup = this.device.createBindGroup({
268
+ layout: this.bindGroupLayout,
269
+ entries: [
270
+ { binding: 0, resource: this.polarTexture.createView() },
271
+ { binding: 1, resource: this.colorTexture.createView() },
272
+ { binding: 2, resource: this.sampler },
273
+ { binding: 3, resource: { buffer: this.uniformBuffer } },
274
+ ],
275
+ });
276
+ }
277
+
278
+ drawSpoke(spoke) {
279
+ if (!this.data) return;
280
+
281
+ // Don't draw spokes in standby mode
282
+ if (this.standbyMode) {
283
+ // Prepare to wait for full rotation when we exit standby
284
+ this.waitForRotation = true;
285
+ this.waitStartAngle = -1;
286
+ this.seenAngleWrap = false;
287
+ return;
288
+ }
289
+
290
+ // Bounds check - log bad angles
291
+ if (spoke.angle >= this.spokesPerRevolution) {
292
+ console.error(`Bad spoke angle: ${spoke.angle} >= ${this.spokesPerRevolution}`);
293
+ return;
294
+ }
295
+
296
+ // Wait for full rotation: skip all buffered spokes until we complete one full sweep
297
+ // This ensures we only draw fresh data after standby/range change
298
+ if (this.waitForRotation) {
299
+ if (this.waitStartAngle < 0) {
300
+ // First spoke - record starting angle
301
+ this.waitStartAngle = spoke.angle;
302
+ this.lastWaitAngle = spoke.angle;
303
+ return;
304
+ }
305
+
306
+ // Detect angle wraparound (e.g., from 2000 to 100)
307
+ if (!this.seenAngleWrap && spoke.angle < this.lastWaitAngle - this.spokesPerRevolution / 2) {
308
+ this.seenAngleWrap = true;
309
+ }
310
+
311
+ // After wraparound, wait until we're back past the start angle
312
+ // This means we've completed one full rotation of fresh data
313
+ if (this.seenAngleWrap && spoke.angle >= this.waitStartAngle) {
314
+ // Full rotation complete - start drawing fresh data
315
+ this.waitForRotation = false;
316
+ this.rotationCount = 0;
317
+ this.lastSpokeAngle = -1;
318
+ this.firstSpokeAngle = -1;
319
+ // Clear display before starting fresh
320
+ if (this.data) this.data.fill(0);
321
+ if (this.ready && this.polarTexture && this.data) {
322
+ this.device.queue.writeTexture(
323
+ { texture: this.polarTexture },
324
+ this.data,
325
+ { bytesPerRow: this.max_spoke_len },
326
+ { width: this.max_spoke_len, height: this.spokesPerRevolution }
327
+ );
328
+ }
329
+ // Fall through to draw this spoke
330
+ } else {
331
+ // Still waiting for rotation to complete
332
+ this.lastWaitAngle = spoke.angle;
333
+ return;
334
+ }
335
+ }
336
+
337
+ if (this.actual_range != spoke.range) {
338
+ const wasInitialRange = this.actual_range === 0;
339
+ this.actual_range = spoke.range;
340
+ // Clear spoke data when range changes - old data is at wrong scale
341
+ this.data.fill(0);
342
+ // Reset rotation counter on range change
343
+ this.rotationCount = 0;
344
+ this.lastSpokeAngle = -1;
345
+ this.firstSpokeAngle = -1;
346
+ this.redrawCanvas();
347
+
348
+ // Only wait for full rotation on actual range CHANGE, not initial range setting
349
+ // This prevents ghost spokes from buffered old-range data
350
+ if (!wasInitialRange) {
351
+ this.waitForRotation = true;
352
+ this.waitStartAngle = -1;
353
+ this.seenAngleWrap = false;
354
+ // Upload cleared data to GPU
355
+ if (this.ready && this.polarTexture) {
356
+ this.device.queue.writeTexture(
357
+ { texture: this.polarTexture },
358
+ this.data,
359
+ { bytesPerRow: this.max_spoke_len },
360
+ { width: this.max_spoke_len, height: this.spokesPerRevolution }
361
+ );
362
+ this.render();
363
+ }
364
+ return; // Skip this spoke, it's from the old range
365
+ }
366
+ // For initial range, just continue drawing - no stale data to flush
367
+ }
368
+
369
+ // Track first spoke angle after clear (for limiting backward spread)
370
+ if (this.firstSpokeAngle < 0) {
371
+ this.firstSpokeAngle = spoke.angle;
372
+ }
373
+
374
+ // Track rotations: detect when we wrap around from high angle to low angle
375
+ if (this.lastSpokeAngle >= 0 && spoke.angle < this.lastSpokeAngle - this.spokesPerRevolution / 2) {
376
+ this.rotationCount++;
377
+ }
378
+ this.lastSpokeAngle = spoke.angle;
379
+
380
+ let offset = spoke.angle * this.max_spoke_len;
381
+
382
+ // Check if data fits in buffer
383
+ if (offset + spoke.data.length > this.data.length) {
384
+ console.error(`Buffer overflow: offset=${offset}, data.len=${spoke.data.length}, buf.len=${this.data.length}, angle=${spoke.angle}, maxSpokeLen=${this.max_spoke_len}, spokes=${this.spokesPerRevolution}`);
385
+ return;
386
+ }
387
+
388
+ const spokeLen = spoke.data.length;
389
+ const maxLen = this.max_spoke_len;
390
+
391
+ // Only use neighbor enhancement during first few rotations to fill display quickly
392
+ if (this.rotationCount < this.fillRotations) {
393
+ // Write spoke data with neighbor enhancement
394
+ // Strong signals spread wider, weak signals spread less
395
+ const spokes = this.spokesPerRevolution;
396
+
397
+ for (let i = 0; i < spokeLen; i++) {
398
+ const val = spoke.data[i];
399
+ // Write current spoke at full value
400
+ this.data[offset + i] = val;
401
+
402
+ if (val > 1) {
403
+ // Strong signals (>60): spread wide (±6 spokes) with higher intensity
404
+ // Medium signals (25-60): spread medium (±4 spokes)
405
+ // Weak signals (<25): spread narrow (±2 spokes) with lower intensity
406
+ let spreadWidth, blendFactors;
407
+
408
+ if (val > 60) {
409
+ // Strong signal - spread wide and strong
410
+ spreadWidth = 6;
411
+ blendFactors = [0.95, 0.88, 0.78, 0.65, 0.50, 0.35];
412
+ } else if (val > 25) {
413
+ // Medium signal - normal spread
414
+ spreadWidth = 4;
415
+ blendFactors = [0.85, 0.65, 0.45, 0.25];
416
+ } else {
417
+ // Weak signal - narrow spread, lower intensity
418
+ spreadWidth = 2;
419
+ blendFactors = [0.6, 0.3];
420
+ }
421
+
422
+ // Spread to neighboring spokes (both directions)
423
+ for (let d = 1; d <= spreadWidth; d++) {
424
+ const prev = (spoke.angle + spokes - d) % spokes;
425
+ const next = (spoke.angle + d) % spokes;
426
+ const prevOffset = prev * maxLen;
427
+ const nextOffset = next * maxLen;
428
+ const blendVal = Math.floor(val * blendFactors[d - 1]);
429
+
430
+ if (this.data[prevOffset + i] < blendVal) {
431
+ this.data[prevOffset + i] = blendVal;
432
+ }
433
+ if (this.data[nextOffset + i] < blendVal) {
434
+ this.data[nextOffset + i] = blendVal;
435
+ }
436
+ }
437
+ }
438
+ }
439
+ } else {
440
+ // RUN mode: smart filtering
441
+ // - Strong signals with neighbor support get amplified aggressively (wide check ±4)
442
+ // - Isolated weak signals (scatter) get killed
443
+ const spokes = this.spokesPerRevolution;
444
+
445
+ // Wide neighbor check for strong signals: ±4 spokes
446
+ const prev1Offset = ((spoke.angle + spokes - 1) % spokes) * maxLen;
447
+ const prev2Offset = ((spoke.angle + spokes - 2) % spokes) * maxLen;
448
+ const prev3Offset = ((spoke.angle + spokes - 3) % spokes) * maxLen;
449
+ const prev4Offset = ((spoke.angle + spokes - 4) % spokes) * maxLen;
450
+ const next1Offset = ((spoke.angle + 1) % spokes) * maxLen;
451
+ const next2Offset = ((spoke.angle + 2) % spokes) * maxLen;
452
+ const next3Offset = ((spoke.angle + 3) % spokes) * maxLen;
453
+ const next4Offset = ((spoke.angle + 4) % spokes) * maxLen;
454
+
455
+ for (let i = 0; i < spokeLen; i++) {
456
+ const val = spoke.data[i];
457
+
458
+ // Check neighbor support (from previous rotation's data still in buffer)
459
+ const prev1 = this.data[prev1Offset + i];
460
+ const prev2 = this.data[prev2Offset + i];
461
+ const prev3 = this.data[prev3Offset + i];
462
+ const prev4 = this.data[prev4Offset + i];
463
+ const next1 = this.data[next1Offset + i];
464
+ const next2 = this.data[next2Offset + i];
465
+ const next3 = this.data[next3Offset + i];
466
+ const next4 = this.data[next4Offset + i];
467
+
468
+ // For strong signals: use wide sum (±4)
469
+ const wideSum = prev1 + prev2 + prev3 + prev4 + next1 + next2 + next3 + next4;
470
+ const wideMax = Math.max(prev1, prev2, prev3, prev4, next1, next2, next3, next4);
471
+ // For weak signals: use narrow sum (±2)
472
+ const narrowSum = prev1 + prev2 + next1 + next2;
473
+ const narrowMax = Math.max(prev1, prev2, next1, next2);
474
+
475
+ let outputVal;
476
+
477
+ if (val > 60) {
478
+ // Strong signal: use wide neighbor check (±4)
479
+ if (wideSum > 200) {
480
+ // Solid mass - boost hard and spread to neighbors
481
+ outputVal = Math.min(255, Math.floor(val * 1.35));
482
+ // Boost immediate neighbors to fill gaps
483
+ if (prev1 > 25) this.data[prev1Offset + i] = Math.min(255, Math.floor(prev1 * 1.15));
484
+ if (next1 > 25) this.data[next1Offset + i] = Math.min(255, Math.floor(next1 * 1.15));
485
+ if (prev2 > 25) this.data[prev2Offset + i] = Math.min(255, Math.floor(prev2 * 1.1));
486
+ if (next2 > 25) this.data[next2Offset + i] = Math.min(255, Math.floor(next2 * 1.1));
487
+ } else if (wideMax > 50) {
488
+ // Some support - moderate boost
489
+ outputVal = Math.min(255, Math.floor(val * 1.2));
490
+ } else {
491
+ // Strong but isolated - suspicious, reduce
492
+ outputVal = Math.floor(val * 0.8);
493
+ }
494
+ } else if (val > 25) {
495
+ // Medium signal: needs good neighbor support
496
+ if (narrowSum > 80) {
497
+ // Good support - boost it
498
+ outputVal = Math.min(255, Math.floor(val * 1.2));
499
+ } else if (narrowMax > 40) {
500
+ // Some support - keep
501
+ outputVal = val;
502
+ } else {
503
+ // Isolated medium - likely scatter, punish hard
504
+ outputVal = Math.floor(val * 0.4);
505
+ }
506
+ } else if (val > 1) {
507
+ // Weak signal: kill it unless very well supported
508
+ if (narrowSum > 100) {
509
+ // Strong neighbors - this might be edge of real target
510
+ outputVal = val;
511
+ } else if (narrowMax > 60) {
512
+ // Next to something strong - keep faint
513
+ outputVal = Math.floor(val * 0.5);
514
+ } else {
515
+ // Isolated weak signal - kill it
516
+ outputVal = 0;
517
+ }
518
+ } else {
519
+ outputVal = val;
520
+ }
521
+
522
+ this.data[offset + i] = outputVal;
523
+ }
524
+ }
525
+
526
+ // Clear remainder of spoke if data is shorter than max
527
+ if (spokeLen < maxLen) {
528
+ this.data.fill(0, offset + spokeLen, offset + maxLen);
529
+ }
530
+ }
531
+
532
+ render() {
533
+ if (!this.ready || !this.data || !this.bindGroup) {
534
+ return;
535
+ }
536
+
537
+ // Upload spoke data to GPU
538
+ this.device.queue.writeTexture(
539
+ { texture: this.polarTexture },
540
+ this.data,
541
+ { bytesPerRow: this.max_spoke_len },
542
+ { width: this.max_spoke_len, height: this.spokesPerRevolution }
543
+ );
544
+
545
+ const encoder = this.device.createCommandEncoder();
546
+
547
+ const renderPass = encoder.beginRenderPass({
548
+ colorAttachments: [{
549
+ view: this.context.getCurrentTexture().createView(),
550
+ clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 0.0 },
551
+ loadOp: "clear",
552
+ storeOp: "store",
553
+ }],
554
+ });
555
+ renderPass.setPipeline(this.renderPipeline);
556
+ renderPass.setBindGroup(0, this.bindGroup);
557
+ renderPass.setVertexBuffer(0, this.vertexBuffer);
558
+ renderPass.draw(4);
559
+ renderPass.end();
560
+
561
+ this.device.queue.submit([encoder.finish()]);
562
+ }
563
+
564
+ redrawCanvas() {
565
+ var parent = this.dom.parentNode,
566
+ styles = getComputedStyle(parent),
567
+ w = parseInt(styles.getPropertyValue("width"), 10),
568
+ h = parseInt(styles.getPropertyValue("height"), 10);
569
+
570
+ this.dom.width = w;
571
+ this.dom.height = h;
572
+ this.background_dom.width = w;
573
+ this.background_dom.height = h;
574
+ if (this.overlay_dom) {
575
+ this.overlay_dom.width = w;
576
+ this.overlay_dom.height = h;
577
+ }
578
+
579
+ this.width = this.dom.width;
580
+ this.height = this.dom.height;
581
+ this.center_x = this.width / 2;
582
+ this.center_y = this.height / 2;
583
+ this.beam_length = Math.trunc(
584
+ Math.max(this.center_x, this.center_y) * RANGE_SCALE
585
+ );
586
+
587
+ this.drawBackgroundCallback(this, "MAYARA (WebGPU)");
588
+ this.#drawOverlay();
589
+
590
+ if (this.ready) {
591
+ this.context.configure({
592
+ device: this.device,
593
+ format: this.canvasFormat,
594
+ alphaMode: "premultiplied",
595
+ });
596
+ this.#updateUniforms();
597
+ }
598
+ }
599
+
600
+ // Format hours as TimeZero-style DAYS.HH:MM:SS
601
+ #formatHoursAsTimeZero(totalHours) {
602
+ const totalSeconds = Math.floor(totalHours * 3600);
603
+ const days = Math.floor(totalSeconds / 86400);
604
+ const remainingAfterDays = totalSeconds % 86400;
605
+ const hours = Math.floor(remainingAfterDays / 3600);
606
+ const minutes = Math.floor((remainingAfterDays % 3600) / 60);
607
+ const seconds = remainingAfterDays % 60;
608
+
609
+ const hh = hours.toString().padStart(2, '0');
610
+ const mm = minutes.toString().padStart(2, '0');
611
+ const ss = seconds.toString().padStart(2, '0');
612
+
613
+ return `${days}.${hh}:${mm}:${ss}`;
614
+ }
615
+
616
+ #drawStandbyOverlay(ctx) {
617
+ // Draw STANDBY text with ON-TIME and TX-Time in center of PPI
618
+ ctx.save();
619
+
620
+ // Large STANDBY text
621
+ ctx.fillStyle = "white";
622
+ ctx.font = "bold 36px/1 Verdana, Geneva, sans-serif";
623
+ ctx.textAlign = "center";
624
+ ctx.textBaseline = "middle";
625
+
626
+ // Add shadow for better readability
627
+ ctx.shadowColor = "black";
628
+ ctx.shadowBlur = 4;
629
+ ctx.shadowOffsetX = 2;
630
+ ctx.shadowOffsetY = 2;
631
+
632
+ // Calculate vertical position based on what we're showing
633
+ const hasAnyHours = this.hasOnTimeCapability || this.hasTxTimeCapability;
634
+ const standbyY = hasAnyHours ? this.center_y - 40 : this.center_y;
635
+
636
+ ctx.fillText("STANDBY", this.center_x, standbyY);
637
+
638
+ // Only show hours if capability exists
639
+ if (hasAnyHours) {
640
+ ctx.font = "bold 20px/1 Verdana, Geneva, sans-serif";
641
+
642
+ let yOffset = this.center_y + 10;
643
+
644
+ if (this.hasOnTimeCapability) {
645
+ const onTimeStr = this.#formatHoursAsTimeZero(this.onTimeHours);
646
+ ctx.fillText("ON-TIME: " + onTimeStr, this.center_x, yOffset);
647
+ yOffset += 30;
648
+ }
649
+
650
+ if (this.hasTxTimeCapability) {
651
+ const txTimeStr = this.#formatHoursAsTimeZero(this.txTimeHours);
652
+ ctx.fillText("TX-TIME: " + txTimeStr, this.center_x, yOffset);
653
+ }
654
+ }
655
+
656
+ ctx.restore();
657
+ }
658
+
659
+ #drawOverlay() {
660
+ if (!this.overlay_ctx) return;
661
+
662
+ const ctx = this.overlay_ctx;
663
+ const range = this.range || this.actual_range;
664
+
665
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
666
+ ctx.clearRect(0, 0, this.width, this.height);
667
+
668
+ // Draw standby overlay if in standby mode
669
+ if (this.standbyMode) {
670
+ this.#drawStandbyOverlay(ctx);
671
+ }
672
+
673
+ // Draw range rings in bright green on top of radar
674
+ ctx.strokeStyle = "#00ff00";
675
+ ctx.lineWidth = 1.5;
676
+ ctx.fillStyle = "#00ff00";
677
+ ctx.font = "bold 14px/1 Verdana, Geneva, sans-serif";
678
+
679
+ for (let i = 1; i <= 4; i++) {
680
+ const radius = (i * this.beam_length) / 4;
681
+ ctx.beginPath();
682
+ ctx.arc(this.center_x, this.center_y, radius, 0, 2 * Math.PI);
683
+ ctx.stroke();
684
+
685
+ // Draw range labels
686
+ if (range) {
687
+ const text = formatRangeValue(is_metric(range), (range * i) / 4);
688
+ // Position labels at 45 degrees (upper right)
689
+ const labelX = this.center_x + (radius * 0.707);
690
+ const labelY = this.center_y - (radius * 0.707);
691
+ ctx.fillText(text, labelX + 5, labelY - 5);
692
+ }
693
+ }
694
+
695
+ // Draw degree markers (compass rose) around the 3rd range ring
696
+ const degreeRingRadius = (3 * this.beam_length) / 4;
697
+ const tickLength = 8;
698
+ const majorTickLength = 12;
699
+ ctx.font = "bold 12px/1 Verdana, Geneva, sans-serif";
700
+ ctx.textAlign = "center";
701
+ ctx.textBaseline = "middle";
702
+
703
+ // Get heading mode and true heading for compass rose rotation
704
+ const headingMode = getHeadingMode();
705
+ const trueHeadingRad = getTrueHeading();
706
+ const trueHeadingDeg = (trueHeadingRad * 180) / Math.PI;
707
+
708
+ // In Heading Up mode: compass rose rotates so heading is at top
709
+ // In North Up mode: compass rose stays fixed with 0 (N) at top
710
+ const roseRotationDeg = headingMode === "headingUp" ? -trueHeadingDeg : 0;
711
+
712
+ for (let deg = 0; deg < 360; deg += 10) {
713
+ // Apply compass rose rotation
714
+ const displayDeg = deg + roseRotationDeg;
715
+
716
+ // Radar convention: 0° = top, angles increase clockwise
717
+ // Canvas: 0 radians = right (3 o'clock), increases counter-clockwise
718
+ // So we need: canvasAngle = -displayDeg + 90 (in degrees), or (90 - displayDeg) * PI/180
719
+ const radians = ((90 - displayDeg) * Math.PI) / 180;
720
+
721
+ const cos = Math.cos(radians);
722
+ const sin = Math.sin(radians);
723
+
724
+ // Determine tick length (longer for cardinal directions)
725
+ const isMajor = deg % 30 === 0;
726
+ const tick = isMajor ? majorTickLength : tickLength;
727
+
728
+ // Inner and outer points of tick mark
729
+ const innerRadius = degreeRingRadius - tick / 2;
730
+ const outerRadius = degreeRingRadius + tick / 2;
731
+
732
+ const x1 = this.center_x + innerRadius * cos;
733
+ const y1 = this.center_y - innerRadius * sin;
734
+ const x2 = this.center_x + outerRadius * cos;
735
+ const y2 = this.center_y - outerRadius * sin;
736
+
737
+ ctx.beginPath();
738
+ ctx.moveTo(x1, y1);
739
+ ctx.lineTo(x2, y2);
740
+ ctx.stroke();
741
+
742
+ // Draw degree labels at major ticks (every 30°)
743
+ if (isMajor) {
744
+ const labelRadius = degreeRingRadius + majorTickLength + 10;
745
+ const labelX = this.center_x + labelRadius * cos;
746
+ const labelY = this.center_y - labelRadius * sin;
747
+ ctx.fillText(deg.toString(), labelX, labelY);
748
+ }
749
+ }
750
+
751
+ // Draw North indicator (N) at 0° position
752
+ const northDeg = roseRotationDeg; // Where 0° (North) appears on screen
753
+ const northRadians = ((90 - northDeg) * Math.PI) / 180;
754
+ const northRadius = degreeRingRadius + majorTickLength + 25;
755
+ const northX = this.center_x + northRadius * Math.cos(northRadians);
756
+ const northY = this.center_y - northRadius * Math.sin(northRadians);
757
+ ctx.font = "bold 14px/1 Verdana, Geneva, sans-serif";
758
+ ctx.fillText("N", northX, northY);
759
+ }
760
+
761
+ #updateUniforms() {
762
+ const range = this.range || this.actual_range || 1500;
763
+ const scale = (1.0 * this.actual_range) / range;
764
+
765
+ const scaleX = scale * ((2 * this.beam_length) / this.width);
766
+ const scaleY = scale * ((2 * this.beam_length) / this.height);
767
+
768
+ // Pack uniforms: scaleX, scaleY, spokesPerRev, maxSpokeLen, headingRotation
769
+ const uniforms = new Float32Array([
770
+ scaleX, scaleY,
771
+ this.spokesPerRevolution || 2048,
772
+ this.max_spoke_len || 512,
773
+ this.headingRotation || 0, // Heading rotation in radians (for North Up mode)
774
+ 0, 0, 0 // padding to 32 bytes
775
+ ]);
776
+
777
+ this.device.queue.writeBuffer(this.uniformBuffer, 0, uniforms);
778
+
779
+ this.background_ctx.fillStyle = "lightgreen";
780
+ this.background_ctx.fillText("Beam length: " + this.beam_length + " px", 5, 40);
781
+ this.background_ctx.fillText("Display range: " + formatRangeValue(is_metric(range), range), 5, 60);
782
+ this.background_ctx.fillText("Radar range: " + formatRangeValue(is_metric(this.actual_range), this.actual_range), 5, 80);
783
+ this.background_ctx.fillText("Spoke length: " + (this.max_spoke_len || 0) + " px", 5, 100);
784
+ }
785
+ }
786
+
787
+ // Direct polar-to-cartesian shader with color lookup
788
+ // Radar convention: angle 0 = bow (up), angles increase CLOCKWISE
789
+ // So angle spokesPerRev/4 = starboard (right), spokesPerRev/2 = stern (down)
790
+ const shaderCode = `
791
+ struct VertexOutput {
792
+ @builtin(position) position: vec4<f32>,
793
+ @location(0) texCoord: vec2<f32>,
794
+ }
795
+
796
+ struct Uniforms {
797
+ scaleX: f32,
798
+ scaleY: f32,
799
+ spokesPerRev: f32,
800
+ maxSpokeLen: f32,
801
+ headingRotation: f32, // Rotation in radians for North Up mode
802
+ }
803
+
804
+ @group(0) @binding(3) var<uniform> uniforms: Uniforms;
805
+
806
+ @vertex
807
+ fn vertexMain(@location(0) pos: vec2<f32>, @location(1) texCoord: vec2<f32>) -> VertexOutput {
808
+ var output: VertexOutput;
809
+ // Apply scaling
810
+ let scaledPos = vec2<f32>(pos.x * uniforms.scaleX, pos.y * uniforms.scaleY);
811
+ output.position = vec4<f32>(scaledPos, 0.0, 1.0);
812
+ output.texCoord = texCoord;
813
+ return output;
814
+ }
815
+
816
+ @group(0) @binding(0) var polarData: texture_2d<f32>;
817
+ @group(0) @binding(1) var colorTable: texture_2d<f32>;
818
+ @group(0) @binding(2) var texSampler: sampler;
819
+
820
+ const PI: f32 = 3.14159265359;
821
+ const TWO_PI: f32 = 6.28318530718;
822
+
823
+ @fragment
824
+ fn fragmentMain(@location(0) texCoord: vec2<f32>) -> @location(0) vec4<f32> {
825
+ // Convert cartesian (texCoord) to polar for sampling radar data
826
+ // texCoord is [0,1]x[0,1], center at (0.5, 0.5)
827
+ //
828
+ // IMPORTANT: In our vertex setup, texCoord.y=0 is BOTTOM, texCoord.y=1 is TOP
829
+ // (WebGPU clip space has Y pointing up)
830
+ // So centered.y is POSITIVE at TOP of screen, NEGATIVE at BOTTOM
831
+ let centered = texCoord - vec2<f32>(0.5, 0.5);
832
+
833
+ // Calculate radius (0 at center, 1 at edge of unit circle)
834
+ let r = length(centered) * 2.0;
835
+
836
+ // Calculate angle from center for clockwise rotation from top (bow)
837
+ //
838
+ // Our coordinate system (after centering):
839
+ // - Top of screen (bow): centered = (0, +0.5)
840
+ // - Right of screen (stbd): centered = (+0.5, 0)
841
+ // - Bottom of screen (stern): centered = (0, -0.5)
842
+ // - Left of screen (port): centered = (-0.5, 0)
843
+ //
844
+ // Radar convention (from protobuf):
845
+ // - angle 0 = bow (top on screen)
846
+ // - angle increases clockwise: bow -> starboard -> stern -> port -> bow
847
+ //
848
+ // Use atan2(x, y) to get clockwise angle from top:
849
+ // - Top: (0, 0.5) -> atan2(0, 0.5) = 0
850
+ // - Right: (0.5, 0) -> atan2(0.5, 0) = PI/2
851
+ // - Bottom: (0, -0.5) -> atan2(0, -0.5) = PI
852
+ // - Left: (-0.5, 0) -> atan2(-0.5, 0) = -PI/2 -> normalized to 3PI/2
853
+ var theta = atan2(centered.x, centered.y);
854
+
855
+ // Apply heading rotation for North Up mode
856
+ // In North Up: we rotate the radar image by -heading, so we add heading to theta
857
+ // This samples the spoke data at (theta + heading), effectively rotating the display
858
+ theta = theta - uniforms.headingRotation;
859
+
860
+ if (theta < 0.0) {
861
+ theta = theta + TWO_PI;
862
+ }
863
+ if (theta >= TWO_PI) {
864
+ theta = theta - TWO_PI;
865
+ }
866
+
867
+ // Normalize to [0, 1] for texture V coordinate
868
+ let normalizedTheta = theta / TWO_PI;
869
+
870
+ // Sample polar data (always sample, mask later to avoid non-uniform control flow)
871
+ // U = radius [0,1], V = angle [0,1] where 0=bow, 0.25=starboard, 0.5=stern, 0.75=port
872
+ let radarValue = textureSample(polarData, texSampler, vec2<f32>(r, normalizedTheta)).r;
873
+
874
+ // Look up color from table
875
+ let color = textureSample(colorTable, texSampler, vec2<f32>(radarValue, 0.0));
876
+
877
+ // Mask pixels outside the radar circle (use step instead of if)
878
+ let insideCircle = step(r, 1.0);
879
+
880
+ // Use alpha from color table, but make background transparent
881
+ let hasData = step(0.004, radarValue); // ~1/255 threshold
882
+ let alpha = hasData * color.a * insideCircle;
883
+
884
+ return vec4<f32>(color.rgb * insideCircle, alpha);
885
+ }
886
+ `;