signalk-livedepth-plugin 0.3.4 → 0.3.12

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.
@@ -163,6 +163,7 @@ uniform float uShadeLimit;
163
163
  uniform int uLevels;
164
164
  uniform float uInterval;
165
165
  uniform float uLineWidth;
166
+ uniform float uIsobars; // 1 = per-fragment isobars, 0 = vector contours drawn by the runtime
166
167
  bool uncov(float v) { return v < 0.0; } // -1: no coverage
167
168
  float sampleDepth(vec2 uv) {
168
169
  vec2 fc = uv / uTexel - 0.5;
@@ -193,16 +194,87 @@ void main() {
193
194
  float tCenter = (band + 0.5) / float(uLevels);
194
195
  fill = texture(uLut, vec2(clamp(tCenter, 0.0, 1.0), 0.5)).rgb;
195
196
  }
196
- // Isobars at every absolute multiple of the interval, over ALL data.
197
- float ci = d / uInterval;
198
- float dv = min(fract(ci), 1.0 - fract(ci));
199
- float px = dv / max(fwidth(ci), 1e-6);
200
- float line = 1.0 - smoothstep(uLineWidth, uLineWidth + 1.5, px);
197
+ // Isobars at every absolute multiple of the interval, over ALL data. The
198
+ // runtime can disable these and draw vector contours instead.
199
+ float line = 0.0;
200
+ if (uIsobars > 0.5) {
201
+ float ci = d / uInterval;
202
+ float dv = min(fract(ci), 1.0 - fract(ci));
203
+ float px = dv / max(fwidth(ci), 1e-6);
204
+ line = 1.0 - smoothstep(uLineWidth, uLineWidth + 1.5, px);
205
+ }
201
206
  vec3 col = mix(fill, vec3(0.0), line);
202
207
  float alpha = max(line, shaded ? 1.0 : 0.0);
203
208
  fragColor = vec4(col, alpha);
204
209
  }`;
205
210
 
211
+ // src/web/shared/contours.ts
212
+ function onGrid(level, step) {
213
+ const n = level / step;
214
+ return Math.abs(n - Math.round(n)) < 1e-6;
215
+ }
216
+ function extractContours(field, minorStep, majorStep, maxLevel) {
217
+ const { width, height, depth, extent } = field;
218
+ const result = { major: [], minor: [] };
219
+ const minor = minorStep > 0 ? minorStep : 1;
220
+ const major = majorStep > 0 ? majorStep : minor;
221
+ if (width < 2 || height < 2) return result;
222
+ const cell = (extent[2] - extent[0]) / width;
223
+ const at = (gx, gy) => depth[gy * width + gx];
224
+ const ox = [0, 1, 1, 0];
225
+ const oy = [0, 0, 1, 1];
226
+ const step = Math.min(minor, major);
227
+ for (let gy = 0; gy < height - 1; gy++) {
228
+ for (let gx = 0; gx < width - 1; gx++) {
229
+ const d = [
230
+ at(gx, gy),
231
+ at(gx + 1, gy),
232
+ at(gx + 1, gy + 1),
233
+ at(gx, gy + 1)
234
+ ];
235
+ if (!Number.isFinite(d[0]) || !Number.isFinite(d[1]) || !Number.isFinite(d[2]) || !Number.isFinite(d[3])) {
236
+ continue;
237
+ }
238
+ const lo = Math.min(d[0], d[1], d[2], d[3]);
239
+ const hi = Math.max(d[0], d[1], d[2], d[3]);
240
+ if (hi <= lo) continue;
241
+ const first = Math.floor(lo / step) * step + step;
242
+ for (let level = first; level < hi && level <= maxLevel; level += step) {
243
+ if (level <= lo) continue;
244
+ const out = onGrid(level, major) ? result.major : result.minor;
245
+ const cross = [null, null, null, null];
246
+ const crossed = [];
247
+ for (let i = 0; i < 4; i++) {
248
+ const j = (i + 1) % 4;
249
+ if (d[i] >= level === d[j] >= level) {
250
+ continue;
251
+ }
252
+ const t = (level - d[i]) / (d[j] - d[i]);
253
+ cross[i] = [
254
+ extent[0] + (gx + ox[i] + (ox[j] - ox[i]) * t) * cell,
255
+ extent[1] + (gy + oy[i] + (oy[j] - oy[i]) * t) * cell
256
+ ];
257
+ crossed.push(i);
258
+ }
259
+ if (crossed.length === 2) {
260
+ const [a, b] = crossed;
261
+ out.push([...cross[a], ...cross[b]]);
262
+ } else if (crossed.length === 4) {
263
+ const centre = (d[0] + d[1] + d[2] + d[3]) / 4;
264
+ if (centre >= level) {
265
+ out.push([...cross[0], ...cross[1]]);
266
+ out.push([...cross[2], ...cross[3]]);
267
+ } else {
268
+ out.push([...cross[0], ...cross[3]]);
269
+ out.push([...cross[1], ...cross[2]]);
270
+ }
271
+ }
272
+ }
273
+ }
274
+ }
275
+ return result;
276
+ }
277
+
206
278
  // src/web/shared/live-depth.ts
207
279
  var DEFAULT_SHADE_LIMIT_M = 10;
208
280
  var EARTH_RADIUS2 = 6378137;
@@ -222,11 +294,11 @@ function latOf(y) {
222
294
  return (2 * Math.atan(Math.exp(y / EARTH_RADIUS2)) - Math.PI / 2) / RAD2;
223
295
  }
224
296
  var SHADE_RAMP = [
225
- [0, 26, 132, 216],
226
- [0.3, 96, 178, 232],
227
- [0.55, 156, 209, 240],
228
- [0.78, 205, 232, 248],
229
- [1, 255, 255, 255]
297
+ [0, 37, 175, 248],
298
+ [0.25, 143, 215, 248],
299
+ [0.5, 167, 223, 248],
300
+ [0.75, 183, 223, 248],
301
+ [1, 248, 248, 248]
230
302
  ];
231
303
  var SHADE_CONTOUR_LEVELS = 6;
232
304
  function shadeColor(t) {
@@ -417,11 +489,21 @@ var liveSamples = [];
417
489
  var settings = null;
418
490
  var builder = null;
419
491
  var fieldDirty = true;
492
+ var fieldVersion = 0;
493
+ var surfaceCache = null;
494
+ var outCanvas = null;
495
+ var outCtx = null;
496
+ var SURFACE_PAD = 0.25;
497
+ var overlayCache = null;
498
+ var overlayCanvas = null;
499
+ var overlayCtx = null;
420
500
  var vessel = { position: null };
421
501
  var depthUnit = "m";
422
502
  var samplesVersion = 0;
423
503
  var shoalCache = null;
424
504
  var clusterCache = null;
505
+ var contours = { major: [], minor: [] };
506
+ var gpuMsaa = true;
425
507
  var AUTO_FRAME_BUDGET_MS = 10;
426
508
  var AUTO_ADJUST_EVERY = 30;
427
509
  var PERF_REPORT_EVERY = 20;
@@ -430,6 +512,7 @@ var frameAvgMs = 0;
430
512
  var framesSinceAdjust = 0;
431
513
  var perfFrames = 0;
432
514
  function renderScale() {
515
+ if (settings?.fullResolution) return 1;
433
516
  const quality = settings?.quality ?? "auto";
434
517
  return quality === "auto" ? autoScale : QUALITY_SCALE[quality];
435
518
  }
@@ -452,6 +535,7 @@ var uShadeLimit = null;
452
535
  var uLevels = null;
453
536
  var uInterval = null;
454
537
  var uLineWidth = null;
538
+ var uIsobars = null;
455
539
  function compile(ctx, type, src) {
456
540
  const shader = ctx.createShader(type);
457
541
  if (!shader) return null;
@@ -466,11 +550,14 @@ function compile(ctx, type, src) {
466
550
  function initWebgl() {
467
551
  if (gl) return true;
468
552
  glCanvas = new OffscreenCanvas(1, 1);
553
+ const msaa = settings?.msaa ?? true;
469
554
  const ctx = glCanvas.getContext("webgl2", {
470
555
  alpha: true,
471
- premultipliedAlpha: true
556
+ premultipliedAlpha: true,
557
+ antialias: msaa
472
558
  });
473
559
  if (!ctx) return false;
560
+ gpuMsaa = msaa;
474
561
  const vs = compile(ctx, ctx.VERTEX_SHADER, DEPTH_VERT_SRC);
475
562
  const fs = compile(ctx, ctx.FRAGMENT_SHADER, DEPTH_FRAG_SRC);
476
563
  const prog = ctx.createProgram();
@@ -491,6 +578,7 @@ function initWebgl() {
491
578
  uLevels = ctx.getUniformLocation(prog, "uLevels");
492
579
  uInterval = ctx.getUniformLocation(prog, "uInterval");
493
580
  uLineWidth = ctx.getUniformLocation(prog, "uLineWidth");
581
+ uIsobars = ctx.getUniformLocation(prog, "uIsobars");
494
582
  quad = ctx.createBuffer();
495
583
  ctx.bindBuffer(ctx.ARRAY_BUFFER, quad);
496
584
  ctx.bufferData(
@@ -541,6 +629,14 @@ function releaseGpu() {
541
629
  depthTexture = null;
542
630
  lutTexture = null;
543
631
  glCanvas = null;
632
+ surfaceCache?.image.close();
633
+ surfaceCache = null;
634
+ outCanvas = null;
635
+ outCtx = null;
636
+ overlayCache?.image.close();
637
+ overlayCache = null;
638
+ overlayCanvas = null;
639
+ overlayCtx = null;
544
640
  }
545
641
  function uploadField(f) {
546
642
  if (!gl || !depthTexture) return;
@@ -562,6 +658,7 @@ function uploadField(f) {
562
658
  gl.FLOAT,
563
659
  gpu
564
660
  );
661
+ fieldVersion++;
565
662
  }
566
663
  function uploadRegion(f, [x0, y0, x1, y1]) {
567
664
  if (!gl || !depthTexture) return;
@@ -580,6 +677,7 @@ function uploadRegion(f, [x0, y0, x1, y1]) {
580
677
  gl.activeTexture(gl.TEXTURE0);
581
678
  gl.bindTexture(gl.TEXTURE_2D, depthTexture);
582
679
  gl.texSubImage2D(gl.TEXTURE_2D, 0, x0, y0, w, h, gl.RED, gl.FLOAT, gpu);
680
+ fieldVersion++;
583
681
  }
584
682
  function rebuildField() {
585
683
  builder = null;
@@ -610,9 +708,14 @@ function rebuildField() {
610
708
  const tiles = settings.source === "tiles";
611
709
  const gap = tiles ? 0 : medianConsecutiveGap(source);
612
710
  const minCover = tiles ? 0 : DEFAULT_COVERAGE_MIN_M;
613
- const cell = Math.max(gap, extMax / MAX_FIELD_CELLS, minCover / 8, 0.01);
711
+ const maxCells = Math.max(
712
+ 64,
713
+ Math.round(settings.fieldCells) || MAX_FIELD_CELLS
714
+ );
715
+ const cell = Math.max(gap, extMax / maxCells, minCover / 8, 0.01);
614
716
  const next = createDepthFieldBuilder(extent, {
615
717
  cellMetres: cell,
718
+ maxCells,
616
719
  // `gap` bridges the along-track gaps, the fill radius widens a sounding
617
720
  // into empty water, and a sounding always covers its own cell.
618
721
  coverageMetres: Math.max(cell * 1.5, gap * 1.5, settings.fillMetres),
@@ -623,6 +726,12 @@ function rebuildField() {
623
726
  if (liveSamples.length) next.add(liveSamples);
624
727
  builder = next;
625
728
  uploadField(next.field);
729
+ contours = settings.vectorContours ? extractContours(
730
+ next.field,
731
+ Math.max(0.01, settings.minorContourMetres),
732
+ Math.max(0.01, settings.contourIntervalMetres),
733
+ Math.max(0, settings.maxDepthMetres)
734
+ ) : { major: [], minor: [] };
626
735
  }
627
736
  function addLive(newSamples) {
628
737
  liveSamples.push(...newSamples);
@@ -669,11 +778,45 @@ function renderGpu(box, canvasW, canvasH, ratio) {
669
778
  gl.uniform1i(uLevels, SHADE_CONTOUR_LEVELS);
670
779
  gl.uniform1f(uInterval, Math.max(0.01, settings?.contourIntervalMetres ?? 1));
671
780
  gl.uniform1f(uLineWidth, Math.max(1, ratio));
781
+ gl.uniform1f(uIsobars, settings?.vectorContours ? 0 : 1);
672
782
  gl.bindBuffer(gl.ARRAY_BUFFER, quad);
673
783
  gl.enableVertexAttribArray(aPos);
674
784
  gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
675
785
  gl.drawArrays(gl.TRIANGLES, 0, 6);
676
786
  }
787
+ function drawContours(ctx, box, canvasW, canvasH, ratio) {
788
+ if (!settings?.vectorContours) return;
789
+ const f = builder?.field;
790
+ if (!f || contours.major.length === 0 && contours.minor.length === 0) {
791
+ return;
792
+ }
793
+ const boxW = box[2] - box[0];
794
+ const boxH = box[3] - box[1];
795
+ if (!(boxW > 0) || !(boxH > 0)) return;
796
+ const ext = f.extent;
797
+ const shift = Math.round(((ext[0] + ext[2]) / 2 - (box[0] + box[2]) / 2) / WORLD_WIDTH) * WORLD_WIDTH;
798
+ const px = (x) => (x + shift - box[0]) / boxW * canvasW;
799
+ const py = (y) => (box[3] - y) / boxH * canvasH;
800
+ const stroke = (segments, color, widthPx) => {
801
+ if (segments.length === 0) return;
802
+ ctx.beginPath();
803
+ for (const [x0, y0, x1, y1] of segments) {
804
+ ctx.moveTo(px(x0), py(y0));
805
+ ctx.lineTo(px(x1), py(y1));
806
+ }
807
+ ctx.lineWidth = Math.max(0.5, widthPx * ratio);
808
+ ctx.lineCap = "round";
809
+ ctx.lineJoin = "round";
810
+ ctx.strokeStyle = color;
811
+ ctx.stroke();
812
+ };
813
+ stroke(
814
+ contours.minor,
815
+ settings.minorContourColor,
816
+ settings.minorContourWidthPx
817
+ );
818
+ stroke(contours.major, settings.majorContourColor, settings.contourWidthPx);
819
+ }
677
820
  function drawOverlay(ctx, box, canvasW, canvasH, resolution, ratio, zoom) {
678
821
  if (!settings) return;
679
822
  const boxW = box[2] - box[0];
@@ -707,6 +850,7 @@ function drawOverlay(ctx, box, canvasW, canvasH, resolution, ratio, zoom) {
707
850
  ctx.fillStyle = "rgba(0,0,0,0.95)";
708
851
  ctx.fillText(text, p[0], p[1]);
709
852
  };
853
+ drawContours(ctx, box, canvasW, canvasH, ratio);
710
854
  const shoalKey = `${samplesVersion}:${settings.safetyDepthMetres}`;
711
855
  if (!shoalCache || shoalCache.key !== shoalKey) {
712
856
  shoalCache = {
@@ -731,7 +875,6 @@ function drawOverlay(ctx, box, canvasW, canvasH, resolution, ratio, zoom) {
731
875
  const clusters = clusterCache.value;
732
876
  for (const c of clusters) {
733
877
  const p = toPixel(c.lon, c.lat);
734
- dot(p, c.depth);
735
878
  const radiusPx = c.radiusMetres / resolution * ratio;
736
879
  if (radiusPx >= LABEL_MIN_RADIUS_PX) {
737
880
  const fontCss = Math.max(
@@ -739,6 +882,8 @@ function drawOverlay(ctx, box, canvasW, canvasH, resolution, ratio, zoom) {
739
882
  Math.min(LABEL_FONT_MAX, (zoom - 8) * 1.5 + LABEL_FONT_MIN)
740
883
  );
741
884
  label(formatDepth(c.depth), p, fontCss);
885
+ } else {
886
+ dot(p, c.depth);
742
887
  }
743
888
  }
744
889
  const v = vessel;
@@ -793,31 +938,112 @@ function render(req) {
793
938
  const surfaceW = Math.max(1, Math.round(width * scale));
794
939
  const surfaceH = Math.max(1, Math.round(height * scale));
795
940
  const started = performance.now();
796
- const out = new OffscreenCanvas(width, height);
797
- const ctx = out.getContext("2d");
941
+ if (!outCanvas || !outCtx || outCanvas.width !== width || outCanvas.height !== height) {
942
+ outCanvas = new OffscreenCanvas(width, height);
943
+ outCtx = outCanvas.getContext("2d");
944
+ }
945
+ const out = outCanvas;
946
+ const ctx = outCtx;
798
947
  if (!ctx) {
799
948
  post({ type: "error", message: "no 2D context" });
800
949
  return;
801
950
  }
802
951
  ctx.clearRect(0, 0, width, height);
803
- if (builder) {
804
- renderGpu(box, surfaceW, surfaceH, pixelRatio * scale);
805
- ctx.drawImage(
806
- glCanvas,
807
- 0,
808
- 0,
809
- surfaceW,
810
- surfaceH,
811
- 0,
812
- 0,
813
- width,
814
- height
815
- );
952
+ let redrew = false;
953
+ if (builder && glCanvas) {
954
+ const padX = (box[2] - box[0]) * SURFACE_PAD;
955
+ const padY = (box[3] - box[1]) * SURFACE_PAD;
956
+ const padBox = [
957
+ box[0] - padX,
958
+ box[1] - padY,
959
+ box[2] + padX,
960
+ box[3] + padY
961
+ ];
962
+ const padW = Math.max(1, Math.round(surfaceW * (1 + SURFACE_PAD * 2)));
963
+ const padH = Math.max(1, Math.round(surfaceH * (1 + SURFACE_PAD * 2)));
964
+ const surfaceKey = [
965
+ fieldVersion,
966
+ padW,
967
+ padH,
968
+ pixelRatio,
969
+ settings?.shadeLimitMetres,
970
+ settings?.contourIntervalMetres,
971
+ settings?.vectorContours,
972
+ settings?.msaa
973
+ ].join("|");
974
+ const cached = surfaceCache;
975
+ const covers = !!cached && cached.key === surfaceKey && box[0] >= cached.box[0] && box[1] >= cached.box[1] && box[2] <= cached.box[2] && box[3] <= cached.box[3];
976
+ if (!covers) {
977
+ renderGpu(padBox, padW, padH, pixelRatio * scale);
978
+ cached?.image.close();
979
+ surfaceCache = {
980
+ key: surfaceKey,
981
+ image: glCanvas.transferToImageBitmap(),
982
+ box: padBox,
983
+ width: padW,
984
+ height: padH
985
+ };
986
+ redrew = true;
987
+ }
988
+ const cache = surfaceCache;
989
+ const cw = cache.box[2] - cache.box[0];
990
+ const ch = cache.box[3] - cache.box[1];
991
+ const sx = (box[0] - cache.box[0]) / cw * cache.width;
992
+ const sy = (cache.box[3] - box[3]) / ch * cache.height;
993
+ const sw = (box[2] - box[0]) / cw * cache.width;
994
+ const sh = (box[3] - box[1]) / ch * cache.height;
995
+ ctx.drawImage(cache.image, sx, sy, sw, sh, 0, 0, width, height);
816
996
  }
817
- drawOverlay(ctx, box, width, height, resolution, pixelRatio, zoom);
997
+ const overlayKey = [
998
+ fieldVersion,
999
+ samplesVersion,
1000
+ box.join(","),
1001
+ width,
1002
+ height,
1003
+ resolution,
1004
+ pixelRatio,
1005
+ zoom,
1006
+ settings?.vectorContours,
1007
+ settings?.contourIntervalMetres,
1008
+ settings?.minorContourMetres,
1009
+ settings?.contourWidthPx,
1010
+ settings?.minorContourWidthPx,
1011
+ settings?.majorContourColor,
1012
+ settings?.minorContourColor,
1013
+ settings?.safetyDepthMetres,
1014
+ settings?.cellPx,
1015
+ settings?.mergeTolerance,
1016
+ settings?.maxDepthMetres,
1017
+ settings?.shadeLimitMetres
1018
+ ].join("|");
1019
+ if (!overlayCache || overlayCache.key !== overlayKey) {
1020
+ if (!overlayCanvas || !overlayCtx || overlayCanvas.width !== width || overlayCanvas.height !== height) {
1021
+ overlayCanvas = new OffscreenCanvas(width, height);
1022
+ overlayCtx = overlayCanvas.getContext("2d");
1023
+ }
1024
+ if (overlayCtx) {
1025
+ overlayCtx.clearRect(0, 0, width, height);
1026
+ drawOverlay(
1027
+ overlayCtx,
1028
+ box,
1029
+ width,
1030
+ height,
1031
+ resolution,
1032
+ pixelRatio,
1033
+ zoom
1034
+ );
1035
+ overlayCache?.image.close();
1036
+ overlayCache = {
1037
+ key: overlayKey,
1038
+ image: overlayCanvas.transferToImageBitmap()
1039
+ };
1040
+ redrew = true;
1041
+ }
1042
+ }
1043
+ if (overlayCache) ctx.drawImage(overlayCache.image, 0, 0);
818
1044
  const image = out.transferToImageBitmap();
819
1045
  const frameMs = performance.now() - started;
820
- if ((settings?.quality ?? "auto") === "auto") {
1046
+ if (redrew && (settings?.quality ?? "auto") === "auto") {
821
1047
  frameAvgMs = frameAvgMs === 0 ? frameMs : frameAvgMs * 0.8 + frameMs * 0.2;
822
1048
  if (++framesSinceAdjust >= AUTO_ADJUST_EVERY) {
823
1049
  framesSinceAdjust = 0;
@@ -854,10 +1080,12 @@ self.onmessage = (e) => {
854
1080
  case "vessel":
855
1081
  vessel = msg;
856
1082
  break;
857
- case "settings":
1083
+ case "settings": {
1084
+ if (msg.settings.msaa !== gpuMsaa) releaseGpu();
858
1085
  settings = msg.settings;
859
1086
  fieldDirty = true;
860
1087
  break;
1088
+ }
861
1089
  case "units":
862
1090
  depthUnit = msg.depth;
863
1091
  break;