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/README.md +81 -59
- package/correctionTable.js +1 -1
- package/index.js +392 -264
- package/package.json +5 -2
- package/public/TableRenderer.js +114 -210
- package/public/app.js +804 -447
- package/public/index.html +257 -96
- package/public/main.css +79 -158
- package/public/vectors.html +0 -30
- package/public/vectors.js +0 -145
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "speedandcurrent",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
34
|
+
"signalkutilities": "^1.8.0"
|
|
32
35
|
},
|
|
33
36
|
"files": [
|
|
34
37
|
"index.js",
|
package/public/TableRenderer.js
CHANGED
|
@@ -1,239 +1,143 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
23
|
-
|
|
24
|
-
render(data) {
|
|
25
|
-
const {id, row, col, table, displayAttributes } = data;
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
38
|
+
el.appendChild(this._dataRow(r, rIndex, col, table, maxDev, fmtSpeedFn));
|
|
44
39
|
rIndex++;
|
|
45
40
|
}
|
|
46
|
-
return
|
|
41
|
+
return el;
|
|
47
42
|
}
|
|
48
43
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
51
|
+
const th = document.createElement('th');
|
|
52
|
+
th.textContent = fmtHeelFn(c);
|
|
53
|
+
th.classList.add('TablecolumnHeader');
|
|
54
|
+
tr.appendChild(th);
|
|
63
55
|
}
|
|
64
|
-
return
|
|
56
|
+
return tr;
|
|
65
57
|
}
|
|
66
58
|
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
70
|
+
return tr;
|
|
101
71
|
}
|
|
102
72
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
73
|
+
_cellElement(cell, maxDev) {
|
|
74
|
+
const td = document.createElement('td');
|
|
75
|
+
td.classList.add('TableCell');
|
|
106
76
|
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
99
|
+
td.style.backgroundColor = color;
|
|
214
100
|
}
|
|
215
|
-
color = this.blueScale(Math.pow(norm,0.5));
|
|
216
101
|
}
|
|
217
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|