speedandcurrent 1.6.0 → 1.7.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/correctionTable.js +1 -1
- package/index.js +392 -264
- package/package.json +2 -2
- package/public/TableRenderer.js +104 -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/public/app.js
CHANGED
|
@@ -1,447 +1,804 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
1
|
+
import TableRenderer from './TableRenderer.js';
|
|
2
|
+
|
|
3
|
+
const API_BASE = '/plugins/speedandcurrent';
|
|
4
|
+
|
|
5
|
+
// ─── Unit conversion (respects SK server unitPreferences via displayUnits in meta) ─
|
|
6
|
+
// Fallback defaults are nautical (knots / degrees) when the server does not
|
|
7
|
+
// supply displayUnits (old server or no unit-preference preset configured).
|
|
8
|
+
const DEFAULTS = {
|
|
9
|
+
speed: { convert: v => v * 1.943844, invert: v => v / 1.943844, symbol: 'kn', decimals: 1 },
|
|
10
|
+
angle: { convert: v => v * (180 / Math.PI), invert: v => v * (Math.PI / 180), symbol: '°', decimals: 0 },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Cache compiled converters so new Function() is called at most once per formula.
|
|
14
|
+
const _converterCache = new Map();
|
|
15
|
+
|
|
16
|
+
// Validate a formula string before passing it to Function().
|
|
17
|
+
// Allows only numbers, whitespace, basic math operators, parens, and 'value'.
|
|
18
|
+
function isSafeFormula(f) {
|
|
19
|
+
return typeof f === 'string' && /^[\d\s+\-*/.()eE]*$/.test(f.replace(/\bvalue\b/g, '0'));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Build a { convert, symbol, decimals } converter from a displayUnits object.
|
|
23
|
+
// Returns null when displayUnits is absent, unsafe, or uncompilable.
|
|
24
|
+
function buildConverter(displayUnits) {
|
|
25
|
+
if (!displayUnits || !isSafeFormula(displayUnits.formula)) return null;
|
|
26
|
+
const key = displayUnits.formula + '|' + (displayUnits.symbol || '') + '|' + (displayUnits.displayFormat || '');
|
|
27
|
+
if (_converterCache.has(key)) return _converterCache.get(key);
|
|
28
|
+
let fn;
|
|
29
|
+
try { fn = new Function('value', 'return ' + displayUnits.formula); fn(1); }
|
|
30
|
+
catch (e) { _converterCache.set(key, null); return null; }
|
|
31
|
+
let invertFn = null;
|
|
32
|
+
if (isSafeFormula(displayUnits.inverseFormula)) {
|
|
33
|
+
try { invertFn = new Function('value', 'return ' + displayUnits.inverseFormula); invertFn(1); }
|
|
34
|
+
catch (e) { /* leave null — will fall back to DEFAULTS.invert */ }
|
|
35
|
+
}
|
|
36
|
+
const parts = (displayUnits.displayFormat || '0.0').split('.');
|
|
37
|
+
const decimals = parts.length > 1 ? parts[1].length : 0;
|
|
38
|
+
const result = { convert: fn, invert: invertFn, symbol: displayUnits.symbol || displayUnits.targetUnit || '', decimals };
|
|
39
|
+
_converterCache.set(key, result);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Global converters extracted from meta after loadMeta(); used for attitudes and
|
|
44
|
+
// table axes that don't have per-item displayUnits.
|
|
45
|
+
// speed axis = speed through water; heel axis = attitude roll.
|
|
46
|
+
// These are sourced from the specific items that define the table dimensions.
|
|
47
|
+
const unitConverters = { speed: null, angle: null };
|
|
48
|
+
|
|
49
|
+
function updateUnitConverters() {
|
|
50
|
+
// Speed axis: source from the STW polar (smoothed preferred, raw fallback)
|
|
51
|
+
const speedMeta = metaById['boatSpeed.smoothed'] || metaById['boatSpeed'];
|
|
52
|
+
unitConverters.speed = buildConverter(speedMeta?.magnitude?.displayUnits) || null;
|
|
53
|
+
// Heel axis: source from the attitude handler (smoothed preferred, raw fallback)
|
|
54
|
+
const heelMeta = metaById['attitude.smoothed'] || metaById['attitude'];
|
|
55
|
+
unitConverters.angle = buildConverter(heelMeta?.displayUnits) || null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Update dialog label text to reflect the active unit symbols.
|
|
59
|
+
function applyUnitLabels() {
|
|
60
|
+
const speedSym = (unitConverters.speed || DEFAULTS.speed).symbol;
|
|
61
|
+
const angleSym = (unitConverters.angle || DEFAULTS.angle).symbol;
|
|
62
|
+
['create', 'resize'].forEach(prefix => {
|
|
63
|
+
const setLabel = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text; };
|
|
64
|
+
setLabel(`${prefix}-maxSpeed-label`, `Max speed (${speedSym})`);
|
|
65
|
+
setLabel(`${prefix}-speedStep-label`, `Speed step (${speedSym})`);
|
|
66
|
+
setLabel(`${prefix}-maxHeel-label`, `Max heel (${angleSym})`);
|
|
67
|
+
setLabel(`${prefix}-heelStep-label`, `Heel step (${angleSym})`);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── TableRenderer instance ───────────────────────────────────────────────────
|
|
72
|
+
const tableRenderer = new TableRenderer();
|
|
73
|
+
|
|
74
|
+
// ─── Live state (indexed + raw arrays) ───────────────────────────────────────
|
|
75
|
+
let state = {
|
|
76
|
+
polarsAll: [], deltasAll: [], attitudesAll: [],
|
|
77
|
+
polarsById: {}, deltasById: {}, attitudesById: {},
|
|
78
|
+
tablesById: {}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function normaliseState(data) {
|
|
82
|
+
// reporter.report() returns { polars: {[id]:item}, deltas: {[id]:item},
|
|
83
|
+
// tables: {[id]:item}, attitudes: {[id]:item} }
|
|
84
|
+
const polarsById = {};
|
|
85
|
+
const deltasById = {};
|
|
86
|
+
const attitudesById = {};
|
|
87
|
+
const tablesById = {};
|
|
88
|
+
for (const [id, v] of Object.entries(data.polars || {})) polarsById[id] = { ...v, id };
|
|
89
|
+
for (const [id, v] of Object.entries(data.deltas || {})) deltasById[id] = { ...v, id };
|
|
90
|
+
for (const [id, v] of Object.entries(data.attitudes || {})) attitudesById[id] = { ...v, id };
|
|
91
|
+
for (const [id, v] of Object.entries(data.tables || {})) tablesById[id] = { ...v, id };
|
|
92
|
+
const polarsAll = Object.values(polarsById);
|
|
93
|
+
const deltasAll = Object.values(deltasById);
|
|
94
|
+
const attitudesAll = Object.values(attitudesById);
|
|
95
|
+
state = { polarsAll, deltasAll, attitudesAll, polarsById, deltasById, attitudesById, tablesById };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Static meta (fetched once at startup from /api/meta) ─────────────────────
|
|
99
|
+
let metaById = {}; // keyed by item id
|
|
100
|
+
|
|
101
|
+
async function loadMeta() {
|
|
102
|
+
const data = await apiGet('/api/meta');
|
|
103
|
+
if (!data) return;
|
|
104
|
+
// reporter.meta() returns { polars: {[id]:meta}, deltas: {[id]:meta}, ... }
|
|
105
|
+
// Flatten into a single metaById lookup.
|
|
106
|
+
metaById = {};
|
|
107
|
+
for (const category of ['polars', 'deltas', 'attitudes', 'tables']) {
|
|
108
|
+
const bucket = data[category] || {};
|
|
109
|
+
for (const [id, m] of Object.entries(bucket)) {
|
|
110
|
+
metaById[id] = { ...m, id };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Extract global speed/angle converters from the meta for attitudes and table axes.
|
|
114
|
+
updateUnitConverters();
|
|
115
|
+
// Update dialog labels to reflect the active unit symbols.
|
|
116
|
+
applyUnitLabels();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isStale(item) {
|
|
120
|
+
if (!item) return true;
|
|
121
|
+
return item.state?.stale === true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Config (live settings mirror) ───────────────────────────────────────────
|
|
125
|
+
let config = null;
|
|
126
|
+
|
|
127
|
+
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
|
|
128
|
+
async function apiGet(path) {
|
|
129
|
+
let res;
|
|
130
|
+
try {
|
|
131
|
+
res = await fetch(`${API_BASE}${path}`, { credentials: 'same-origin' });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
showMessage(`Cannot reach server: ${err.message}`);
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (res.status === 401 || res.status === 403) {
|
|
137
|
+
showMessage('Not signed in. Please <a href="/">sign in</a>.', true);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (res.status === 503) { showMessage('Plugin is not running.'); return null; }
|
|
141
|
+
if (!res.ok) { showMessage(`Server error ${res.status}`); return null; }
|
|
142
|
+
const ct = res.headers.get('content-type') || '';
|
|
143
|
+
if (!ct.includes('application/json')) { showMessage('Unexpected server response.'); return null; }
|
|
144
|
+
showMessage('');
|
|
145
|
+
return res.json();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// PUT /api/settings: server returns full merged config — store it and re-render.
|
|
149
|
+
async function apiPutSettings(body) {
|
|
150
|
+
const res = await fetch(`${API_BASE}/api/settings`, {
|
|
151
|
+
method: 'PUT', credentials: 'same-origin',
|
|
152
|
+
headers: { 'Content-Type': 'application/json' },
|
|
153
|
+
body: JSON.stringify(body)
|
|
154
|
+
});
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
const e = await res.json().catch(() => ({ error: res.statusText }));
|
|
157
|
+
throw new Error(e.error || res.statusText);
|
|
158
|
+
}
|
|
159
|
+
config = await res.json();
|
|
160
|
+
renderSettingsPanel();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function apiPost(path, body) {
|
|
164
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
165
|
+
method: 'POST', credentials: 'same-origin',
|
|
166
|
+
headers: { 'Content-Type': 'application/json' },
|
|
167
|
+
body: JSON.stringify(body)
|
|
168
|
+
});
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
const e = await res.json().catch(() => ({ error: res.statusText }));
|
|
171
|
+
throw new Error(e.error || res.statusText);
|
|
172
|
+
}
|
|
173
|
+
return res.json();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function showMessage(html, isLink = false) {
|
|
177
|
+
_explicitMsg = html;
|
|
178
|
+
_refreshMessage();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let _pluginStatus = '';
|
|
182
|
+
let _explicitMsg = '';
|
|
183
|
+
|
|
184
|
+
function _refreshMessage() {
|
|
185
|
+
const el = document.getElementById('message');
|
|
186
|
+
if (!el) return;
|
|
187
|
+
if (_explicitMsg) {
|
|
188
|
+
el.style.color = '#842029';
|
|
189
|
+
if (_explicitMsg.includes('<')) el.innerHTML = _explicitMsg;
|
|
190
|
+
else el.textContent = _explicitMsg;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const isRunningOk = (_pluginStatus === 'Running' || _pluginStatus === '');
|
|
194
|
+
if (!isRunningOk) {
|
|
195
|
+
el.style.color =
|
|
196
|
+
_pluginStatus === 'Stabilizing' ? '#084298' :
|
|
197
|
+
_pluginStatus === 'Falling back to Speed Over Ground' ? '#664d03' : '#842029';
|
|
198
|
+
el.textContent = _pluginStatus;
|
|
199
|
+
} else {
|
|
200
|
+
el.style.color = '';
|
|
201
|
+
el.textContent = '';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─── Settings: paramMeta ──────────────────────────────────────────────────────
|
|
206
|
+
// Each entry: { label, type, min?, max?, step?, default?, sourceOf? }
|
|
207
|
+
// sourceOf: { type:'polar'|'delta'|'attitude', id }
|
|
208
|
+
// For polars: sources come from item.state.magnitude.sources
|
|
209
|
+
// For deltas/attitudes: sources come from item.state.sources
|
|
210
|
+
const paramMeta = {
|
|
211
|
+
estimateBoatSpeed: { label: 'Estimate boat speed', type: 'boolean' },
|
|
212
|
+
updateCorrectionTable: { label: 'Update correction table', type: 'boolean' },
|
|
213
|
+
assumeCurrent: { label: 'Assume current during update', type: 'boolean', description: 'Experimental, works best when currents are relatively stable.' },
|
|
214
|
+
sogFallback: { label: 'Groundspeed fallback', type: 'boolean', description: 'Output Groundspeed as Boatspeed when the paddlewheel sensor is malfunctioning or stalled.' },
|
|
215
|
+
preventDuplication: { label: 'Prevent speed duplication', type: 'boolean', description: 'Replace the raw sensor boatspeed on the Signal K bus with the corrected value, preventing duplicate conflicting values.' },
|
|
216
|
+
stability: { label: 'Stability (1–20)', type: 'number', min: 1, max: 20, step: 1, default: 7, description: 'How quickly the correction table adapts to new observations. Higher values mean slower, more stable changes.' },
|
|
217
|
+
showStatistics: { label: 'Show statistics (σ)', type: 'boolean', description: 'Display standard deviation alongside smoothed values for debugging.' },
|
|
218
|
+
headingSource: { label: 'Heading source', type: 'source', sourceOf: { type: 'delta', id: 'heading.smoothed' } },
|
|
219
|
+
boatSpeedSource:{ label: 'Boat speed source', type: 'source', sourceOf: { type: 'polar', id: 'boatSpeed.smoothed' } },
|
|
220
|
+
SOGSource: { label: 'Groundspeed source', type: 'source', sourceOf: { type: 'polar', id: 'groundSpeed.smoothed' } },
|
|
221
|
+
attitudeSource: { label: 'Attitude source', type: 'source', sourceOf: { type: 'attitude',id: 'attitude.smoothed' } },
|
|
222
|
+
smootherClass: {
|
|
223
|
+
label: 'Smoother type', type: 'select',
|
|
224
|
+
description: 'Smoothing applied to all sensor inputs for learning only.',
|
|
225
|
+
options: [
|
|
226
|
+
{ value: 'MovingAverageSmoother', label: 'Moving average (window)' },
|
|
227
|
+
{ value: 'ExponentialSmoother', label: 'Exponential (τ)' },
|
|
228
|
+
{ value: 'KalmanSmoother', label: 'Kalman filter' },
|
|
229
|
+
]
|
|
230
|
+
},
|
|
231
|
+
smootherTau: {
|
|
232
|
+
label: 'Time constant (τ)', type: 'number', unit: 's',
|
|
233
|
+
min: 1, max: 60, step: 0.5, default: 3,
|
|
234
|
+
description: 'Time constant in seconds.'
|
|
235
|
+
},
|
|
236
|
+
smootherTimeSpan: {
|
|
237
|
+
label: 'Window size', type: 'number', unit: 's',
|
|
238
|
+
min: 2, max: 60, step: 0.5, default: 5,
|
|
239
|
+
description: 'Moving-average window in seconds.'
|
|
240
|
+
},
|
|
241
|
+
smootherSteadyState: {
|
|
242
|
+
label: 'Kalman gain', type: 'number',
|
|
243
|
+
min: 0.01, max: 0.99, step: 0.01, default: 0.2,
|
|
244
|
+
description: 'Steady-state Kalman gain (0 ≈ slow/smooth, 1 ≈ fast/raw). Clamped to 0.01–0.99.'
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// Settings groups for each UI section
|
|
249
|
+
const INPUTS_SOURCE_KEYS = ['headingSource','boatSpeedSource','SOGSource','attitudeSource'];
|
|
250
|
+
const ESTIMATION_SETTING_KEYS = ['sogFallback','preventDuplication'];
|
|
251
|
+
const LEARNING_SETTING_KEYS = ['stability','assumeCurrent','showStatistics'];
|
|
252
|
+
const SMOOTHER_SETTING_KEYS = [
|
|
253
|
+
'smootherClass',
|
|
254
|
+
{ key: 'smootherTau', showIf: cfg => cfg.smootherClass === 'ExponentialSmoother' },
|
|
255
|
+
{ key: 'smootherTimeSpan', showIf: cfg => (cfg.smootherClass || 'MovingAverageSmoother') === 'MovingAverageSmoother' },
|
|
256
|
+
{ key: 'smootherSteadyState', showIf: cfg => cfg.smootherClass === 'KalmanSmoother' },
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
function getStateItem(sourceOf) {
|
|
260
|
+
if (!sourceOf) return null;
|
|
261
|
+
switch (sourceOf.type) {
|
|
262
|
+
case 'polar': return state.polarsById[sourceOf.id];
|
|
263
|
+
case 'delta': return state.deltasById[sourceOf.id];
|
|
264
|
+
case 'attitude': return state.attitudesById[sourceOf.id];
|
|
265
|
+
default: return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function getSources(sourceOf) {
|
|
270
|
+
const item = getStateItem(sourceOf);
|
|
271
|
+
if (!item) return [];
|
|
272
|
+
if (sourceOf.type === 'polar') {
|
|
273
|
+
return Array.isArray(item.state?.magnitude?.sources) ? item.state.magnitude.sources : [];
|
|
274
|
+
}
|
|
275
|
+
return Array.isArray(item.state?.sources) ? item.state.sources
|
|
276
|
+
: Array.isArray(item.state?.angle?.sources) ? item.state.angle.sources
|
|
277
|
+
: [];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Build one settings control for a key.
|
|
281
|
+
function createSettingControl(key, meta, value) {
|
|
282
|
+
if (meta.type === 'boolean') {
|
|
283
|
+
const lbl = document.createElement('label');
|
|
284
|
+
lbl.className = 'switch switch-text switch-primary';
|
|
285
|
+
const cb = document.createElement('input');
|
|
286
|
+
cb.type = 'checkbox'; cb.className = 'switch-input form-check-input'; cb.checked = !!value;
|
|
287
|
+
cb.addEventListener('change', () => apiPutSettings({ [key]: cb.checked }).catch(e => showMessage(`Save failed: ${e.message}`)));
|
|
288
|
+
const sl = document.createElement('span');
|
|
289
|
+
sl.className = 'switch-label'; sl.setAttribute('data-on', 'On'); sl.setAttribute('data-off', 'Off');
|
|
290
|
+
const sh = document.createElement('span');
|
|
291
|
+
sh.className = 'switch-handle';
|
|
292
|
+
lbl.appendChild(cb); lbl.appendChild(sl); lbl.appendChild(sh);
|
|
293
|
+
return lbl;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (meta.type === 'number') {
|
|
297
|
+
const wrap = document.createElement('span');
|
|
298
|
+
const inp = document.createElement('input');
|
|
299
|
+
inp.type = 'number'; inp.className = 'form-control form-control-sm d-inline-block';
|
|
300
|
+
inp.style.width = '80px';
|
|
301
|
+
inp.value = value !== undefined ? value : (meta.default !== undefined ? meta.default : '');
|
|
302
|
+
if (meta.min !== undefined) inp.min = meta.min;
|
|
303
|
+
if (meta.max !== undefined) inp.max = meta.max;
|
|
304
|
+
if (meta.step !== undefined) inp.step = meta.step;
|
|
305
|
+
inp.addEventListener('change', () => {
|
|
306
|
+
const v = Number(inp.value);
|
|
307
|
+
if (Number.isFinite(v)) apiPutSettings({ [key]: v }).catch(e => showMessage(`Save failed: ${e.message}`));
|
|
308
|
+
});
|
|
309
|
+
wrap.appendChild(inp);
|
|
310
|
+
if (meta.default !== undefined && value !== meta.default) {
|
|
311
|
+
const btn = document.createElement('button');
|
|
312
|
+
btn.className = 'btn btn-link btn-sm p-0 ms-1';
|
|
313
|
+
btn.title = `Reset to default (${meta.default})`;
|
|
314
|
+
btn.textContent = '↺';
|
|
315
|
+
btn.addEventListener('click', () => apiPutSettings({ [key]: meta.default }).catch(e => showMessage(`Save failed: ${e.message}`)));
|
|
316
|
+
wrap.appendChild(btn);
|
|
317
|
+
}
|
|
318
|
+
return wrap;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (meta.type === 'select') {
|
|
322
|
+
const sel = document.createElement('select');
|
|
323
|
+
sel.className = 'form-select form-select-sm d-inline-block';
|
|
324
|
+
sel.style.width = '220px';
|
|
325
|
+
(meta.options || []).forEach(opt => {
|
|
326
|
+
const o = document.createElement('option');
|
|
327
|
+
o.value = opt.value; o.textContent = opt.label;
|
|
328
|
+
sel.appendChild(o);
|
|
329
|
+
});
|
|
330
|
+
sel.value = value !== undefined && value !== null ? value : (meta.options[0]?.value || '');
|
|
331
|
+
sel.addEventListener('change', () => {
|
|
332
|
+
apiPutSettings({ [key]: sel.value }).catch(e => showMessage(`Save failed: ${e.message}`));
|
|
333
|
+
});
|
|
334
|
+
return sel;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (meta.type === 'source') {
|
|
338
|
+
if (meta.disabled) {
|
|
339
|
+
const inp = document.createElement('input');
|
|
340
|
+
inp.type = 'text'; inp.className = 'form-control form-control-sm';
|
|
341
|
+
inp.value = (value || '').trim(); inp.disabled = true; inp.style.opacity = '0.5';
|
|
342
|
+
return inp;
|
|
343
|
+
}
|
|
344
|
+
const sel = document.createElement('select');
|
|
345
|
+
sel.className = 'form-select form-select-sm d-inline-block';
|
|
346
|
+
sel.style.width = '200px';
|
|
347
|
+
const blank = document.createElement('option');
|
|
348
|
+
blank.value = ''; blank.textContent = '(any)';
|
|
349
|
+
sel.appendChild(blank);
|
|
350
|
+
const sources = meta.sourceOf ? getSources(meta.sourceOf) : [];
|
|
351
|
+
sources.forEach(src => {
|
|
352
|
+
const opt = document.createElement('option');
|
|
353
|
+
opt.value = src; opt.textContent = src;
|
|
354
|
+
sel.appendChild(opt);
|
|
355
|
+
});
|
|
356
|
+
sel.value = (value || '').trim();
|
|
357
|
+
sel.addEventListener('change', () => {
|
|
358
|
+
apiPutSettings({ [key]: sel.value || ' ' }).catch(e => showMessage(`Save failed: ${e.message}`));
|
|
359
|
+
});
|
|
360
|
+
return sel;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// fallback: text input
|
|
364
|
+
const inp = document.createElement('input');
|
|
365
|
+
inp.type = 'text'; inp.className = 'form-control form-control-sm';
|
|
366
|
+
inp.value = (value || '').trim();
|
|
367
|
+
let debounce;
|
|
368
|
+
inp.addEventListener('input', () => {
|
|
369
|
+
clearTimeout(debounce);
|
|
370
|
+
debounce = setTimeout(() => apiPutSettings({ [key]: inp.value.trim() || ' ' }).catch(e => showMessage(`Save failed: ${e.message}`)), 600);
|
|
371
|
+
});
|
|
372
|
+
return inp;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function renderSettingsRows(tableId, keys) {
|
|
376
|
+
const table = document.getElementById(tableId);
|
|
377
|
+
const tbody = table && table.querySelector('tbody');
|
|
378
|
+
if (!tbody || !config) return;
|
|
379
|
+
tbody.innerHTML = '';
|
|
380
|
+
keys.forEach(entry => {
|
|
381
|
+
const key = typeof entry === 'string' ? entry : entry.key;
|
|
382
|
+
if (entry.showIf && !entry.showIf(config)) return;
|
|
383
|
+
const meta = paramMeta[key];
|
|
384
|
+
if (!meta) return;
|
|
385
|
+
const value = config[key];
|
|
386
|
+
const tr = document.createElement('tr');
|
|
387
|
+
const tdL = document.createElement('td');
|
|
388
|
+
tdL.textContent = meta.label;
|
|
389
|
+
if (meta.description) {
|
|
390
|
+
const desc = document.createElement('small');
|
|
391
|
+
desc.className = 'text-muted d-block';
|
|
392
|
+
desc.textContent = meta.description;
|
|
393
|
+
tdL.appendChild(desc);
|
|
394
|
+
}
|
|
395
|
+
const tdC = document.createElement('td'); tdC.appendChild(createSettingControl(key, meta, value));
|
|
396
|
+
tr.appendChild(tdL); tr.appendChild(tdC);
|
|
397
|
+
tbody.appendChild(tr);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function renderSectionToggles() {
|
|
402
|
+
if (!config) return;
|
|
403
|
+
[['toggle-estimateBoatSpeed', 'estimateBoatSpeed'], ['toggle-updateCorrectionTable', 'updateCorrectionTable']].forEach(([id, key]) => {
|
|
404
|
+
const cb = document.getElementById(id);
|
|
405
|
+
if (!cb) return;
|
|
406
|
+
cb.checked = !!config[key];
|
|
407
|
+
cb.onchange = () => apiPutSettings({ [key]: cb.checked }).catch(e => showMessage(`Save failed: ${e.message}`));
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function renderSettingsPanel() {
|
|
412
|
+
if (!config) return;
|
|
413
|
+
renderSettingsRows('inputs-sources-table', INPUTS_SOURCE_KEYS);
|
|
414
|
+
renderSettingsRows('estimation-settings-table', ESTIMATION_SETTING_KEYS);
|
|
415
|
+
renderSettingsRows('smoother-settings-table', SMOOTHER_SETTING_KEYS);
|
|
416
|
+
renderSettingsRows('learning-settings-table', LEARNING_SETTING_KEYS);
|
|
417
|
+
renderSectionToggles();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ─── Live data rendering ─────────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
function formatPolarValue(p) {
|
|
423
|
+
if (!p) return '—';
|
|
424
|
+
const m = metaById[p.id];
|
|
425
|
+
const speedC = buildConverter(m?.magnitude?.displayUnits) || DEFAULTS.speed;
|
|
426
|
+
const angleC = buildConverter(m?.angle?.displayUnits) || DEFAULTS.angle;
|
|
427
|
+
const spd = typeof p.magnitude === 'number' ? speedC.convert(p.magnitude).toFixed(speedC.decimals) : '—';
|
|
428
|
+
const ang = typeof p.angle === 'number' ? angleC.convert(p.angle).toFixed(angleC.decimals) : '—';
|
|
429
|
+
const sigma = (config?.showStatistics && p.id.endsWith('.smoothed') && typeof p.trace === 'number')
|
|
430
|
+
? ` (σ=${speedC.convert(Math.sqrt(p.trace)).toFixed(speedC.decimals + 2)})` : '';
|
|
431
|
+
return `${spd} ${speedC.symbol} / ${ang}${angleC.symbol}${sigma}`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function formatDeltaValue(d) {
|
|
435
|
+
if (!d || typeof d.value !== 'number') return '—';
|
|
436
|
+
const m = metaById[d.id];
|
|
437
|
+
const uc = buildConverter(m?.displayUnits)
|
|
438
|
+
|| (m?.units === 'm/s' ? DEFAULTS.speed : DEFAULTS.angle);
|
|
439
|
+
const sigma = (config?.showStatistics && d.id.endsWith('.smoothed') && typeof d.variance === 'number')
|
|
440
|
+
? ` (σ=${uc.convert(Math.sqrt(d.variance)).toFixed(uc.decimals + 2)})` : '';
|
|
441
|
+
return `${uc.convert(d.value).toFixed(uc.decimals)} ${uc.symbol}${sigma}`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function formatAttitudeValue(a) {
|
|
445
|
+
const v = (a && a.value) || {};
|
|
446
|
+
const variance = a && a.variance;
|
|
447
|
+
const m = metaById[a?.id];
|
|
448
|
+
const angleC = buildConverter(m?.displayUnits) || DEFAULTS.angle;
|
|
449
|
+
const roll = typeof v.roll === 'number' ? angleC.convert(v.roll).toFixed(angleC.decimals) : '—';
|
|
450
|
+
const pitch = typeof v.pitch === 'number' ? angleC.convert(v.pitch).toFixed(angleC.decimals) : '—';
|
|
451
|
+
const showSigma = config?.showStatistics && a?.id?.endsWith('.smoothed');
|
|
452
|
+
const sigmaRoll = (showSigma && variance && typeof variance.roll === 'number') ? ` (σ=${angleC.convert(Math.sqrt(variance.roll)).toFixed(angleC.decimals + 2)})` : '';
|
|
453
|
+
const sigmaPitch = (showSigma && variance && typeof variance.pitch === 'number') ? ` (σ=${angleC.convert(Math.sqrt(variance.pitch)).toFixed(angleC.decimals + 2)})` : '';
|
|
454
|
+
return `heel ${roll} ${angleC.symbol} ${sigmaRoll} `;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function itemLabel(item) {
|
|
458
|
+
return metaById[item.id]?.displayName ?? item.path ?? item.id;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function buildDataTable(rows) {
|
|
462
|
+
const tbl = document.createElement('table');
|
|
463
|
+
tbl.className = 'table table-sm table-borderless mb-0';
|
|
464
|
+
const tbody = document.createElement('tbody');
|
|
465
|
+
rows.forEach(row => {
|
|
466
|
+
const tr = document.createElement('tr');
|
|
467
|
+
if (row.stale) tr.className = 'stale';
|
|
468
|
+
const tdL = document.createElement('td'); tdL.textContent = row.label;
|
|
469
|
+
const tdV = document.createElement('td'); tdV.textContent = row.value;
|
|
470
|
+
tr.appendChild(tdL); tr.appendChild(tdV);
|
|
471
|
+
tbody.appendChild(tr);
|
|
472
|
+
});
|
|
473
|
+
tbl.appendChild(tbody);
|
|
474
|
+
return tbl;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ─── Section rendering ───────────────────────────────────────────────────────
|
|
478
|
+
// Items are routed by explicit ID lists per section.
|
|
479
|
+
// Raw handlers have plain IDs ('boatSpeed', 'groundSpeed', 'attitude') — present when
|
|
480
|
+
// estimateBoatSpeed is enabled. Smoothed wrappers have '<id>.smoothed' IDs — present when
|
|
481
|
+
// updateCorrectionTable is enabled.
|
|
482
|
+
|
|
483
|
+
function filterById(arr, ids) {
|
|
484
|
+
return ids.flatMap(id => {
|
|
485
|
+
const item = arr.find(item => item.id === id);
|
|
486
|
+
return item ? [item] : [];
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function renderGroupInto(elId, polars, deltas, attitudes) {
|
|
491
|
+
const el = document.getElementById(elId);
|
|
492
|
+
if (!el) return;
|
|
493
|
+
el.innerHTML = '';
|
|
494
|
+
const rows = [
|
|
495
|
+
...polars .map(p => ({ label: itemLabel(p), value: formatPolarValue(p), stale: isStale(p) })),
|
|
496
|
+
...deltas .map(d => ({ label: itemLabel(d), value: formatDeltaValue(d), stale: isStale(d) })),
|
|
497
|
+
...attitudes.map(a => ({ label: itemLabel(a), value: formatAttitudeValue(a), stale: isStale(a) }))
|
|
498
|
+
];
|
|
499
|
+
if (rows.length) el.appendChild(buildDataTable(rows));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function renderLiveSections() {
|
|
503
|
+
// Inputs section — raw sensor readings only (smoothing is internal to the plugin)
|
|
504
|
+
renderGroupInto('inputs-values',
|
|
505
|
+
filterById(state.polarsAll, ['boatSpeed', 'groundSpeed']),
|
|
506
|
+
filterById(state.deltasAll, ['heading.angle']),
|
|
507
|
+
filterById(state.attitudesAll, ['attitude'])
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
// Estimation — inputs (raw sensor data used for boat speed estimation)
|
|
511
|
+
renderGroupInto('estimation-inputs',
|
|
512
|
+
filterById(state.polarsAll, ['boatSpeed', 'groundSpeed']),
|
|
513
|
+
filterById(state.deltasAll, ['heading.angle']),
|
|
514
|
+
filterById(state.attitudesAll, ['attitude'])
|
|
515
|
+
);
|
|
516
|
+
// Estimation — intermediates
|
|
517
|
+
renderGroupInto('estimation-intermediates',
|
|
518
|
+
filterById(state.polarsAll, ['boatSpeedRefGround', 'speedCorrection']),
|
|
519
|
+
[], []
|
|
520
|
+
);
|
|
521
|
+
// Estimation — outputs
|
|
522
|
+
renderGroupInto('estimation-outputs',
|
|
523
|
+
filterById(state.polarsAll, ['correctedBoatSpeed', 'current.smoothed']),
|
|
524
|
+
[], []
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
// Learning — inputs: smoothed sensors + current if assumeCurrent
|
|
528
|
+
const learningCurrentPolars = (config && config.assumeCurrent)
|
|
529
|
+
? filterById(state.polarsAll, ['current.smoothed'])
|
|
530
|
+
: [];
|
|
531
|
+
renderGroupInto('learning-inputs',
|
|
532
|
+
[...filterById(state.polarsAll, ['boatSpeed.smoothed', 'groundSpeed.smoothed']), ...learningCurrentPolars],
|
|
533
|
+
filterById(state.deltasAll, ['heading.smoothed']),
|
|
534
|
+
filterById(state.attitudesAll, ['attitude.smoothed'])
|
|
535
|
+
);
|
|
536
|
+
// Learning — intermediates
|
|
537
|
+
renderGroupInto('learning-intermediates',
|
|
538
|
+
filterById(state.polarsAll, ['residual']),
|
|
539
|
+
[], []
|
|
540
|
+
);
|
|
541
|
+
// Correction table
|
|
542
|
+
const tableEl = document.getElementById('table-container');
|
|
543
|
+
if (tableEl) {
|
|
544
|
+
tableEl.innerHTML = '';
|
|
545
|
+
const speedC = unitConverters.speed || DEFAULTS.speed;
|
|
546
|
+
const angleC = unitConverters.angle || DEFAULTS.angle;
|
|
547
|
+
const tableOpts = {
|
|
548
|
+
fmtSpeed: v => speedC.convert(v).toFixed(speedC.decimals),
|
|
549
|
+
fmtHeel: v => angleC.convert(v).toFixed(angleC.decimals),
|
|
550
|
+
speedSymbol: speedC.symbol,
|
|
551
|
+
heelSymbol: angleC.symbol,
|
|
552
|
+
};
|
|
553
|
+
Object.values(state.tablesById).forEach(t => tableEl.appendChild(tableRenderer.render(t, tableOpts)));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ─── Polling ──────────────────────────────────────────────────────────────────
|
|
558
|
+
let updateTimer = null;
|
|
559
|
+
let lastTickOk = false;
|
|
560
|
+
|
|
561
|
+
async function tick() {
|
|
562
|
+
const data = await apiGet('/api/report');
|
|
563
|
+
if (data) {
|
|
564
|
+
// Recovered from an error — reload meta and config so unit converters,
|
|
565
|
+
// source lists and settings reflect the (possibly restarted) plugin state.
|
|
566
|
+
if (!lastTickOk) {
|
|
567
|
+
await loadMeta();
|
|
568
|
+
config = await apiGet('/api/settings');
|
|
569
|
+
if (config && config.tableName) setTableName(config.tableName);
|
|
570
|
+
renderSettingsPanel();
|
|
571
|
+
}
|
|
572
|
+
lastTickOk = true;
|
|
573
|
+
normaliseState(data);
|
|
574
|
+
renderLiveSections();
|
|
575
|
+
} else {
|
|
576
|
+
lastTickOk = false;
|
|
577
|
+
}
|
|
578
|
+
// Always poll status separately so the message reflects plugin state
|
|
579
|
+
// even when /api/report fails (plugin stopped/restarting).
|
|
580
|
+
const statusData = await fetch(`${API_BASE}/api/status`, { credentials: 'same-origin' })
|
|
581
|
+
.then(r => r.ok ? r.json() : null)
|
|
582
|
+
.catch(() => null);
|
|
583
|
+
_pluginStatus = statusData?.status ?? '';
|
|
584
|
+
_refreshMessage();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function startUpdates() {
|
|
588
|
+
if (updateTimer) clearInterval(updateTimer);
|
|
589
|
+
updateTimer = setInterval(tick, 1000);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
// ─── Vanilla modal helpers ────────────────────────────────────────────────────
|
|
595
|
+
|
|
596
|
+
function showModal(id) {
|
|
597
|
+
const el = document.getElementById(id);
|
|
598
|
+
if (!el) return;
|
|
599
|
+
el.style.display = 'block';
|
|
600
|
+
el.classList.add('show');
|
|
601
|
+
el.setAttribute('aria-hidden', 'false');
|
|
602
|
+
document.body.classList.add('modal-open');
|
|
603
|
+
if (!document.getElementById('app-modal-backdrop')) {
|
|
604
|
+
const bd = document.createElement('div');
|
|
605
|
+
bd.id = 'app-modal-backdrop';
|
|
606
|
+
bd.className = 'modal-backdrop show';
|
|
607
|
+
document.body.appendChild(bd);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function closeModal(id) {
|
|
612
|
+
const el = document.getElementById(id);
|
|
613
|
+
if (el) { el.style.display = 'none'; el.classList.remove('show'); el.setAttribute('aria-hidden', 'true'); }
|
|
614
|
+
document.body.classList.remove('modal-open');
|
|
615
|
+
const bd = document.getElementById('app-modal-backdrop');
|
|
616
|
+
if (bd) bd.remove();
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// ─── Correction table manager ─────────────────────────────────────────────────
|
|
620
|
+
|
|
621
|
+
function setTableName(name) {
|
|
622
|
+
const el = document.getElementById('active-table-name');
|
|
623
|
+
if (el) el.textContent = name ? `(${name})` : '';
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function modalStatus(modalId, msg, ok = false) {
|
|
627
|
+
const el = document.getElementById('modal-' + modalId + '-status');
|
|
628
|
+
if (el) { el.textContent = msg; el.className = 'small mr-auto ' + (ok ? 'text-success' : 'text-danger'); }
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function initTableManager() {
|
|
632
|
+
if (config && config.tableName) setTableName(config.tableName);
|
|
633
|
+
|
|
634
|
+
// Wire close/cancel buttons and click-outside for each modal
|
|
635
|
+
['modal-create', 'modal-load', 'modal-copy', 'modal-resize'].forEach(id => {
|
|
636
|
+
const el = document.getElementById(id);
|
|
637
|
+
if (!el) return;
|
|
638
|
+
el.querySelectorAll('.close, [data-dismiss="modal"]').forEach(btn => {
|
|
639
|
+
btn.addEventListener('click', () => closeModal(id));
|
|
640
|
+
});
|
|
641
|
+
el.addEventListener('click', e => { if (e.target === el) closeModal(id); });
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// ── New ──
|
|
645
|
+
document.getElementById('btn-tbl-new')?.addEventListener('click', () => {
|
|
646
|
+
modalStatus('create', '');
|
|
647
|
+
showModal('modal-create');
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// ── Load ──
|
|
651
|
+
document.getElementById('btn-tbl-load')?.addEventListener('click', async () => {
|
|
652
|
+
const listEl = document.getElementById('table-list');
|
|
653
|
+
const confirmBtn = document.getElementById('btn-load-confirm');
|
|
654
|
+
if (listEl) listEl.innerHTML = '<li class="list-group-item text-muted small">Loading…</li>';
|
|
655
|
+
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.onclick = null; }
|
|
656
|
+
modalStatus('load', '');
|
|
657
|
+
showModal('modal-load');
|
|
658
|
+
let selectedName = null;
|
|
659
|
+
const tables = await apiGet('/api/tables');
|
|
660
|
+
if (!listEl) return;
|
|
661
|
+
listEl.innerHTML = '';
|
|
662
|
+
if (!tables || tables.length === 0) {
|
|
663
|
+
listEl.innerHTML = '<li class="list-group-item text-muted small">No saved tables found.</li>';
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
tables.forEach(t => {
|
|
667
|
+
const li = document.createElement('li');
|
|
668
|
+
li.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center'
|
|
669
|
+
+ (t.active ? ' active' : '');
|
|
670
|
+
const span = document.createElement('span');
|
|
671
|
+
span.textContent = t.name;
|
|
672
|
+
li.appendChild(span);
|
|
673
|
+
if (t.active) {
|
|
674
|
+
const badge = document.createElement('span');
|
|
675
|
+
badge.className = 'badge badge-light';
|
|
676
|
+
badge.textContent = 'active';
|
|
677
|
+
li.appendChild(badge);
|
|
678
|
+
}
|
|
679
|
+
li.addEventListener('click', () => {
|
|
680
|
+
listEl.querySelectorAll('li').forEach(l => l.classList.remove('active'));
|
|
681
|
+
li.classList.add('active');
|
|
682
|
+
selectedName = t.name;
|
|
683
|
+
if (confirmBtn) confirmBtn.disabled = false;
|
|
684
|
+
});
|
|
685
|
+
listEl.appendChild(li);
|
|
686
|
+
});
|
|
687
|
+
if (confirmBtn) {
|
|
688
|
+
confirmBtn.onclick = async () => {
|
|
689
|
+
if (!selectedName) return;
|
|
690
|
+
modalStatus('load', '');
|
|
691
|
+
try {
|
|
692
|
+
const r = await apiPost('/api/tables/load', { name: selectedName });
|
|
693
|
+
setTableName(r.name);
|
|
694
|
+
if (config) config.tableName = r.name;
|
|
695
|
+
closeModal('modal-load');
|
|
696
|
+
await tick();
|
|
697
|
+
} catch (e) { modalStatus('load', e.message); }
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// ── Copy ──
|
|
703
|
+
document.getElementById('btn-tbl-copy')?.addEventListener('click', () => {
|
|
704
|
+
modalStatus('copy', '');
|
|
705
|
+
showModal('modal-copy');
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// ── Resize ──
|
|
709
|
+
document.getElementById('btn-tbl-resize')?.addEventListener('click', () => {
|
|
710
|
+
modalStatus('resize', '');
|
|
711
|
+
const t = Object.values(state.tablesById)[0];
|
|
712
|
+
if (t && t.row && t.col) {
|
|
713
|
+
const speedC = unitConverters.speed || DEFAULTS.speed;
|
|
714
|
+
const angleC = unitConverters.angle || DEFAULTS.angle;
|
|
715
|
+
const setVal = (id, v, dec) => { const el = document.getElementById(id); if (el) el.value = +v.toFixed(dec); };
|
|
716
|
+
setVal('resize-maxSpeed', speedC.convert(t.row.max), Math.max(speedC.decimals, 1));
|
|
717
|
+
setVal('resize-speedStep', speedC.convert(t.row.step), Math.max(speedC.decimals, 1));
|
|
718
|
+
setVal('resize-maxHeel', angleC.convert(t.col.max), Math.max(angleC.decimals, 0));
|
|
719
|
+
setVal('resize-heelStep', angleC.convert(t.col.step), Math.max(angleC.decimals, 0));
|
|
720
|
+
}
|
|
721
|
+
showModal('modal-resize');
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// ── Create confirm ──
|
|
725
|
+
document.getElementById('btn-create-confirm')?.addEventListener('click', async () => {
|
|
726
|
+
modalStatus('create', '');
|
|
727
|
+
const speedC = unitConverters.speed || DEFAULTS.speed;
|
|
728
|
+
const angleC = unitConverters.angle || DEFAULTS.angle;
|
|
729
|
+
const invertSpeed = speedC.invert || DEFAULTS.speed.invert;
|
|
730
|
+
const invertAngle = angleC.invert || DEFAULTS.angle.invert;
|
|
731
|
+
const body = {
|
|
732
|
+
name: (document.getElementById('create-name')?.value || '').trim(),
|
|
733
|
+
maxSpeed: invertSpeed(Number(document.getElementById('create-maxSpeed')?.value)),
|
|
734
|
+
speedStep: invertSpeed(Number(document.getElementById('create-speedStep')?.value)),
|
|
735
|
+
maxHeel: invertAngle(Number(document.getElementById('create-maxHeel')?.value)),
|
|
736
|
+
heelStep: invertAngle(Number(document.getElementById('create-heelStep')?.value)),
|
|
737
|
+
};
|
|
738
|
+
if (!body.name) { modalStatus('create', 'Name is required.'); return; }
|
|
739
|
+
try {
|
|
740
|
+
const r = await apiPost('/api/tables/create', body);
|
|
741
|
+
setTableName(r.name);
|
|
742
|
+
if (config) config.tableName = r.name;
|
|
743
|
+
closeModal('modal-create');
|
|
744
|
+
await tick();
|
|
745
|
+
} catch (e) { modalStatus('create', e.message); }
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
// ── Copy confirm ──
|
|
749
|
+
document.getElementById('btn-copy-confirm')?.addEventListener('click', async () => {
|
|
750
|
+
modalStatus('copy', '');
|
|
751
|
+
const newName = (document.getElementById('copy-name')?.value || '').trim();
|
|
752
|
+
if (!newName) { modalStatus('copy', 'New name is required.'); return; }
|
|
753
|
+
try {
|
|
754
|
+
const r = await apiPost('/api/tables/copy', { newName });
|
|
755
|
+
setTableName(r.name);
|
|
756
|
+
if (config) config.tableName = r.name;
|
|
757
|
+
closeModal('modal-copy');
|
|
758
|
+
await tick();
|
|
759
|
+
} catch (e) { modalStatus('copy', e.message); }
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// ── Resize confirm ──
|
|
763
|
+
document.getElementById('btn-resize-confirm')?.addEventListener('click', async () => {
|
|
764
|
+
modalStatus('resize', '');
|
|
765
|
+
const speedC = unitConverters.speed || DEFAULTS.speed;
|
|
766
|
+
const angleC = unitConverters.angle || DEFAULTS.angle;
|
|
767
|
+
const invertSpeed = speedC.invert || DEFAULTS.speed.invert;
|
|
768
|
+
const invertAngle = angleC.invert || DEFAULTS.angle.invert;
|
|
769
|
+
const body = {
|
|
770
|
+
maxSpeed: invertSpeed(Number(document.getElementById('resize-maxSpeed')?.value)),
|
|
771
|
+
speedStep: invertSpeed(Number(document.getElementById('resize-speedStep')?.value)),
|
|
772
|
+
maxHeel: invertAngle(Number(document.getElementById('resize-maxHeel')?.value)),
|
|
773
|
+
heelStep: invertAngle(Number(document.getElementById('resize-heelStep')?.value)),
|
|
774
|
+
};
|
|
775
|
+
if (!Object.values(body).every(v => Number.isFinite(v) && v > 0)) {
|
|
776
|
+
modalStatus('resize', 'All dimensions must be positive numbers.');
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
try {
|
|
780
|
+
await apiPost('/api/tables/resize', body);
|
|
781
|
+
closeModal('modal-resize');
|
|
782
|
+
await tick();
|
|
783
|
+
} catch (e) { modalStatus('resize', e.message); }
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ─── Boot ─────────────────────────────────────────────────────────────────────
|
|
788
|
+
async function start() {
|
|
789
|
+
initTableManager();
|
|
790
|
+
|
|
791
|
+
config = await apiGet('/api/settings');
|
|
792
|
+
if (config && config.tableName) setTableName(config.tableName);
|
|
793
|
+
renderSettingsPanel();
|
|
794
|
+
|
|
795
|
+
await loadMeta();
|
|
796
|
+
await tick();
|
|
797
|
+
// Re-render settings after first tick so source dropdowns are populated
|
|
798
|
+
renderSettingsPanel();
|
|
799
|
+
startUpdates();
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
window.addEventListener('DOMContentLoaded', () => {
|
|
803
|
+
start();
|
|
804
|
+
});
|