@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
|
@@ -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
|
+
`;
|