speedandcurrent 1.4.2 → 1.5.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/LICENSE +1 -1
- package/README.md +179 -78
- package/correctionTable.js +149 -6
- package/index.js +363 -430
- package/leakyExtremes.js +98 -0
- package/package.json +12 -7
- package/public/TableRenderer.js +135 -83
- package/public/app.js +214 -55
- package/public/index.html +20 -5
- package/public/main.css +2 -2
- package/CHANGELOG.md +0 -28
- package/correctionModel.png +0 -0
- package/currentModel.png +0 -0
- package/models.md +0 -26
- package/public/surfaceplot.html +0 -78
package/leakyExtremes.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
class LeakyExtremes {
|
|
2
|
+
/**
|
|
3
|
+
* @param {Object} opts
|
|
4
|
+
* @param {number} opts.initialMin
|
|
5
|
+
* @param {number} opts.initialMax
|
|
6
|
+
* @param {number} opts.tau - time constant in seconds for exponential decay
|
|
7
|
+
* @param {number} opts.catchupTau - time constant in seconds for fast catchup
|
|
8
|
+
* @param {boolean} opts.isAngle - true if data is angular (radians)
|
|
9
|
+
* @param {number} opts.period - period for angle wrapping (e.g., 2*Math.PI)
|
|
10
|
+
*/
|
|
11
|
+
constructor({ initialMin=0, initialMax=0, tau = .15, catchupTau=.5, isAngle = false, period = 2 * Math.PI }) {
|
|
12
|
+
this.min = initialMin;
|
|
13
|
+
this.max = initialMax;
|
|
14
|
+
this.tau = tau;
|
|
15
|
+
this.catchupTau = catchupTau;
|
|
16
|
+
this.isAngle = isAngle;
|
|
17
|
+
this.period = period;
|
|
18
|
+
this.lastUpdate = Date.now();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Helper for angle difference in [-period/2, period/2]
|
|
22
|
+
angleDiff(a, b) {
|
|
23
|
+
let d = a - b;
|
|
24
|
+
while (d > this.period / 2) d -= this.period;
|
|
25
|
+
while (d < -this.period / 2) d += this.period;
|
|
26
|
+
return d;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Helper for angle normalization to [0, period)
|
|
30
|
+
normalizeAngle(a) {
|
|
31
|
+
while (a < 0) a += this.period;
|
|
32
|
+
while (a >= this.period) a -= this.period;
|
|
33
|
+
return a;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
update(obs) {
|
|
37
|
+
if (!isFinite(obs) ) return;
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
const dt = (now - this.lastUpdate) / 1000;
|
|
40
|
+
if (dt==0) return;
|
|
41
|
+
this.lastUpdate = now;
|
|
42
|
+
|
|
43
|
+
if (!this.isAngle) {
|
|
44
|
+
// Linear data
|
|
45
|
+
// Update min
|
|
46
|
+
if (obs < this.min) {
|
|
47
|
+
const alpha = 1 - Math.exp(-dt / this.catchupTau);
|
|
48
|
+
this.min += (obs - this.min) * alpha;
|
|
49
|
+
} else {
|
|
50
|
+
const alpha = 1 - Math.exp(-dt / this.tau);
|
|
51
|
+
this.min += (obs - this.min) * alpha;
|
|
52
|
+
}
|
|
53
|
+
// Update max
|
|
54
|
+
if (obs > this.max) {
|
|
55
|
+
const alpha = 1 - Math.exp(-dt / this.catchupTau);
|
|
56
|
+
this.max += (obs - this.max) * alpha;
|
|
57
|
+
} else {
|
|
58
|
+
const alpha = 1 - Math.exp(-dt / this.tau);
|
|
59
|
+
this.max += (obs - this.max) * alpha;
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
// Angular data
|
|
63
|
+
obs=this.normalizeAngle(obs);
|
|
64
|
+
let minDiff = this.angleDiff(obs, this.min);
|
|
65
|
+
let maxDiff = this.angleDiff(obs, this.max);
|
|
66
|
+
|
|
67
|
+
// Update min
|
|
68
|
+
if (minDiff < 0) {
|
|
69
|
+
const alpha = 1 - Math.exp(-dt / this.catchupTau);
|
|
70
|
+
this.min = this.normalizeAngle(this.min + minDiff * alpha);
|
|
71
|
+
} else {
|
|
72
|
+
const alpha = 1 - Math.exp(-dt / this.tau);
|
|
73
|
+
this.min = this.normalizeAngle(this.min + minDiff * alpha);
|
|
74
|
+
}
|
|
75
|
+
// Update max
|
|
76
|
+
if (maxDiff > 0) {
|
|
77
|
+
const alpha = 1 - Math.exp(-dt / this.catchupTau);
|
|
78
|
+
this.max = this.normalizeAngle(this.max + maxDiff * alpha);
|
|
79
|
+
} else {
|
|
80
|
+
const alpha = 1 - Math.exp(-dt / this.tau);
|
|
81
|
+
this.max = this.normalizeAngle(this.max + maxDiff * alpha);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// For angles, returns the minimal arc between min and max
|
|
88
|
+
get range() {
|
|
89
|
+
if (!this.isAngle) {
|
|
90
|
+
return this.max - this.min;
|
|
91
|
+
} else {
|
|
92
|
+
let diff = this.angleDiff(this.max, this.min);
|
|
93
|
+
return Math.abs(diff);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { LeakyExtremes };
|
package/package.json
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "speedandcurrent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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": {
|
|
7
7
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
8
8
|
},
|
|
9
9
|
"author": "aswin.bouwmeester@gmail.com",
|
|
10
|
-
"license": "
|
|
10
|
+
"license": "MIT",
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
13
|
-
"url": "git+https://github.com/Asw1n/speedandcurrent.git"
|
|
14
|
-
"directory": "./CHANGELOG.md"
|
|
13
|
+
"url": "git+https://github.com/Asw1n/speedandcurrent.git"
|
|
15
14
|
},
|
|
16
15
|
"bugs": {
|
|
17
16
|
"url": "https://github.com/Asw1n/speedandcurrent/issues"
|
|
@@ -24,11 +23,17 @@
|
|
|
24
23
|
],
|
|
25
24
|
"signalk-plugin-enabled-by-default": false,
|
|
26
25
|
"signalk": {
|
|
27
|
-
"appIcon": "
|
|
26
|
+
"appIcon": "icon.png",
|
|
28
27
|
"displayName": "Speed and current"
|
|
29
28
|
},
|
|
30
29
|
"dependencies": {
|
|
31
30
|
"kalman-filter": "^2.3.0",
|
|
32
|
-
"signalkutilities": "^1.
|
|
33
|
-
}
|
|
31
|
+
"signalkutilities": "^1.5.1"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"index.js",
|
|
35
|
+
"correctionTable.js",
|
|
36
|
+
"leakyExtremes.js",
|
|
37
|
+
"public/"
|
|
38
|
+
]
|
|
34
39
|
}
|
package/public/TableRenderer.js
CHANGED
|
@@ -3,8 +3,8 @@ class TableRenderer {
|
|
|
3
3
|
this.formatColumnHeader = this.defaultDisplay;
|
|
4
4
|
this.formatRowHeader = this.defaultDisplay;
|
|
5
5
|
this.formatCell = this.defaultDisplay;
|
|
6
|
-
this.
|
|
7
|
-
this.
|
|
6
|
+
this.colorMode = 'none';
|
|
7
|
+
this.cachedExtents = {}; // store min/max per mode
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
setColumnHeaderFormat(columnHeader) {
|
|
@@ -19,18 +19,15 @@ class TableRenderer {
|
|
|
19
19
|
cell != null ? this.formatCell = cell : this.formatCell = this.defaultDisplay;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
cellX != null ? this.formatCellX = cellX : this.formatCellX = this.defaultDisplay;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
setCellFormatY(cellY) {
|
|
27
|
-
cellY != null ? this.formatCellY = cellY : this.formatCellY = this.defaultDisplay;
|
|
28
|
-
}
|
|
22
|
+
// Removed legacy X/Y specific formatting.
|
|
29
23
|
|
|
30
24
|
render(data) {
|
|
31
25
|
const {id, row, col, table, displayAttributes } = data;
|
|
32
26
|
this.data = data;
|
|
33
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);
|
|
34
31
|
|
|
35
32
|
// Create a table element
|
|
36
33
|
const tableElement = document.createElement('table');
|
|
@@ -90,7 +87,12 @@ class TableRenderer {
|
|
|
90
87
|
else {
|
|
91
88
|
cell.classList.add('emptyCell');
|
|
92
89
|
}
|
|
93
|
-
if (value.displayAttributes && value.displayAttributes.selected)
|
|
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);
|
|
94
96
|
|
|
95
97
|
rowElement.appendChild(cell);
|
|
96
98
|
cIndex++;
|
|
@@ -102,86 +104,136 @@ class TableRenderer {
|
|
|
102
104
|
return value.toFixed(2);
|
|
103
105
|
}
|
|
104
106
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
+
setColorMode(mode) {
|
|
108
|
+
this.colorMode = mode || 'none';
|
|
109
|
+
}
|
|
107
110
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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};
|
|
112
117
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
+
}
|
|
117
141
|
}
|
|
118
|
-
|
|
142
|
+
this.cachedExtents = accum;
|
|
143
|
+
}
|
|
119
144
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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;
|
|
212
|
+
} else {
|
|
213
|
+
norm = (v - extent.min) / (extent.max - extent.min || 1); // 0..1
|
|
174
214
|
}
|
|
175
|
-
|
|
215
|
+
color = this.blueScale(Math.pow(norm,0.5));
|
|
216
|
+
}
|
|
217
|
+
cell.style.backgroundColor = color;
|
|
218
|
+
}
|
|
176
219
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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})`;
|
|
228
|
+
}
|
|
181
229
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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})`;
|
|
185
237
|
}
|
|
186
238
|
}
|
|
187
239
|
|