signalk-livedepth-plugin 0.3.0 → 0.3.2

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.
@@ -28,7 +28,7 @@ function medianConsecutiveGap(samples2) {
28
28
  gaps.sort((a, b) => a - b);
29
29
  return gaps[Math.floor(gaps.length / 2)];
30
30
  }
31
- function buildDepthField(samples2, extent, options) {
31
+ function createDepthFieldBuilder(extent, options) {
32
32
  const cellMetres = options.cellMetres;
33
33
  if (!(cellMetres > 0) || extent.length !== 4 || !extent.every((v) => Number.isFinite(v)) || extent[2] <= extent[0] || extent[3] <= extent[1]) {
34
34
  return null;
@@ -69,52 +69,76 @@ function buildDepthField(samples2, extent, options) {
69
69
  );
70
70
  const coverage2 = coverageRadius * coverageRadius;
71
71
  const radiusCells = Math.min(64, Math.ceil(coverageRadius / cellMetres));
72
- for (const s of samples2) {
73
- if (!Number.isFinite(s.lon) || !Number.isFinite(s.lat) || !Number.isFinite(s.depth) || s.depth < 0) {
74
- continue;
72
+ const field = { width, height, extent: gridExtent, depth };
73
+ function refresh([rx0, ry0, rx1, ry1]) {
74
+ for (let gy = ry0; gy < ry1; gy++) {
75
+ const row = gy * width;
76
+ for (let gx = rx0; gx < rx1; gx++) {
77
+ const i = row + gx;
78
+ const w = wsum[i];
79
+ if (w <= 0) {
80
+ depth[i] = NaN;
81
+ continue;
82
+ }
83
+ const idw = wdsum[i] / w;
84
+ const shallowest = minSeen[i];
85
+ depth[i] = (1 - shallowBias) * idw + shallowBias * Math.min(idw, shallowest);
86
+ }
75
87
  }
76
- const sx = mercX(s.lon);
77
- const sy = mercY(s.lat);
78
- const cx = Math.floor((sx - minX) / cellMetres);
79
- const cy = Math.floor((sy - minY) / cellMetres);
80
- for (let dy = -radiusCells; dy <= radiusCells; dy++) {
81
- const gy = cy + dy;
82
- if (gy < 0 || gy >= height) {
88
+ }
89
+ function add(samples2) {
90
+ let rect = null;
91
+ const grow = (gx, gy) => {
92
+ if (!rect) {
93
+ rect = [gx, gy, gx + 1, gy + 1];
94
+ return;
95
+ }
96
+ if (gx < rect[0]) rect[0] = gx;
97
+ if (gy < rect[1]) rect[1] = gy;
98
+ if (gx + 1 > rect[2]) rect[2] = gx + 1;
99
+ if (gy + 1 > rect[3]) rect[3] = gy + 1;
100
+ };
101
+ for (const s of samples2) {
102
+ if (!Number.isFinite(s.lon) || !Number.isFinite(s.lat) || !Number.isFinite(s.depth) || s.depth < 0) {
83
103
  continue;
84
104
  }
85
- const cellCenterY = minY + (gy + 0.5) * cellMetres;
86
- const ddy = cellCenterY - sy;
87
- for (let dx = -radiusCells; dx <= radiusCells; dx++) {
88
- const gx = cx + dx;
89
- if (gx < 0 || gx >= width) {
105
+ const sx = mercX(s.lon);
106
+ const sy = mercY(s.lat);
107
+ const cx = Math.floor((sx - minX) / cellMetres);
108
+ const cy = Math.floor((sy - minY) / cellMetres);
109
+ for (let dy = -radiusCells; dy <= radiusCells; dy++) {
110
+ const gy = cy + dy;
111
+ if (gy < 0 || gy >= height) {
90
112
  continue;
91
113
  }
92
- const cellCenterX = minX + (gx + 0.5) * cellMetres;
93
- const ddx = cellCenterX - sx;
94
- const dist2 = ddx * ddx + ddy * ddy;
95
- if (dist2 > coverage2) {
96
- continue;
97
- }
98
- const idx = gy * width + gx;
99
- const w = 1 / (Math.pow(dist2, power / 2) + 1e-6);
100
- wsum[idx] += w;
101
- wdsum[idx] += w * s.depth;
102
- if (s.depth < minSeen[idx]) {
103
- minSeen[idx] = s.depth;
114
+ const cellCenterY = minY + (gy + 0.5) * cellMetres;
115
+ const ddy = cellCenterY - sy;
116
+ for (let dx = -radiusCells; dx <= radiusCells; dx++) {
117
+ const gx = cx + dx;
118
+ if (gx < 0 || gx >= width) {
119
+ continue;
120
+ }
121
+ const cellCenterX = minX + (gx + 0.5) * cellMetres;
122
+ const ddx = cellCenterX - sx;
123
+ const dist2 = ddx * ddx + ddy * ddy;
124
+ if (dist2 > coverage2) {
125
+ continue;
126
+ }
127
+ const idx = gy * width + gx;
128
+ const w = 1 / (Math.pow(dist2, power / 2) + 1e-6);
129
+ wsum[idx] += w;
130
+ wdsum[idx] += w * s.depth;
131
+ if (s.depth < minSeen[idx]) {
132
+ minSeen[idx] = s.depth;
133
+ }
134
+ grow(gx, gy);
104
135
  }
105
136
  }
106
137
  }
138
+ if (rect) refresh(rect);
139
+ return rect;
107
140
  }
108
- for (let i = 0; i < depth.length; i++) {
109
- const w = wsum[i];
110
- if (w <= 0) {
111
- continue;
112
- }
113
- const idw = wdsum[i] / w;
114
- const shallowest = minSeen[i];
115
- depth[i] = (1 - shallowBias) * idw + shallowBias * Math.min(idw, shallowest);
116
- }
117
- return { width, height, extent: gridExtent, depth };
141
+ return { field, add };
118
142
  }
119
143
 
120
144
  // src/web/shared/shaders.ts
@@ -369,6 +393,13 @@ function depthClusters(samples2, cellSizeMetres, tolerance = DEFAULT_MERGE_TOLER
369
393
  );
370
394
  }
371
395
 
396
+ // src/web/shared/settings.ts
397
+ var QUALITY_SCALE = {
398
+ high: 1,
399
+ balanced: 0.6,
400
+ low: 0.4
401
+ };
402
+
372
403
  // src/web/render-worker.ts
373
404
  var MAX_FIELD_CELLS = 1024;
374
405
  var LABEL_FONT_MIN = 12;
@@ -377,10 +408,27 @@ var LABEL_MIN_RADIUS_PX = 8;
377
408
  var ORIGIN_SHIFT = Math.PI * 6378137;
378
409
  var WORLD_WIDTH = 2 * ORIGIN_SHIFT;
379
410
  var samples = [];
411
+ var baseSamples = [];
412
+ var liveSamples = [];
380
413
  var settings = null;
381
- var field = null;
414
+ var builder = null;
382
415
  var fieldDirty = true;
416
+ var vessel = { position: null };
383
417
  var depthUnit = "m";
418
+ var samplesVersion = 0;
419
+ var shoalCache = null;
420
+ var clusterCache = null;
421
+ var AUTO_FRAME_BUDGET_MS = 10;
422
+ var AUTO_ADJUST_EVERY = 30;
423
+ var PERF_REPORT_EVERY = 20;
424
+ var autoScale = 0.7;
425
+ var frameAvgMs = 0;
426
+ var framesSinceAdjust = 0;
427
+ var perfFrames = 0;
428
+ function renderScale() {
429
+ const quality = settings?.quality ?? "auto";
430
+ return quality === "auto" ? autoScale : QUALITY_SCALE[quality];
431
+ }
384
432
  function formatDepth(metres) {
385
433
  return depthUnit === "foot" ? (metres / 0.3048).toFixed(0) : metres.toFixed(1);
386
434
  }
@@ -511,14 +559,34 @@ function uploadField(f) {
511
559
  gpu
512
560
  );
513
561
  }
514
- function buildField() {
515
- field = null;
516
- if (samples.length === 0 || !settings) return;
562
+ function uploadRegion(f, [x0, y0, x1, y1]) {
563
+ if (!gl || !depthTexture) return;
564
+ const w = x1 - x0;
565
+ const h = y1 - y0;
566
+ if (w <= 0 || h <= 0) return;
567
+ const gpu = new Float32Array(w * h);
568
+ for (let y = 0; y < h; y++) {
569
+ const src = (y0 + y) * f.width + x0;
570
+ const dst = y * w;
571
+ for (let x = 0; x < w; x++) {
572
+ const d = f.depth[src + x];
573
+ gpu[dst + x] = Number.isNaN(d) ? -1 : d;
574
+ }
575
+ }
576
+ gl.activeTexture(gl.TEXTURE0);
577
+ gl.bindTexture(gl.TEXTURE_2D, depthTexture);
578
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, x0, y0, w, h, gl.RED, gl.FLOAT, gpu);
579
+ }
580
+ function rebuildField() {
581
+ builder = null;
582
+ samples = [...baseSamples, ...liveSamples];
583
+ const source = baseSamples.length ? baseSamples : samples;
584
+ if (source.length === 0 || !settings) return;
517
585
  let minX = Infinity;
518
586
  let minY = Infinity;
519
587
  let maxX = -Infinity;
520
588
  let maxY = -Infinity;
521
- for (const s of samples) {
589
+ for (const s of source) {
522
590
  const x = mercX2(s.lon);
523
591
  const y = mercY2(s.lat);
524
592
  minX = Math.min(minX, x);
@@ -536,20 +604,33 @@ function buildField() {
536
604
  ];
537
605
  const extMax = Math.max(extent[2] - extent[0], extent[3] - extent[1]);
538
606
  const tiles = settings.source === "tiles";
539
- const gap = tiles ? 0 : medianConsecutiveGap(samples);
607
+ const gap = tiles ? 0 : medianConsecutiveGap(source);
540
608
  const minCover = tiles ? 0 : DEFAULT_COVERAGE_MIN_M;
541
609
  const cell = Math.max(gap, extMax / MAX_FIELD_CELLS, minCover / 8, 0.01);
542
- const built = buildDepthField(samples, extent, {
610
+ const next = createDepthFieldBuilder(extent, {
543
611
  cellMetres: cell,
544
612
  coverageMetres: Math.max(cell * 1.5, gap * 1.5),
545
613
  coverageMinMetres: minCover
546
614
  });
547
- if (!built) return;
548
- field = built;
549
- uploadField(built);
615
+ if (!next) return;
616
+ next.add(baseSamples);
617
+ if (liveSamples.length) next.add(liveSamples);
618
+ builder = next;
619
+ uploadField(next.field);
620
+ }
621
+ function addLive(newSamples) {
622
+ liveSamples.push(...newSamples);
623
+ samples.push(...newSamples);
624
+ samplesVersion++;
625
+ if (!builder) {
626
+ fieldDirty = true;
627
+ return;
628
+ }
629
+ const rect = builder.add(newSamples);
630
+ if (rect) uploadRegion(builder.field, rect);
550
631
  }
551
632
  function renderGpu(box, canvasW, canvasH, ratio) {
552
- const f = field;
633
+ const f = builder?.field;
553
634
  if (!gl || !f || !glCanvas) return;
554
635
  if (glCanvas.width !== canvasW || glCanvas.height !== canvasH) {
555
636
  glCanvas.width = canvasW;
@@ -620,16 +701,28 @@ function drawOverlay(ctx, box, canvasW, canvasH, resolution, ratio, zoom) {
620
701
  ctx.fillStyle = "rgba(0,0,0,0.95)";
621
702
  ctx.fillText(text, p[0], p[1]);
622
703
  };
623
- selectShoals(samples, settings.safetyDepthMetres).forEach(
624
- (s) => dot(toPixel(s.lon, s.lat), s.depth)
625
- );
704
+ const shoalKey = `${samplesVersion}:${settings.safetyDepthMetres}`;
705
+ if (!shoalCache || shoalCache.key !== shoalKey) {
706
+ shoalCache = {
707
+ key: shoalKey,
708
+ value: selectShoals(samples, settings.safetyDepthMetres)
709
+ };
710
+ }
711
+ shoalCache.value.forEach((s) => dot(toPixel(s.lon, s.lat), s.depth));
626
712
  const cellSize = Math.max(1, settings.cellPx * resolution);
627
- const clusters = depthClusters(
628
- samples,
629
- cellSize,
630
- settings.mergeTolerance,
631
- settings.maxDepthMetres
632
- );
713
+ const clusterKey = `${samplesVersion}:${cellSize}:${settings.mergeTolerance}:${settings.maxDepthMetres}`;
714
+ if (!clusterCache || clusterCache.key !== clusterKey) {
715
+ clusterCache = {
716
+ key: clusterKey,
717
+ value: depthClusters(
718
+ samples,
719
+ cellSize,
720
+ settings.mergeTolerance,
721
+ settings.maxDepthMetres
722
+ )
723
+ };
724
+ }
725
+ const clusters = clusterCache.value;
633
726
  for (const c of clusters) {
634
727
  const p = toPixel(c.lon, c.lat);
635
728
  dot(p, c.depth);
@@ -642,6 +735,36 @@ function drawOverlay(ctx, box, canvasW, canvasH, resolution, ratio, zoom) {
642
735
  label(formatDepth(c.depth), p, fontCss);
643
736
  }
644
737
  }
738
+ const v = vessel;
739
+ if (v.position && v.heading !== void 0 && v.loa && v.beam) {
740
+ const cx = mercX2(v.position[0]);
741
+ const cy = mercY2(v.position[1]);
742
+ const cos = Math.cos(v.heading);
743
+ const sin = Math.sin(v.heading);
744
+ const halfLength = v.loa / 2;
745
+ const halfBeam = v.beam / 2;
746
+ const corners = [
747
+ [halfLength, halfBeam],
748
+ [halfLength, -halfBeam],
749
+ [-halfLength, -halfBeam],
750
+ [-halfLength, halfBeam]
751
+ ].map(
752
+ ([fwd, stbd]) => toPixel(
753
+ lonOf(cx + fwd * sin + stbd * cos),
754
+ latOf(cy + fwd * cos - stbd * sin)
755
+ )
756
+ );
757
+ ctx.beginPath();
758
+ corners.forEach(
759
+ ([px, py], i) => i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py)
760
+ );
761
+ ctx.closePath();
762
+ ctx.lineWidth = Math.max(1.5, 1.5 * ratio);
763
+ ctx.strokeStyle = "rgba(255, 0, 255, 0.95)";
764
+ ctx.stroke();
765
+ ctx.fillStyle = "rgba(255, 0, 255, 0.12)";
766
+ ctx.fill();
767
+ }
645
768
  }
646
769
  function render(req) {
647
770
  try {
@@ -649,8 +772,8 @@ function render(req) {
649
772
  post({ type: "error", message: "WebGL2 unavailable" });
650
773
  return;
651
774
  }
652
- if (fieldDirty || !field) {
653
- buildField();
775
+ if (fieldDirty || !builder) {
776
+ rebuildField();
654
777
  fieldDirty = false;
655
778
  }
656
779
  const { extent, width, height, pixelRatio, resolution, zoom } = req;
@@ -660,6 +783,10 @@ function render(req) {
660
783
  extent[2],
661
784
  extent[3]
662
785
  ];
786
+ const scale = renderScale();
787
+ const surfaceW = Math.max(1, Math.round(width * scale));
788
+ const surfaceH = Math.max(1, Math.round(height * scale));
789
+ const started = performance.now();
663
790
  const out = new OffscreenCanvas(width, height);
664
791
  const ctx = out.getContext("2d");
665
792
  if (!ctx) {
@@ -667,12 +794,38 @@ function render(req) {
667
794
  return;
668
795
  }
669
796
  ctx.clearRect(0, 0, width, height);
670
- if (field) {
671
- renderGpu(box, width, height, pixelRatio);
672
- ctx.drawImage(glCanvas, 0, 0);
797
+ if (builder) {
798
+ renderGpu(box, surfaceW, surfaceH, pixelRatio * scale);
799
+ ctx.drawImage(
800
+ glCanvas,
801
+ 0,
802
+ 0,
803
+ surfaceW,
804
+ surfaceH,
805
+ 0,
806
+ 0,
807
+ width,
808
+ height
809
+ );
673
810
  }
674
811
  drawOverlay(ctx, box, width, height, resolution, pixelRatio, zoom);
675
812
  const image = out.transferToImageBitmap();
813
+ const frameMs = performance.now() - started;
814
+ if ((settings?.quality ?? "auto") === "auto") {
815
+ frameAvgMs = frameAvgMs === 0 ? frameMs : frameAvgMs * 0.8 + frameMs * 0.2;
816
+ if (++framesSinceAdjust >= AUTO_ADJUST_EVERY) {
817
+ framesSinceAdjust = 0;
818
+ if (frameAvgMs > AUTO_FRAME_BUDGET_MS * 1.25) {
819
+ autoScale = Math.max(0.4, autoScale - 0.1);
820
+ } else if (frameAvgMs < AUTO_FRAME_BUDGET_MS * 0.6) {
821
+ autoScale = Math.min(1, autoScale + 0.1);
822
+ }
823
+ }
824
+ }
825
+ if (++perfFrames >= PERF_REPORT_EVERY) {
826
+ perfFrames = 0;
827
+ post({ type: "perf", scale, frameMs });
828
+ }
676
829
  post({ type: "frame", requestId: req.requestId, image }, [image]);
677
830
  } catch (err) {
678
831
  post({ type: "error", message: String(err) });
@@ -684,9 +837,16 @@ function post(message, transfer) {
684
837
  self.onmessage = (e) => {
685
838
  const msg = e.data;
686
839
  switch (msg.type) {
687
- case "samples":
688
- samples = msg.samples;
840
+ case "base":
841
+ baseSamples = msg.samples;
689
842
  fieldDirty = true;
843
+ samplesVersion++;
844
+ break;
845
+ case "live":
846
+ addLive(msg.samples);
847
+ break;
848
+ case "vessel":
849
+ vessel = msg;
690
850
  break;
691
851
  case "settings":
692
852
  settings = msg.settings;