speedandcurrent 2.3.4 → 2.5.0
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/CHANGELOG.md +204 -0
- package/README.md +83 -46
- package/correctionTable.js +296 -66
- package/docs/correction-table-learning.md +33 -0
- package/docs/correction-table-v1.schema.json +219 -0
- package/docs/correction-table-v2.schema.json +134 -0
- package/docs/correction-table.schema.json +219 -0
- package/index.js +295 -147
- package/package.json +4 -3
- package/public/TableRenderer.js +89 -14
- package/public/app.js +126 -78
- package/public/index.html +14 -6
- package/public/main.css +19 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "speedandcurrent",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
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": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"homepage": "https://github.com/Asw1n/speedandcurrent#readme",
|
|
19
19
|
"engines": {
|
|
20
20
|
"node": ">=22.0.0",
|
|
21
|
-
"signalk-server": ">=2.
|
|
21
|
+
"signalk-server": ">=2.35.0"
|
|
22
22
|
},
|
|
23
23
|
"keywords": [
|
|
24
24
|
"signalk-webapp",
|
|
@@ -41,9 +41,10 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"kalman-filter": "^2.3.0",
|
|
44
|
-
"signalkutilities": "^3.
|
|
44
|
+
"signalkutilities": "^3.2.0"
|
|
45
45
|
},
|
|
46
46
|
"files": [
|
|
47
|
+
"CHANGELOG.md",
|
|
47
48
|
"icon.png",
|
|
48
49
|
"index.js",
|
|
49
50
|
"correctionTable.js",
|
package/public/TableRenderer.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
// TableRenderer — purpose-built renderer for the correction table.
|
|
2
2
|
// Rows = speed bins (knots), columns = heel bins (degrees).
|
|
3
3
|
// Each learned cell shows factor deviation (±%) and leeway (°).
|
|
4
|
-
// Background encodes factor
|
|
5
|
-
// Active cell (last updated) gets a
|
|
4
|
+
// Background encodes factor and leeway; a bottom bar encodes fusion weight.
|
|
5
|
+
// Active cell (last updated) gets a thin black border.
|
|
6
6
|
|
|
7
7
|
const RAD_TO_DEG = 180 / Math.PI;
|
|
8
8
|
const MPS_TO_KNOTS = 1.943844;
|
|
9
9
|
|
|
10
10
|
const DEFAULT_SPEED_SYMBOL = 'kn';
|
|
11
11
|
const DEFAULT_HEEL_SYMBOL = '°';
|
|
12
|
+
|
|
13
|
+
const MARKER_CORRECTED_COLOR = '#0d6efd';
|
|
14
|
+
const MARKER_RAW_COLOR = '#dc3545';
|
|
15
|
+
const MARKER_DOT_RADIUS = 8;
|
|
12
16
|
function fmtSpeed(mps) { return (mps * MPS_TO_KNOTS).toFixed(1); }
|
|
13
17
|
function fmtHeel(rad) { return (rad * RAD_TO_DEG).toFixed(0); }
|
|
14
18
|
function fmtFactor(f) { const p = (f - 1) * 100; return (p >= 0 ? '+' : '') + p.toFixed(1) + '%'; }
|
|
@@ -38,7 +42,75 @@ class TableRenderer {
|
|
|
38
42
|
el.appendChild(this._dataRow(r, rIndex, col, table, maxDev, fmtSpeedFn));
|
|
39
43
|
rIndex++;
|
|
40
44
|
}
|
|
41
|
-
|
|
45
|
+
|
|
46
|
+
const wrap = document.createElement('div');
|
|
47
|
+
wrap.classList.add('Table2D-wrap');
|
|
48
|
+
wrap.appendChild(el);
|
|
49
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
50
|
+
svg.classList.add('Table2D-markers');
|
|
51
|
+
wrap.appendChild(svg);
|
|
52
|
+
return wrap;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Overlay a dot for the uncorrected (raw) speed and a dot for the corrected
|
|
56
|
+
// speed, both positioned at the raw heel column.
|
|
57
|
+
// wrap: the element returned by render(). row/col: the table's axis config
|
|
58
|
+
// ({min,max,step}). marker: { rawSpeed, heel, correctedSpeed } in raw (SI)
|
|
59
|
+
// units, or null/undefined to clear the overlay.
|
|
60
|
+
renderMarkers(wrap, row, col, marker) {
|
|
61
|
+
const svg = wrap.querySelector('.Table2D-markers');
|
|
62
|
+
const table = wrap.querySelector('.Table2D');
|
|
63
|
+
if (!svg || !table) return;
|
|
64
|
+
svg.innerHTML = '';
|
|
65
|
+
if (!marker) return;
|
|
66
|
+
const { rawSpeed, heel, correctedSpeed } = marker;
|
|
67
|
+
if (![rawSpeed, heel, correctedSpeed].every(Number.isFinite)) return;
|
|
68
|
+
|
|
69
|
+
const wrapRect = wrap.getBoundingClientRect();
|
|
70
|
+
svg.setAttribute('width', wrapRect.width);
|
|
71
|
+
svg.setAttribute('height', wrapRect.height);
|
|
72
|
+
|
|
73
|
+
const rowHeaders = [...table.querySelectorAll('th.TableRowHeader:not(.TableCorner)')];
|
|
74
|
+
const colHeaders = [...table.querySelectorAll('th.TablecolumnHeader')];
|
|
75
|
+
if (!rowHeaders.length || !colHeaders.length) return;
|
|
76
|
+
|
|
77
|
+
const centerY = rowHeaders.map(th => {
|
|
78
|
+
const r = th.getBoundingClientRect();
|
|
79
|
+
return (r.top + r.bottom) / 2 - wrapRect.top;
|
|
80
|
+
});
|
|
81
|
+
const centerX = colHeaders.map(th => {
|
|
82
|
+
const r = th.getBoundingClientRect();
|
|
83
|
+
return (r.left + r.right) / 2 - wrapRect.left;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const x = this._lerpAxis(centerX, (heel - col.min) / col.step);
|
|
87
|
+
const yRaw = this._lerpAxis(centerY, (rawSpeed - row.min) / row.step);
|
|
88
|
+
const yCorr = this._lerpAxis(centerY, (correctedSpeed - row.min) / row.step);
|
|
89
|
+
|
|
90
|
+
svg.appendChild(this._markerDot(x, yRaw, MARKER_RAW_COLOR));
|
|
91
|
+
svg.appendChild(this._markerDot(x, yCorr, MARKER_CORRECTED_COLOR));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Interpolate a continuous axis index (may be fractional, out of range) against
|
|
95
|
+
// an array of known pixel centers for each axis bin.
|
|
96
|
+
_lerpAxis(centers, frac) {
|
|
97
|
+
const n = centers.length;
|
|
98
|
+
const f = Math.max(0, Math.min(n - 1, frac));
|
|
99
|
+
const i0 = Math.floor(f);
|
|
100
|
+
const i1 = Math.min(i0 + 1, n - 1);
|
|
101
|
+
const t = f - i0;
|
|
102
|
+
return centers[i0] + (centers[i1] - centers[i0]) * t;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_markerDot(x, y, color) {
|
|
106
|
+
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
107
|
+
circle.setAttribute('cx', x);
|
|
108
|
+
circle.setAttribute('cy', y);
|
|
109
|
+
circle.setAttribute('r', MARKER_DOT_RADIUS);
|
|
110
|
+
circle.setAttribute('fill', color);
|
|
111
|
+
circle.setAttribute('stroke', '#fff');
|
|
112
|
+
circle.setAttribute('stroke-width', 1);
|
|
113
|
+
return circle;
|
|
42
114
|
}
|
|
43
115
|
|
|
44
116
|
_headerRow(col, cornerText, fmtHeelFn) {
|
|
@@ -89,7 +161,7 @@ class TableRenderer {
|
|
|
89
161
|
td.appendChild(d);
|
|
90
162
|
const color = this._factorColor(factor, maxDev);
|
|
91
163
|
if (leeway !== null) {
|
|
92
|
-
const angleDeg = 90 + leeway * RAD_TO_DEG
|
|
164
|
+
const angleDeg = 90 + leeway * RAD_TO_DEG;
|
|
93
165
|
td.style.background = `repeating-linear-gradient(
|
|
94
166
|
${angleDeg}deg,
|
|
95
167
|
${color} 0px, ${color} 9px,
|
|
@@ -107,13 +179,19 @@ class TableRenderer {
|
|
|
107
179
|
}
|
|
108
180
|
|
|
109
181
|
const attrs = cell.displayAttributes;
|
|
110
|
-
if (attrs?.
|
|
111
|
-
|
|
182
|
+
if (Number.isFinite(attrs?.normWeight) && attrs.normWeight > 0) {
|
|
183
|
+
const d = document.createElement('div');
|
|
184
|
+
d.className = 'cell-weight';
|
|
185
|
+
const weight = Math.max(0, Math.min(1, attrs.normWeight));
|
|
186
|
+
d.style.width = `${weight * 100}%`;
|
|
187
|
+
d.title = `Fusion weight ${(weight * 100).toFixed(1)}%`;
|
|
188
|
+
td.appendChild(d);
|
|
189
|
+
}
|
|
190
|
+
if (attrs?.selected) td.classList.add('cell--active');
|
|
112
191
|
|
|
113
192
|
return td;
|
|
114
193
|
}
|
|
115
194
|
|
|
116
|
-
// Find the largest absolute factor deviation from 1 to normalise the color scale.
|
|
117
195
|
_computeMaxDev(table) {
|
|
118
196
|
let maxDev = 0;
|
|
119
197
|
for (const row of table) {
|
|
@@ -123,21 +201,18 @@ class TableRenderer {
|
|
|
123
201
|
if (dev > maxDev) maxDev = dev;
|
|
124
202
|
}
|
|
125
203
|
}
|
|
126
|
-
return maxDev || 0.05;
|
|
204
|
+
return maxDev || 0.05;
|
|
127
205
|
}
|
|
128
206
|
|
|
129
|
-
// factor < 1: paddlewheel reads fast → white→orange
|
|
130
|
-
// factor > 1: paddlewheel reads slow → white→green
|
|
131
207
|
_factorColor(factor, maxDev) {
|
|
132
208
|
if (!Number.isFinite(factor)) return '';
|
|
133
209
|
const dev = factor - 1;
|
|
134
210
|
if (Math.abs(dev) < 1e-6) return '';
|
|
135
|
-
const
|
|
211
|
+
const intensity = Math.min(1, Math.abs(dev) / maxDev);
|
|
136
212
|
if (dev < 0) {
|
|
137
|
-
return `rgb(255,${Math.round(255 - 90 *
|
|
138
|
-
} else {
|
|
139
|
-
return `rgb(${Math.round(255 * (1 - a))},${Math.round(255 - 95 * a)},${Math.round(255 - 175 * a)})`; // white→green
|
|
213
|
+
return `rgb(255,${Math.round(255 - 90 * intensity)},${Math.round(255 * (1 - intensity))})`;
|
|
140
214
|
}
|
|
215
|
+
return `rgb(${Math.round(255 * (1 - intensity))},${Math.round(255 - 95 * intensity)},${Math.round(255 - 175 * intensity)})`;
|
|
141
216
|
}
|
|
142
217
|
}
|
|
143
218
|
|
package/public/app.js
CHANGED
|
@@ -235,7 +235,12 @@ async function apiGet(path) {
|
|
|
235
235
|
const ct = res.headers.get('content-type') || '';
|
|
236
236
|
if (!ct.includes('application/json')) { showMessage('Unexpected server response.'); return null; }
|
|
237
237
|
showMessage('');
|
|
238
|
-
|
|
238
|
+
try {
|
|
239
|
+
return await res.json();
|
|
240
|
+
} catch (err) {
|
|
241
|
+
showMessage('Unexpected server response.');
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
239
244
|
}
|
|
240
245
|
|
|
241
246
|
// PUT /api/settings: server returns full merged config — store it and re-render.
|
|
@@ -297,46 +302,21 @@ function _refreshMessage() {
|
|
|
297
302
|
const paramMeta = {
|
|
298
303
|
estimateBoatSpeed: { label: 'Estimate boat speed', type: 'boolean' },
|
|
299
304
|
updateCorrectionTable: { label: 'Update correction table', type: 'boolean' },
|
|
300
|
-
suspendLearningOnNavigationState: { label: 'Suspend on navigation.state
|
|
301
|
-
assumeCurrent: { label: 'Assume current during update', type: 'boolean', description: 'Experimental, works best when currents are relatively stable.' },
|
|
305
|
+
suspendLearningOnNavigationState: { label: 'Suspend on navigation.state', type: 'boolean', description: 'When enabled, navigation.state can override the vessel\u2019s own SOG-based moving/not-moving state: anchored, moored, or motoring are treated as not moving (learning suspended, leeway zeroed). It can never force "moving" when SOG says otherwise.' },
|
|
302
306
|
sogFallback: { label: 'Groundspeed fallback', type: 'boolean', description: 'Output Groundspeed as Boatspeed when the paddlewheel sensor is malfunctioning or stalled.' },
|
|
303
|
-
|
|
307
|
+
correctionDriftRate: { label: 'Correction drift', type: 'number', unit: 'knots/month', min: 0, max: 3, step: 0.1, default: 0.3, description: 'How quickly the correction may change over time (knots/month). 0.3 knots/month means the correction can typically change by about 0.3 knots over a month.' },
|
|
304
308
|
showStatistics: { label: 'Show statistics (σ)', type: 'boolean', description: 'Display standard deviation alongside smoothed values for debugging.' },
|
|
305
|
-
smootherClass: {
|
|
306
|
-
label: 'Smoother type', type: 'select',
|
|
307
|
-
description: 'Smoothing applied to all sensor inputs for learning only.',
|
|
308
|
-
options: [
|
|
309
|
-
{ value: 'MovingAverageSmoother', label: 'Moving average (window)' },
|
|
310
|
-
{ value: 'ExponentialSmoother', label: 'Exponential (τ)' },
|
|
311
|
-
{ value: 'KalmanSmoother', label: 'Kalman filter' },
|
|
312
|
-
]
|
|
313
|
-
},
|
|
314
|
-
smootherTau: {
|
|
315
|
-
label: 'Time constant (τ)', type: 'number', unit: 's',
|
|
316
|
-
min: 1, max: 60, step: 0.5, default: 3,
|
|
317
|
-
description: 'Time constant in seconds.'
|
|
318
|
-
},
|
|
319
309
|
smootherTimeSpan: {
|
|
320
310
|
label: 'Window size', type: 'number', unit: 's',
|
|
321
|
-
min:
|
|
322
|
-
description: 'Moving-average window in seconds.'
|
|
323
|
-
},
|
|
324
|
-
smootherSteadyState: {
|
|
325
|
-
label: 'Kalman gain', type: 'number',
|
|
326
|
-
min: 0.01, max: 0.99, step: 0.01, default: 0.2,
|
|
327
|
-
description: 'Steady-state Kalman gain (0 ≈ slow/smooth, 1 ≈ fast/raw). Clamped to 0.01–0.99.'
|
|
311
|
+
min: 5, max: 60, step: 0.5, default: 5,
|
|
312
|
+
description: 'Moving-average window in seconds. Learning runs once per window plus a one-second gap.'
|
|
328
313
|
},
|
|
329
314
|
};
|
|
330
315
|
|
|
331
316
|
// Settings groups for each UI section
|
|
332
317
|
const ESTIMATION_SETTING_KEYS = ['sogFallback'];
|
|
333
|
-
const LEARNING_SETTING_KEYS = ['
|
|
334
|
-
const SMOOTHER_SETTING_KEYS = [
|
|
335
|
-
'smootherClass',
|
|
336
|
-
{ key: 'smootherTau', showIf: cfg => cfg.smootherClass === 'ExponentialSmoother' },
|
|
337
|
-
{ key: 'smootherTimeSpan', showIf: cfg => (cfg.smootherClass || 'MovingAverageSmoother') === 'MovingAverageSmoother' },
|
|
338
|
-
{ key: 'smootherSteadyState', showIf: cfg => cfg.smootherClass === 'KalmanSmoother' },
|
|
339
|
-
];
|
|
318
|
+
const LEARNING_SETTING_KEYS = ['correctionDriftRate', 'suspendLearningOnNavigationState', 'showStatistics'];
|
|
319
|
+
const SMOOTHER_SETTING_KEYS = ['smootherTimeSpan'];
|
|
340
320
|
|
|
341
321
|
// Build one settings control for a key.
|
|
342
322
|
function createSettingControl(key, meta, value) {
|
|
@@ -500,6 +480,15 @@ function itemLabel(item) {
|
|
|
500
480
|
return metaById[item.id]?.displayName ?? item.path ?? item.id;
|
|
501
481
|
}
|
|
502
482
|
|
|
483
|
+
function formatNavigationStateValue(navState) {
|
|
484
|
+
if (!navState) return '—';
|
|
485
|
+
if (navState.pathKnown === false) return 'not available';
|
|
486
|
+
// Use `value` rather than `ready` — navigation.state changes infrequently, so it goes
|
|
487
|
+
// stale (ready=false) between changes even though the last known value is still valid.
|
|
488
|
+
if (navState.value == null) return 'not ready';
|
|
489
|
+
return navState.isStale ? `${navState.value} (stale)` : navState.value;
|
|
490
|
+
}
|
|
491
|
+
|
|
503
492
|
function buildDataTable(rows) {
|
|
504
493
|
const tbl = document.createElement('table');
|
|
505
494
|
tbl.className = 'table table-sm table-borderless mb-0';
|
|
@@ -528,18 +517,46 @@ function filterById(arr, ids) {
|
|
|
528
517
|
});
|
|
529
518
|
}
|
|
530
519
|
|
|
531
|
-
function renderGroupInto(elId, polars, deltas, attitudes) {
|
|
520
|
+
function renderGroupInto(elId, polars, deltas, attitudes, extraRows = []) {
|
|
532
521
|
const el = document.getElementById(elId);
|
|
533
522
|
if (!el) return;
|
|
534
523
|
el.innerHTML = '';
|
|
535
524
|
const rows = [
|
|
536
525
|
...polars .map(p => ({ label: itemLabel(p), value: formatPolarValue(p) })),
|
|
537
526
|
...deltas .map(d => ({ label: itemLabel(d), value: formatDeltaValue(d) })),
|
|
538
|
-
...attitudes.map(a => ({ label: itemLabel(a), value: formatAttitudeValue(a) }))
|
|
527
|
+
...attitudes.map(a => ({ label: itemLabel(a), value: formatAttitudeValue(a) })),
|
|
528
|
+
...extraRows
|
|
539
529
|
];
|
|
540
530
|
if (rows.length) el.appendChild(buildDataTable(rows));
|
|
541
531
|
}
|
|
542
532
|
|
|
533
|
+
function renderItemsInto(elId, items) {
|
|
534
|
+
const el = document.getElementById(elId);
|
|
535
|
+
if (!el) return;
|
|
536
|
+
el.innerHTML = '';
|
|
537
|
+
const rows = items.map(item => ({
|
|
538
|
+
label: itemLabel(item),
|
|
539
|
+
value: state.polarsById[item.id] === item
|
|
540
|
+
? formatPolarValue(item)
|
|
541
|
+
: state.deltasById[item.id] === item
|
|
542
|
+
? formatDeltaValue(item)
|
|
543
|
+
: formatAttitudeValue(item)
|
|
544
|
+
}));
|
|
545
|
+
if (rows.length) el.appendChild(buildDataTable(rows));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function formatSpatialVariance(table) {
|
|
549
|
+
if (!Number.isFinite(table?.q)) return null;
|
|
550
|
+
const pairCount = Number.isInteger(table.qPairCount) ? table.qPairCount : 0;
|
|
551
|
+
const rawEstimate = Number.isFinite(table.qEstimate)
|
|
552
|
+
? `; raw estimate ${table.qEstimate.toExponential(3)} from ${pairCount} adjacent pairs`
|
|
553
|
+
: `; ${pairCount} eligible adjacent pairs`;
|
|
554
|
+
const source = table.qSource === 'derived'
|
|
555
|
+
? `derived from ${pairCount} adjacent pairs`
|
|
556
|
+
: `fallback${rawEstimate}`;
|
|
557
|
+
return `${table.q.toExponential(3)} (${source})`;
|
|
558
|
+
}
|
|
559
|
+
|
|
543
560
|
function renderLiveSections() {
|
|
544
561
|
const learningState = state.learningState || null;
|
|
545
562
|
const inputPolars = filterById(state.polarsAll, ['groundSpeed']);
|
|
@@ -551,38 +568,43 @@ function renderLiveSections() {
|
|
|
551
568
|
const displayedInputs = [...fallbackInputPolars, ...fallbackInputDeltas, ...fallbackInputAttitudes];
|
|
552
569
|
|
|
553
570
|
// Inputs section — raw sensor readings only (smoothing is internal to the plugin)
|
|
571
|
+
const navigationStateRows = [{ label: 'navigation.state', value: formatNavigationStateValue(learningState?.navigationState) }];
|
|
554
572
|
renderGroupInto('inputs-values',
|
|
555
573
|
fallbackInputPolars,
|
|
556
574
|
fallbackInputDeltas,
|
|
557
|
-
fallbackInputAttitudes
|
|
575
|
+
fallbackInputAttitudes,
|
|
576
|
+
navigationStateRows
|
|
558
577
|
);
|
|
559
578
|
renderWarnings('inputs-warnings', displayedInputs);
|
|
560
579
|
|
|
561
580
|
// Estimation — inputs (raw sensor data used for boat speed estimation)
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
581
|
+
const groundSpeedInputs = filterById(state.polarsAll, ['groundSpeed']);
|
|
582
|
+
const boatSpeedInputs = filterById(state.deltasAll, ['boatSpeed']);
|
|
583
|
+
const estimationInputs = config?.sogFallback
|
|
584
|
+
? [...groundSpeedInputs, ...boatSpeedInputs]
|
|
585
|
+
: [...boatSpeedInputs, ...groundSpeedInputs];
|
|
586
|
+
estimationInputs.push(
|
|
587
|
+
...filterById(state.deltasAll, ['heading.angle']),
|
|
565
588
|
...filterById(state.attitudesAll, ['attitude'])
|
|
566
|
-
];
|
|
567
|
-
renderGroupInto('estimation-inputs',
|
|
568
|
-
estimationInputs.filter(item => state.polarsById[item.id] === item),
|
|
569
|
-
estimationInputs.filter(item => state.deltasById[item.id] === item),
|
|
570
|
-
estimationInputs.filter(item => state.attitudesById[item.id] === item)
|
|
571
589
|
);
|
|
590
|
+
renderItemsInto('estimation-inputs', estimationInputs);
|
|
572
591
|
// Estimation — intermediates
|
|
573
592
|
const estimationIntermediates = filterById(state.polarsAll, ['boatSpeedRefGround', 'speedCorrection', 'residual', 'residual.smoothed']);
|
|
574
|
-
|
|
593
|
+
const spatialVariance = config?.showStatistics
|
|
594
|
+
? formatSpatialVariance(Object.values(state.tablesById)[0])
|
|
595
|
+
: null;
|
|
596
|
+
const statisticRows = spatialVariance === null
|
|
597
|
+
? []
|
|
598
|
+
: [{ label: 'Spatial variance q', value: spatialVariance }];
|
|
599
|
+
renderGroupInto('estimation-intermediates', estimationIntermediates, [], [], statisticRows);
|
|
575
600
|
// Estimation — outputs
|
|
576
601
|
const estimationOutputs = filterById(state.polarsAll, ['correctedBoatSpeed', 'current.smoothed']);
|
|
577
602
|
renderGroupInto('estimation-outputs', estimationOutputs, [], []);
|
|
578
603
|
// Estimation — warnings
|
|
579
604
|
renderWarnings('estimation-warnings', [...estimationInputs, ...estimationIntermediates, ...estimationOutputs]);
|
|
580
605
|
|
|
581
|
-
// Learning — inputs
|
|
582
|
-
const
|
|
583
|
-
? filterById(state.polarsAll, ['current.smoothed'])
|
|
584
|
-
: [];
|
|
585
|
-
const learningPolars = [...filterById(state.polarsAll, ['groundSpeed.smoothed']), ...learningCurrentPolars];
|
|
606
|
+
// Learning — inputs
|
|
607
|
+
const learningPolars = filterById(state.polarsAll, ['groundSpeed.smoothed']);
|
|
586
608
|
const learningDeltas = filterById(state.deltasAll, ['heading.smoothed', 'boatSpeed.smoothed']);
|
|
587
609
|
const learningAttitudes = filterById(state.attitudesAll, ['attitude.smoothed']);
|
|
588
610
|
renderGroupInto('learning-inputs',
|
|
@@ -624,7 +646,7 @@ function renderLiveSections() {
|
|
|
624
646
|
startup: 'Startup stabilising window',
|
|
625
647
|
observation_reset: 'Recent rejected or invalid observation',
|
|
626
648
|
nav_state_change: 'navigation.state changed',
|
|
627
|
-
|
|
649
|
+
not_moving: 'Vessel not moving',
|
|
628
650
|
cog_override: 'Blocked by COG override',
|
|
629
651
|
accepted: 'Observation recorded',
|
|
630
652
|
estimator_outlier: 'Estimator rejected observation',
|
|
@@ -632,7 +654,7 @@ function renderLiveSections() {
|
|
|
632
654
|
missing_current_when_required: 'Current estimate unavailable',
|
|
633
655
|
learning_off: 'Learning is off',
|
|
634
656
|
stabilizing: 'Stabilising window active',
|
|
635
|
-
|
|
657
|
+
not_moving_blocked: 'Learning blocked \u2014 vessel not moving',
|
|
636
658
|
cog_override_active: 'Learning blocked by COG override',
|
|
637
659
|
stw_below_threshold: 'Below minimum STW for learning',
|
|
638
660
|
sog_below_threshold: 'Below minimum SOG for learning',
|
|
@@ -642,10 +664,11 @@ function renderLiveSections() {
|
|
|
642
664
|
const reason = reasonTextMap[learningState?.observationReason]
|
|
643
665
|
|| reasonTextMap[learningState?.reason]
|
|
644
666
|
|| '\u2014';
|
|
667
|
+
const showReason = learningState?.observationState && learningState.observationState !== 'accepted';
|
|
645
668
|
statusTbody.innerHTML =
|
|
646
669
|
`<tr><td class="text-muted small">Learning</td><td class="small">${learningText}</td></tr>` +
|
|
647
670
|
`<tr><td class="text-muted small">Observation</td><td class="small">${obsText}</td></tr>` +
|
|
648
|
-
`<tr><td class="text-muted small">Reason</td><td class="small">${reason}</td></tr
|
|
671
|
+
(showReason ? `<tr><td class="text-muted small">Reason</td><td class="small">${reason}</td></tr>` : '');
|
|
649
672
|
}
|
|
650
673
|
// Correction table
|
|
651
674
|
const tableEl = document.getElementById('table-container');
|
|
@@ -659,45 +682,70 @@ function renderLiveSections() {
|
|
|
659
682
|
speedSymbol: speedC.symbol,
|
|
660
683
|
heelSymbol: angleC.symbol,
|
|
661
684
|
};
|
|
662
|
-
Object.values(state.tablesById)
|
|
685
|
+
const tables = Object.values(state.tablesById);
|
|
686
|
+
const rawSpeed = state.deltasById['boatSpeed']?.value;
|
|
687
|
+
const heel = state.attitudesById['attitude']?.value?.roll;
|
|
688
|
+
const correctedSpeed = state.polarsById['correctedBoatSpeed']?.magnitude;
|
|
689
|
+
const marker = [rawSpeed, heel, correctedSpeed].every(Number.isFinite)
|
|
690
|
+
? { rawSpeed, heel, correctedSpeed }
|
|
691
|
+
: null;
|
|
692
|
+
tables.forEach(t => {
|
|
693
|
+
const wrap = tableRenderer.render(t, tableOpts);
|
|
694
|
+
tableEl.appendChild(wrap);
|
|
695
|
+
tableRenderer.renderMarkers(wrap, t.row, t.col, marker);
|
|
696
|
+
});
|
|
663
697
|
}
|
|
664
698
|
}
|
|
665
699
|
|
|
666
700
|
// ─── Polling ──────────────────────────────────────────────────────────────────
|
|
701
|
+
const POLL_INTERVAL_MS = 1000;
|
|
702
|
+
const MIN_POLL_INTERVAL_MS = 100;
|
|
703
|
+
const MAX_POLL_INTERVAL_MS = 5000;
|
|
704
|
+
let pollIntervalMs = POLL_INTERVAL_MS;
|
|
667
705
|
let updateTimer = null;
|
|
668
706
|
let lastTickOk = false;
|
|
669
707
|
|
|
670
708
|
async function tick() {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
709
|
+
try {
|
|
710
|
+
const data = await apiGet('/api/report');
|
|
711
|
+
if (data) {
|
|
712
|
+
// Recovered from an error — reload meta and config so unit converters,
|
|
713
|
+
// source lists and settings reflect the (possibly restarted) plugin state.
|
|
714
|
+
if (!lastTickOk) {
|
|
715
|
+
await loadMeta();
|
|
716
|
+
config = await apiGet('/api/settings');
|
|
717
|
+
if (config && config.tableName) setTableName(config.tableName);
|
|
718
|
+
renderSettingsPanel();
|
|
719
|
+
}
|
|
720
|
+
lastTickOk = true;
|
|
721
|
+
pollIntervalMs = Number.isFinite(data.pollIntervalMs) && data.pollIntervalMs > 0
|
|
722
|
+
? Math.min(Math.max(data.pollIntervalMs, MIN_POLL_INTERVAL_MS), MAX_POLL_INTERVAL_MS)
|
|
723
|
+
: POLL_INTERVAL_MS;
|
|
724
|
+
normaliseState(data);
|
|
725
|
+
state.learningState = data.learningState || null;
|
|
726
|
+
renderLiveSections();
|
|
727
|
+
} else {
|
|
728
|
+
lastTickOk = false;
|
|
729
|
+
state.learningState = null;
|
|
680
730
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
731
|
+
} finally {
|
|
732
|
+
// Always poll status separately so the message reflects plugin state
|
|
733
|
+
// even when /api/report fails (plugin stopped/restarting).
|
|
734
|
+
const statusData = await fetch(`${API_BASE}/api/status`, { credentials: 'same-origin' })
|
|
735
|
+
.then(r => r.ok ? r.json() : null)
|
|
736
|
+
.catch(() => null);
|
|
737
|
+
_pluginStatus = statusData?.status ?? '';
|
|
738
|
+
_refreshMessage();
|
|
688
739
|
}
|
|
689
|
-
// Always poll status separately so the message reflects plugin state
|
|
690
|
-
// even when /api/report fails (plugin stopped/restarting).
|
|
691
|
-
const statusData = await fetch(`${API_BASE}/api/status`, { credentials: 'same-origin' })
|
|
692
|
-
.then(r => r.ok ? r.json() : null)
|
|
693
|
-
.catch(() => null);
|
|
694
|
-
_pluginStatus = statusData?.status ?? '';
|
|
695
|
-
_refreshMessage();
|
|
696
740
|
}
|
|
697
741
|
|
|
698
742
|
function startUpdates() {
|
|
699
|
-
if (updateTimer)
|
|
700
|
-
|
|
743
|
+
if (updateTimer) clearTimeout(updateTimer);
|
|
744
|
+
const poll = async () => {
|
|
745
|
+
await tick();
|
|
746
|
+
updateTimer = setTimeout(poll, pollIntervalMs);
|
|
747
|
+
};
|
|
748
|
+
poll();
|
|
701
749
|
}
|
|
702
750
|
|
|
703
751
|
|
package/public/index.html
CHANGED
|
@@ -152,7 +152,7 @@
|
|
|
152
152
|
<table class="table table-sm table-borderless mb-0" id="learning-settings-table">
|
|
153
153
|
<tbody></tbody>
|
|
154
154
|
</table>
|
|
155
|
-
<h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small">
|
|
155
|
+
<h6 class="text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small">Learning window</h6>
|
|
156
156
|
<table class="table table-sm table-borderless mb-0" id="smoother-settings-table">
|
|
157
157
|
<tbody></tbody>
|
|
158
158
|
</table>
|
|
@@ -193,15 +193,23 @@
|
|
|
193
193
|
</div>
|
|
194
194
|
<div class="table-legend-row">
|
|
195
195
|
<span class="legend-swatch legend-swatch--stripe"></span>
|
|
196
|
-
<span><strong>Stripe angle</strong> — encodes leeway direction
|
|
196
|
+
<span><strong>Stripe angle</strong> — encodes leeway direction</span>
|
|
197
197
|
</div>
|
|
198
198
|
<div class="table-legend-row">
|
|
199
|
-
<span class="legend-swatch legend-swatch--active"
|
|
200
|
-
<span><strong>
|
|
199
|
+
<span class="legend-swatch legend-swatch--active"></span>
|
|
200
|
+
<span><strong>Black border</strong> — most recently updated cell</span>
|
|
201
201
|
</div>
|
|
202
202
|
<div class="table-legend-row">
|
|
203
|
-
<span class="legend-swatch legend-swatch--
|
|
204
|
-
<span><strong>
|
|
203
|
+
<span class="legend-swatch legend-swatch--weight"></span>
|
|
204
|
+
<span><strong>Blue bar</strong> — normalized fusion weight; full cell width is 100%</span>
|
|
205
|
+
</div>
|
|
206
|
+
<div class="table-legend-row">
|
|
207
|
+
<span class="legend-swatch legend-swatch--dot-red"></span>
|
|
208
|
+
<span><strong>Red dot</strong> — current uncorrected (raw) speed and heel</span>
|
|
209
|
+
</div>
|
|
210
|
+
<div class="table-legend-row">
|
|
211
|
+
<span class="legend-swatch legend-swatch--dot-blue"></span>
|
|
212
|
+
<span><strong>Blue dot</strong> — current corrected speed and heel</span>
|
|
205
213
|
</div>
|
|
206
214
|
<div class="table-legend-row mt-1">
|
|
207
215
|
<span class="legend-label-wide">Cell values:</span>
|
package/public/main.css
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
.TableCell {
|
|
35
35
|
width: 60px;
|
|
36
36
|
height: 60px;
|
|
37
|
+
position: relative;
|
|
37
38
|
text-align: center;
|
|
38
39
|
vertical-align: middle;
|
|
39
40
|
font-size: 11px;
|
|
@@ -42,11 +43,14 @@
|
|
|
42
43
|
.TableCorner { font-size: 10px; color: #888; font-weight: 400; text-align: center; }
|
|
43
44
|
/* cell states */
|
|
44
45
|
.cell--empty { background-color: #f0f0f0; }
|
|
45
|
-
.cell--neighbour { box-shadow: inset 0 0 0 1px #90a4ae; }
|
|
46
46
|
.cell-factor { font-weight: 600; font-size: 12px; }
|
|
47
47
|
.cell-leeway { font-weight: 600; font-size: 12px; margin-top: 2px; }
|
|
48
|
-
.cell
|
|
49
|
-
.cell--active
|
|
48
|
+
.cell-weight { position: absolute; left: 0; bottom: 0; height: 4px; background-color: #0d6efd; }
|
|
49
|
+
.cell--active { outline: 1px solid #000; outline-offset: -1px; }
|
|
50
|
+
|
|
51
|
+
/* ─── Correction table marker overlay (raw speed dot + corrected speed/leeway arrow) ─ */
|
|
52
|
+
.Table2D-wrap { position: relative; display: inline-block; }
|
|
53
|
+
.Table2D-markers { position: absolute; top: 0; left: 0; pointer-events: none; overflow: visible; }
|
|
50
54
|
|
|
51
55
|
/* ─── Stable table column widths inside cards ──────────────────────────────── */
|
|
52
56
|
.card-body .table {
|
|
@@ -118,6 +122,16 @@ tr.stale td { color: #aaa; font-style: italic; }
|
|
|
118
122
|
#f0f0f0 4px, #f0f0f0 5px
|
|
119
123
|
);
|
|
120
124
|
}
|
|
121
|
-
.legend-swatch--
|
|
122
|
-
.legend-swatch--
|
|
125
|
+
.legend-swatch--weight { position: relative; border: 0; }
|
|
126
|
+
.legend-swatch--weight::after { content: ''; position: absolute; left: 0; right: 25%; bottom: 2px; height: 4px; background-color: #0d6efd; }
|
|
127
|
+
.legend-swatch--active { background: transparent; border-color: #000; border-radius: 0; }
|
|
128
|
+
.legend-swatch--dot-red { background: transparent; border: 0; border-radius: 0; position: relative; }
|
|
129
|
+
.legend-swatch--dot-red::after,
|
|
130
|
+
.legend-swatch--dot-blue::after {
|
|
131
|
+
content: ''; position: absolute; top: 2px; left: 8px;
|
|
132
|
+
width: 12px; height: 12px; border-radius: 50%; border: 1px solid #fff;
|
|
133
|
+
}
|
|
134
|
+
.legend-swatch--dot-red::after { background-color: #dc3545; }
|
|
135
|
+
.legend-swatch--dot-blue { background: transparent; border: 0; border-radius: 0; position: relative; }
|
|
136
|
+
.legend-swatch--dot-blue::after { background-color: #0d6efd; }
|
|
123
137
|
.legend-label-wide { flex-shrink: 0; white-space: nowrap; font-weight: 600; }
|