speedandcurrent 1.6.0 → 1.7.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "speedandcurrent",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "A plugin that automatically corrects boat speed and calculates current and leeway using sensor fusion.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -16,6 +16,9 @@
16
16
  "url": "https://github.com/Asw1n/speedandcurrent/issues"
17
17
  },
18
18
  "homepage": "https://github.com/Asw1n/speedandcurrent#readme",
19
+ "engines": {
20
+ "node": ">=18.0.0"
21
+ },
19
22
  "keywords": [
20
23
  "signalk-webapp",
21
24
  "signalk-node-server-plugin",
@@ -28,7 +31,7 @@
28
31
  },
29
32
  "dependencies": {
30
33
  "kalman-filter": "^2.3.0",
31
- "signalkutilities": "^1.5.1"
34
+ "signalkutilities": "^1.8.0"
32
35
  },
33
36
  "files": [
34
37
  "index.js",
@@ -1,239 +1,143 @@
1
- class TableRenderer {
2
- constructor() {
3
- this.formatColumnHeader = this.defaultDisplay;
4
- this.formatRowHeader = this.defaultDisplay;
5
- this.formatCell = this.defaultDisplay;
6
- this.colorMode = 'none';
7
- this.cachedExtents = {}; // store min/max per mode
8
- }
9
-
10
- setColumnHeaderFormat(columnHeader) {
11
- columnHeader != null ? this.formatColumnHeader = columnHeader : this.formatColumnHeader = this.defaultDisplay;
12
- }
1
+ // TableRenderer — purpose-built renderer for the correction table.
2
+ // Rows = speed bins (knots), columns = heel bins (degrees).
3
+ // Each learned cell shows factor deviation (±%) and leeway (°).
4
+ // Background encodes factor: green = paddlewheel reads slow, orange = reads fast.
5
+ // Active cell (last updated) gets a bold border; interpolation neighbours get a faint tint.
6
+
7
+ const RAD_TO_DEG = 180 / Math.PI;
8
+ const MPS_TO_KNOTS = 1.943844;
9
+
10
+ const DEFAULT_SPEED_SYMBOL = 'kn';
11
+ const DEFAULT_HEEL_SYMBOL = '°';
12
+ function fmtSpeed(mps) { return (mps * MPS_TO_KNOTS).toFixed(1); }
13
+ function fmtHeel(rad) { return (rad * RAD_TO_DEG).toFixed(0); }
14
+ function fmtFactor(f) { const p = (f - 1) * 100; return (p >= 0 ? '+' : '') + p.toFixed(1) + '%'; }
15
+ function fmtLeeway(rad) { const d = rad * RAD_TO_DEG; return (d >= 0 ? '+' : '') + d.toFixed(0) + '°'; }
13
16
 
14
- setRowHeaderFormat(rowHeader) {
15
- rowHeader != null ? this.formatRowHeader = rowHeader : this.formatRowHeader = this.defaultDisplay;
16
- }
17
-
18
- setCellFormat(cell) {
19
- cell != null ? this.formatCell = cell : this.formatCell = this.defaultDisplay;
20
- }
17
+ class TableRenderer {
21
18
 
22
- // Removed legacy X/Y specific formatting.
23
-
24
- render(data) {
25
- const {id, row, col, table, displayAttributes } = data;
26
- this.data = data;
27
- const label = displayAttributes && displayAttributes.label ? displayAttributes.label : "";
28
-
29
- // Pre-compute extents for color modes (skip weight which is already normalized)
30
- this.computeExtents(table);
31
-
32
- // Create a table element
33
- const tableElement = document.createElement('table');
34
-
35
- tableElement.id = id;
36
- tableElement.classList.add('Table2D');
37
- // create header row
38
- tableElement.appendChild(this.headerRow(col, label));
39
-
40
- // Create other rows
41
- var rIndex = 0;
19
+ // opts.fmtSpeed / opts.fmtHeel: optional unit-aware formatter functions.
20
+ // Fall back to the module-level fmtSpeed / fmtHeel when not provided.
21
+ render(data, opts = {}) {
22
+ const { id, row, col, table, displayAttributes } = data;
23
+ const label = displayAttributes?.label ?? '';
24
+ const maxDev = this._computeMaxDev(table);
25
+ const fmtSpeedFn = opts.fmtSpeed || fmtSpeed;
26
+ const fmtHeelFn = opts.fmtHeel || fmtHeel;
27
+ const speedSymbol = opts.speedSymbol || DEFAULT_SPEED_SYMBOL;
28
+ const heelSymbol = opts.heelSymbol || DEFAULT_HEEL_SYMBOL;
29
+ const cornerText = `${speedSymbol} / ${heelSymbol}`;
30
+
31
+ const el = document.createElement('table');
32
+ el.id = id;
33
+ el.classList.add('Table2D');
34
+ el.appendChild(this._headerRow(col, cornerText, fmtHeelFn));
35
+
36
+ let rIndex = 0;
42
37
  for (let r = row.min; r <= row.max + 0.01; r += row.step) {
43
- tableElement.appendChild(this.tableRow(r, rIndex, col, table));
38
+ el.appendChild(this._dataRow(r, rIndex, col, table, maxDev, fmtSpeedFn));
44
39
  rIndex++;
45
40
  }
46
- return tableElement;
41
+ return el;
47
42
  }
48
43
 
49
- headerRow(col, label) {
50
- // Create label cell
51
- const headerRow = document.createElement('tr');
52
- const firstHeaderCell = document.createElement('th');
53
- firstHeaderCell.innerText = label ? label : '';
54
- firstHeaderCell.classList.add('TableRowHeader');
55
- headerRow.appendChild(firstHeaderCell);
56
-
57
- // Create header row
44
+ _headerRow(col, cornerText, fmtHeelFn) {
45
+ const tr = document.createElement('tr');
46
+ const th0 = document.createElement('th');
47
+ th0.textContent = cornerText;
48
+ th0.classList.add('TableRowHeader', 'TableCorner');
49
+ tr.appendChild(th0);
58
50
  for (let c = col.min; c <= col.max + 0.01; c += col.step) {
59
- const headerCell = document.createElement('th');
60
- headerCell.innerHTML = this.formatColumnHeader(c);
61
- headerCell.classList.add('TablecolumnHeader');
62
- headerRow.appendChild(headerCell);
51
+ const th = document.createElement('th');
52
+ th.textContent = fmtHeelFn(c);
53
+ th.classList.add('TablecolumnHeader');
54
+ tr.appendChild(th);
63
55
  }
64
- return headerRow;
56
+ return tr;
65
57
  }
66
58
 
67
- rowLabel(r) {
68
- const headerCell = document.createElement('th');
69
- headerCell.innerHTML = this.formatRowHeader(r);
70
- //headerCell.innerText = `${cSpeed(r)} `;
71
- headerCell.classList.add('TableRowHeader');
72
- return headerCell;
73
- }
74
-
75
- tableRow(r, rIndex, col, table) {
76
- var cIndex = 0;
77
- const rowElement = document.createElement('tr');
78
- rowElement.appendChild(this.rowLabel(r));
59
+ _dataRow(r, rIndex, col, table, maxDev, fmtSpeedFn) {
60
+ const tr = document.createElement('tr');
61
+ const th = document.createElement('th');
62
+ th.textContent = fmtSpeedFn(r);
63
+ th.classList.add('TableRowHeader');
64
+ tr.appendChild(th);
65
+ let cIndex = 0;
79
66
  for (let c = col.min; c <= col.max + 0.01; c += col.step) {
80
- const cell = document.createElement('td');
81
- cell.classList.add('TableCell');
82
- const value = table[rIndex][cIndex];
83
- const text = this.formatCell(value, r, c);
84
- if (text != null) {
85
- cell.innerHTML = text;
86
- }
87
- else {
88
- cell.classList.add('emptyCell');
89
- }
90
- if (value.displayAttributes && value.displayAttributes.selected) {
91
- cell.classList.add('selectedCell');
92
- //console.log(value);
93
- }
94
- // Apply coloring based on selected color mode
95
- this.applyColor(cell, value, table);
96
-
97
- rowElement.appendChild(cell);
67
+ tr.appendChild(this._cellElement(table[rIndex][cIndex], maxDev));
98
68
  cIndex++;
99
69
  }
100
- return rowElement;
70
+ return tr;
101
71
  }
102
72
 
103
- defaultDisplay(value) {
104
- return value.toFixed(2);
105
- }
73
+ _cellElement(cell, maxDev) {
74
+ const td = document.createElement('td');
75
+ td.classList.add('TableCell');
106
76
 
107
- setColorMode(mode) {
108
- this.colorMode = mode || 'none';
109
- }
110
-
111
- computeExtents(table) {
112
- if (!Array.isArray(table)) return;
113
- const modesToCompute = ['leeway','factor','trace'];
114
- const accum = {};
115
- for (const m of modesToCompute) {
116
- accum[m] = {min: Infinity, max: -Infinity};
117
- }
118
- for (const row of table) {
119
- for (const cell of row) {
120
- if (!cell || cell.N === 0) continue;
121
- const {x,y} = cell;
122
- // Prefer server-provided leeway for consistent scaling; fallback to placeholder
123
- const leeway = Number.isFinite(cell.leeway)
124
- ? cell.leeway
125
- : (Number.isFinite(y) && Number.isFinite(x) ? Math.atan2(y, 1) : null);
126
- if (leeway != null) {
127
- if (leeway < accum.leeway.min) accum.leeway.min = leeway;
128
- if (leeway > accum.leeway.max) accum.leeway.max = leeway;
129
- }
130
- if (Number.isFinite(cell.factor)) {
131
- const f = cell.factor;
132
- if (f < accum.factor.min) accum.factor.min = f;
133
- if (f > accum.factor.max) accum.factor.max = f;
134
- }
135
- if (Number.isFinite(cell.trace)) {
136
- const t = cell.trace;
137
- if (t < accum.trace.min) accum.trace.min = t;
138
- if (t > accum.trace.max) accum.trace.max = t;
139
- }
140
- }
77
+ if (!cell || cell.N === 0) {
78
+ td.classList.add('cell--empty');
79
+ return td;
141
80
  }
142
- this.cachedExtents = accum;
143
- }
144
81
 
145
- applyColor(cell, value, table) {
146
- if (this.colorMode === 'none') return;
147
- // weight uses provided normWeight
148
- if (this.colorMode === 'weight') {
149
- const weight = value.displayAttributes && Number.isFinite(value.displayAttributes.normWeight) ? Math.min(1, value.displayAttributes.normWeight) : 0;
150
- const scaled = Math.pow(weight, 0.4);
151
- cell.style.backgroundColor = this.blueScale(scaled);
152
- return;
153
- }
154
- if (value.N === 0) return;
155
- let v = 0;
156
- if (this.colorMode === 'leeway') v = Number.isFinite(value.leeway) ? value.leeway : Math.atan2(value.y, 1); // prefer server-provided leeway
157
- else if (this.colorMode === 'factor') v = Number.isFinite(value.factor) ? value.factor : null;
158
- else if (this.colorMode === 'trace') v = Number.isFinite(value.trace) ? value.trace : null;
159
-
160
- const extent = this.cachedExtents[this.colorMode];
161
- if (!extent || !Number.isFinite(v) || extent.min === Infinity) return;
162
-
163
- let color;
164
- {
165
- // sequential for magnitude
166
- let norm;
167
- if (this.colorMode === 'leeway') {
168
- // leeway uses absolute value; recompute extent assuming cached min/max are signed
169
- const maxAbs = Math.max(Math.abs(extent.min), Math.abs(extent.max));
170
- norm = maxAbs > 0 ? Math.min(1, Math.abs(v) / maxAbs) : 0;
171
- if (norm === 0) {
172
- return; // blank for zero leeway
173
- }
174
- } else if (this.colorMode === 'factor') {
175
- // Asymmetric: baseline 1 -> blank. <1 use orange scale, >1 use green scale.
176
- if (!Number.isFinite(v) || v <= 0) return; // invalid or zero forward -> blank
177
- if (Math.abs(v - 1) < 1e-6) return; // exactly baseline
178
- // Determine deviation span using max distance from 1 among sampled extents
179
- const maxDev = Math.max(Math.abs(extent.min - 1), Math.abs(extent.max - 1));
180
- if (maxDev <= 0) return;
181
- const dev = (v - 1) / maxDev; // in [-1,1]
182
- const a = Math.min(1, Math.abs(dev));
183
- if (dev < 0) {
184
- // compression (factor<1): white -> orange (#FFA500)
185
- const r = Math.round(255 - (255-255)*a); // stays 255
186
- const g = Math.round(255 - (255-165)*a); // 255 -> 165
187
- const b = Math.round(255 - (255-0)*a); // 255 -> 0
188
- color = `rgb(${r},${g},${b})`;
189
- } else {
190
- // expansion (factor>1): white -> green (#00A050 slightly toned)
191
- const target = {r:0,g:160,b:80};
192
- const r = Math.round(255 - (255-target.r)*a);
193
- const g = Math.round(255 - (255-target.g)*a);
194
- const b = Math.round(255 - (255-target.b)*a);
195
- color = `rgb(${r},${g},${b})`;
196
- }
197
- cell.style.backgroundColor = color;
198
- return;
199
- } else if (this.colorMode === 'trace') {
200
- if (!Number.isFinite(v) || v <= 0) return; // blank for no/zero trace
201
- // Scale 0..1
202
- const normTrace = (v - extent.min) / (extent.max - extent.min || 1);
203
- if (normTrace <= 0) return;
204
- const t = Math.pow(Math.min(1, Math.max(0, normTrace)), 0.5); // gamma 0.5 for contrast
205
- // White -> Purple (#8000FF) gradient
206
- const target = { r: 128, g: 0, b: 255 };
207
- const r = Math.round(255 - (255 - target.r) * t);
208
- const g = Math.round(255 - (255 - target.g) * t);
209
- const b = Math.round(255 - (255 - target.b) * t);
210
- cell.style.backgroundColor = `rgb(${r},${g},${b})`;
211
- return;
82
+ const factor = Number.isFinite(cell.factor) ? cell.factor : null;
83
+ const leeway = Number.isFinite(cell.leeway) ? cell.leeway : null;
84
+
85
+ if (factor !== null) {
86
+ const d = document.createElement('div');
87
+ d.className = 'cell-factor';
88
+ d.textContent = fmtFactor(factor);
89
+ td.appendChild(d);
90
+ const color = this._factorColor(factor, maxDev);
91
+ if (leeway !== null) {
92
+ const angleDeg = 90 + leeway * RAD_TO_DEG * 1;
93
+ td.style.background = `repeating-linear-gradient(
94
+ ${angleDeg}deg,
95
+ ${color} 0px, ${color} 9px,
96
+ #f0f0f0 9px, #f0f0f0 10px
97
+ )`;
212
98
  } else {
213
- norm = (v - extent.min) / (extent.max - extent.min || 1); // 0..1
99
+ td.style.backgroundColor = color;
214
100
  }
215
- color = this.blueScale(Math.pow(norm,0.5));
216
101
  }
217
- cell.style.backgroundColor = color;
102
+ if (leeway !== null) {
103
+ const d = document.createElement('div');
104
+ d.className = 'cell-leeway';
105
+ d.textContent = fmtLeeway(leeway);
106
+ td.appendChild(d);
107
+ }
108
+
109
+ const attrs = cell.displayAttributes;
110
+ if (attrs?.selected) td.classList.add('cell--active');
111
+ else if (attrs?.normWeight > 0) td.classList.add('cell--neighbour');
112
+
113
+ return td;
218
114
  }
219
115
 
220
- blueScale(t) {
221
- // t in [0,1]; 0 = white, 1 = deep blue
222
- if (!Number.isFinite(t)) t = 0;
223
- if (t < 0) t = 0; else if (t > 1) t = 1;
224
- const r = Math.round(255 - 255 * t);
225
- const g = Math.round(255 - 255 * t);
226
- const b = 255;
227
- return `rgb(${r},${g},${b})`;
116
+ // Find the largest absolute factor deviation from 1 to normalise the color scale.
117
+ _computeMaxDev(table) {
118
+ let maxDev = 0;
119
+ for (const row of table) {
120
+ for (const cell of row) {
121
+ if (!cell || cell.N === 0 || !Number.isFinite(cell.factor)) continue;
122
+ const dev = Math.abs(cell.factor - 1);
123
+ if (dev > maxDev) maxDev = dev;
124
+ }
125
+ }
126
+ return maxDev || 0.05; // avoid a fully white table when all factors are near 1
228
127
  }
229
128
 
230
- redBlueDiverging(n) {
231
- // n in [-1,1]; negative=blue positive=red, 0=white
232
- const t = (n+1)/2; // 0..1
233
- const r = Math.round(255 * t);
234
- const g = Math.round(255 * (1 - Math.abs(n)));
235
- const b = Math.round(255 * (1 - t));
236
- return `rgb(${r},${g},${b})`;
129
+ // factor < 1: paddlewheel reads fast → white→orange
130
+ // factor > 1: paddlewheel reads slow → white→green
131
+ _factorColor(factor, maxDev) {
132
+ if (!Number.isFinite(factor)) return '';
133
+ const dev = factor - 1;
134
+ if (Math.abs(dev) < 1e-6) return '';
135
+ const a = Math.min(1, Math.abs(dev) / maxDev);
136
+ if (dev < 0) {
137
+ return `rgb(255,${Math.round(255 - 90 * a)},${Math.round(255 * (1 - a))})`; // white→orange
138
+ } else {
139
+ return `rgb(${Math.round(255 * (1 - a))},${Math.round(255 - 95 * a)},${Math.round(255 - 175 * a)})`; // white→green
140
+ }
237
141
  }
238
142
  }
239
143