speedandcurrent 1.5.1 → 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/public/app.js CHANGED
@@ -1,447 +1,804 @@
1
- const API_BASE_URL = "/plugins/speedandcurrent"; // Adjust based on your server configuration
2
- import TableRenderer from './TableRenderer.js';
3
-
4
-
5
- let updateInterval = 1000;
6
- let updateTimer;
7
- let updatesPaused = false;
8
-
9
- let vAngle = 1;
10
- let dAngle = 2;
11
- let vSpeed = 1;
12
- let dSpeed = 1;
13
-
14
-
15
- const LS_KEYS = {
16
- speed: 'sc_speed_unit',
17
- angle: 'sc_angle_unit',
18
- color: 'sc_color_mode',
19
- metrics: 'sc_table_metrics'
20
- };
21
-
22
- export function handleSpeedUnitChange(value) {
23
- if (value == "knots") {
24
- vSpeed = 1.943844;
25
- dSpeed = 1;
26
- localStorage.setItem(LS_KEYS.speed, 'knots');
27
- return;
28
- }
29
- if (value == "kmh") {
30
- vSpeed = 3.6;
31
- dSpeed = 1;
32
- localStorage.setItem(LS_KEYS.speed, 'kmh');
33
- return;
34
- }
35
- vSpeed = 1;
36
- dSpeed = 1;
37
- localStorage.setItem(LS_KEYS.speed, 'ms');
38
- }
39
-
40
- export function handleAngleUnitChange(value) {
41
- if (value == "degrees") {
42
- vAngle = 180 / Math.PI;
43
- dAngle = 0;
44
- localStorage.setItem(LS_KEYS.angle, 'degrees');
45
- return;
46
- }
47
- vAngle = 1;
48
- dAngle = 2;
49
- localStorage.setItem(LS_KEYS.angle, 'radians');
50
- }
51
-
52
-
53
-
54
- // Removed table style switch; rendering is driven by metrics selection.
55
-
56
- export function handleColorModeChange(value) {
57
- if (tableRenderer && tableRenderer.setColorMode) {
58
- tableRenderer.setColorMode(value);
59
- localStorage.setItem(LS_KEYS.color, value);
60
- // re-render immediately using last data if available
61
- // fetchAndUpdateData will repaint next tick; do a quick repaint now if cached
62
- // (Simplest approach: trigger fetch)
63
- fetchAndUpdateData();
64
- }
65
- }
66
-
67
- function cAngle(value) {
68
- value *= vAngle;
69
- return value.toFixed(dAngle);
70
- }
71
-
72
- function cSpeed(value) {
73
- value *= vSpeed;
74
- return value.toFixed(dSpeed);
75
- }
76
-
77
- let selectedMetrics = {
78
- correction: true,
79
- factor: true,
80
- leeway: true,
81
- trace: false,
82
- N: true,
83
- };
84
-
85
- function buildCellContent(parts) {
86
- // parts is array of strings; filter falsy and join
87
- return parts.filter(Boolean).join('\n');
88
- }
89
-
90
- function cartesian(correction, speed, heel) {
91
- if (correction.N == 0) return null;
92
- const bits = [];
93
- if (selectedMetrics.correction) {
94
- bits.push(` <div><strong>X:</strong> ${cSpeed(correction.x)}</div>`);
95
- bits.push(` <div><strong>Y:</strong> ${cSpeed(correction.y)}</div>`);
96
- }
97
- if (selectedMetrics.trace && Number.isFinite(correction.trace)) {
98
- bits.push(` <div><strong>trace:</strong> ${Number(correction.trace).toPrecision(2)}</div>`);
99
- }
100
- if (selectedMetrics.N) {
101
- bits.push(` <div><strong>N:</strong> ${correction.N}</div>`);
102
- }
103
- return buildCellContent(bits);
104
- }
105
-
106
- function polar(correction, speed, heel) {
107
- if (speed == 0 || correction.N == 0) return null;
108
- const bits = [];
109
- // Prefer server-provided values when available
110
- const factor = Number.isFinite(correction.factor) ? correction.factor : (speed > 0 ? (correction.x + speed) / speed : 0);
111
- const leewayRad = Number.isFinite(correction.leeway) ? correction.leeway : Math.atan2(correction.y, speed + correction.x);
112
- const leewayDisplay = leewayRad; // signed value; color uses |leeway|
113
-
114
- if (selectedMetrics.factor) {
115
- bits.push(` <div><strong>factor:</strong> ${factor.toFixed(2)}</div>`);
116
- }
117
- if (selectedMetrics.leeway) {
118
- bits.push(` <div><strong>leeway:</strong> ${cAngle(leewayDisplay)}</div>`);
119
- }
120
- if (selectedMetrics.trace && Number.isFinite(correction.trace)) {
121
- bits.push(` <div><strong>trace:</strong> ${Number(correction.trace).toPrecision(2)}</div>`);
122
- }
123
- if (selectedMetrics.N) {
124
- bits.push(` <div><strong>N:</strong> ${correction.N}</div>`);
125
- }
126
- return buildCellContent(bits);
127
- }
128
-
129
- function unifiedCellRenderer(correction, speed, heel) {
130
- // Combines polar-oriented metrics and cartesian correction based on selections
131
- if (correction.N == 0) return null;
132
- const bits = [];
133
- // Polar-ish metrics
134
- if (selectedMetrics.factor && (speed !== 0)) {
135
- const factor = Number.isFinite(correction.factor) ? correction.factor : (speed > 0 ? (correction.x + speed) / speed : 0);
136
- bits.push(` <div><strong>factor:</strong> ${factor.toFixed(2)}</div>`);
137
- }
138
- if (selectedMetrics.leeway && (speed !== 0)) {
139
- const leewayRad = Number.isFinite(correction.leeway) ? correction.leeway : Math.atan2(correction.y, speed + correction.x);
140
- bits.push(` <div><strong>leeway:</strong> ${cAngle(leewayRad)}</div>`);
141
- }
142
- // Cartesian correction (always available)
143
- if (selectedMetrics.correction) {
144
- bits.push(` <div><strong>X:</strong> ${cSpeed(correction.x)}</div>`);
145
- bits.push(` <div><strong>Y:</strong> ${cSpeed(correction.y)}</div>`);
146
- }
147
- if (selectedMetrics.trace && Number.isFinite(correction.trace)) {
148
- bits.push(` <div><strong>trace:</strong> ${Number(correction.trace).toPrecision(2)}</div>`);
149
- }
150
- if (selectedMetrics.N) {
151
- bits.push(` <div><strong>N:</strong> ${correction.N}</div>`);
152
- }
153
- return buildCellContent(bits);
154
- }
155
-
156
-
157
-
158
- async function getFromServer(endpoint) {
159
- try {
160
- const response = await fetch(`${API_BASE_URL}/${endpoint}`, {
161
- method: 'GET',
162
- headers: {
163
- 'Accept': 'application/json'
164
- },
165
- credentials: 'same-origin' // include cookies for auth
166
- });
167
-
168
- const msgEl = document.getElementById("message");
169
- const contentType = response.headers.get('content-type') || '';
170
-
171
- // Auth errors: show friendly message and pause updates
172
- if (response.status === 401 || response.status === 403) {
173
- if (msgEl) {
174
- msgEl.innerHTML = 'Not signed in. Please <a href="/">sign in</a> to the Signal K server, then reload this page.';
175
- }
176
- if (updateTimer) {
177
- clearInterval(updateTimer);
178
- updatesPaused = true;
179
- const toggleButton = document.getElementById('toggle-updates');
180
- if (toggleButton) toggleButton.textContent = "Resume";
181
- }
182
- return null;
183
- }
184
-
185
- // Service not running or other HTTP errors
186
- if (!response.ok) {
187
- if (response.status === 503) {
188
- if (msgEl) msgEl.textContent = "Plugin is not running";
189
- } else {
190
- if (msgEl) msgEl.textContent = `Failed to fetch data (${response.status} ${response.statusText}).`;
191
- }
192
- return null;
193
- }
194
-
195
- // Only parse JSON when content-type matches
196
- if (contentType.includes('application/json')) {
197
- const data = await response.json();
198
- if (msgEl) msgEl.textContent = "";
199
- return data;
200
- } else {
201
- // Likely an HTML login page or plain text error
202
- if (msgEl) {
203
- msgEl.innerHTML = 'Unexpected server response. If you are not signed in, please <a href="/">sign in</a> to the Signal K server, then reload.';
204
- }
205
- return null;
206
- }
207
- } catch (error) {
208
- console.error("Failed to fetch data from server:", error);
209
- const msgEl = document.getElementById("message");
210
- if (msgEl) msgEl.textContent = `Network error: ${error && error.message ? error.message : String(error)}`;
211
- return null;
212
- }
213
- }
214
-
215
-
216
-
217
- function updateOptions(data) {
218
- const optionsContent = document.getElementById('options-content');
219
- optionsContent.innerHTML = ''; // Clear previous content
220
-
221
- const table = document.createElement('table');
222
- const headerRow = document.createElement('tr');
223
- headerRow.innerHTML = '<th>Option Name</th><th>Value</th>';
224
- table.appendChild(headerRow);
225
-
226
- Object.entries(data.options).forEach(([key, value]) => {
227
- const type = typeof value;
228
- if (type === 'string' || type === 'number' || type === 'boolean') {
229
-
230
- const row = document.createElement('tr');
231
- row.innerHTML = `<td>${key}</td><td>${value}</td>`;
232
- table.appendChild(row);
233
- };
234
- });
235
-
236
- optionsContent.appendChild(table);
237
- }
238
-
239
-
240
- function updatePolar(data) {
241
- const stepsList = document.getElementById('speeds-container');
242
- stepsList.innerHTML = ''; // Clear previous steps
243
-
244
- const table = document.createElement('table');
245
- table.classList.add('polar');
246
- const headerRow = document.createElement('tr');
247
- headerRow.innerHTML = '<th>Label</th><th>Speed</th><th>Angle</th><th>Trace</th>';
248
- table.appendChild(headerRow);
249
-
250
- data.polars.forEach(polar => {
251
- const row = document.createElement('tr');
252
- if (polar.id) {
253
- row.id = polar.id;
254
- }
255
- if (polar.displayAttributes.unstable) {
256
- row.classList.add('unstable');
257
- }
258
- row.innerHTML = `<td>${polar.displayAttributes.label}</td><td>${cSpeed(polar.magnitude)}</td><td>${cAngle(polar.angle)}</td><td>${(polar.trace !== undefined && polar.trace !== null ? Number(polar.trace).toPrecision(2) : '')}</td>`;
259
- table.appendChild(row);
260
- });
261
-
262
- stepsList.appendChild(table);
263
- }
264
-
265
- function updateDelta(data) {
266
- const stepsList = document.getElementById('delta-container');
267
- stepsList.innerHTML = ''; // Clear previous steps
268
-
269
- const table = document.createElement('table');
270
- table.classList.add('delta');
271
- const headerRow = document.createElement('tr');
272
- headerRow.innerHTML = '<th>Label</th><th>Value</th><th>Variance</th>';
273
- table.appendChild(headerRow);
274
-
275
- data.deltas.forEach(delta => {
276
- const row = document.createElement('tr');
277
- if (delta.id) {
278
- row.id = delta.id;
279
- }
280
- if (delta.displayAttributes.unstable) {
281
- row.classList.add('unstable');
282
- }
283
- row.innerHTML = `<td>${delta.displayAttributes.label}</td><td>${cAngle(delta.value)}</td><td>${(delta.variance !== undefined && delta.variance !== null ? Number(delta.variance).toPrecision(2) : '')}</td>`;
284
- table.appendChild(row);
285
- });
286
-
287
- stepsList.appendChild(table);
288
- }
289
-
290
- function updateAttitude(data) {
291
- const attitudeContainer = document.getElementById('attitude-container');
292
- attitudeContainer.innerHTML = '';
293
- const table = document.createElement('table');
294
- table.classList.add('attitude');
295
-
296
- const headerRow = document.createElement('tr');
297
- headerRow.innerHTML = '<th>Label</th><th>roll</th><th>pitch</th><th>yaw</th>';
298
- table.appendChild(headerRow);
299
-
300
-
301
- data.attitudes.forEach(attitude => {
302
- const row = document.createElement('tr');
303
- if (attitude.id) {
304
- row.id = attitude.id;
305
- }
306
- if (attitude.displayAttributes.unstable) {
307
- row.classList.add('unstable');
308
- }
309
- row.innerHTML = `<td>${attitude.displayAttributes.label}</td><td>${cAngle(attitude.value.roll)}</td><td>${cAngle(attitude.value.pitch)}</td><td>${cAngle(attitude.value.yaw)}</td>`;
310
- table.appendChild(row);
311
- });
312
- attitudeContainer.appendChild(table);
313
- }
314
-
315
- function updateTable(data) {
316
- const tableContainer = document.getElementById('table-container');
317
- tableContainer.innerHTML = '';
318
- data.tables.forEach(table => {
319
- const tableElement = tableRenderer.render(table);
320
- tableContainer.appendChild(tableElement);
321
- });
322
- }
323
-
324
-
325
-
326
-
327
- async function fetchAndUpdateData() {
328
- const data = await getFromServer('getResults'); // Updated endpoint
329
- if (data) {
330
- //console.log(data);
331
- //updateOptions(data);
332
- updatePolar(data);
333
- updateAttitude(data);
334
- updateDelta(data);
335
- updateTable(data);
336
- }
337
- }
338
-
339
- function startUpdates() {
340
- if (updateTimer) clearInterval(updateTimer);
341
- updateTimer = setInterval(fetchAndUpdateData, updateInterval);
342
- }
343
-
344
- export function toggleUpdates() {
345
- updatesPaused = !updatesPaused;
346
- const toggleButton = document.getElementById('toggle-updates');
347
-
348
- if (updatesPaused) {
349
- clearInterval(updateTimer);
350
- toggleButton.textContent = "Resume";
351
- } else {
352
- toggleButton.textContent = "Pause";
353
- startUpdates();
354
- }
355
- }
356
-
357
-
358
-
359
-
360
-
361
- //document.getElementById('toggle-updates').addEventListener('click', toggleUpdates);
362
-
363
- const tableRenderer = new TableRenderer();
364
- tableRenderer.setColumnHeaderFormat(cAngle);
365
- tableRenderer.setRowHeaderFormat(cSpeed);
366
- // Default cell format is unified and driven by metrics; set to polar-like by default
367
- tableRenderer.setCellFormat(unifiedCellRenderer);
368
-
369
- function initSettingsFromStorage() {
370
- try {
371
- const speedUnit = localStorage.getItem(LS_KEYS.speed) || 'knots';
372
- const angleUnit = localStorage.getItem(LS_KEYS.angle) || 'degrees';
373
- const colorMode = localStorage.getItem(LS_KEYS.color) || 'none';
374
- const metricsRaw = localStorage.getItem(LS_KEYS.metrics);
375
-
376
- const speedEl = document.getElementById('speed-unit');
377
- const angleEl = document.getElementById('angle-unit');
378
- const colorEl = document.getElementById('color-mode');
379
- const metricsEls = {
380
- correction: document.getElementById('show-correction'),
381
- factor: document.getElementById('show-factor'),
382
- leeway: document.getElementById('show-leeway'),
383
- trace: document.getElementById('show-trace'),
384
- N: document.getElementById('show-N'),
385
- };
386
- if (speedEl) speedEl.value = speedUnit;
387
- if (angleEl) angleEl.value = angleUnit;
388
- if (colorEl) colorEl.value = colorMode;
389
- if (metricsRaw) {
390
- try {
391
- const parsed = JSON.parse(metricsRaw);
392
- selectedMetrics = { ...selectedMetrics, ...parsed };
393
- } catch {}
394
- }
395
- // Initialize checkboxes to match selectedMetrics
396
- if (metricsEls.correction) metricsEls.correction.checked = !!selectedMetrics.correction;
397
- if (metricsEls.factor) metricsEls.factor.checked = !!selectedMetrics.factor;
398
- if (metricsEls.leeway) metricsEls.leeway.checked = !!selectedMetrics.leeway;
399
- if (metricsEls.trace) metricsEls.trace.checked = !!selectedMetrics.trace;
400
- if (metricsEls.N) metricsEls.N.checked = !!selectedMetrics.N;
401
-
402
- // Apply handlers (order: units, then renderer-dependent)
403
- handleSpeedUnitChange(speedUnit);
404
- handleAngleUnitChange(angleUnit);
405
- handleColorModeChange(colorMode);
406
- } catch (e) {
407
- // Fallback to defaults on any error
408
- handleSpeedUnitChange('knots');
409
- handleAngleUnitChange('degrees');
410
- handleColorModeChange('none');
411
- }
412
- }
413
-
414
- export function handleMetricsChange() {
415
- const metricsEls = {
416
- correction: document.getElementById('show-correction'),
417
- factor: document.getElementById('show-factor'),
418
- leeway: document.getElementById('show-leeway'),
419
- trace: document.getElementById('show-trace'),
420
- N: document.getElementById('show-N'),
421
- };
422
- selectedMetrics = {
423
- correction: metricsEls.correction ? !!metricsEls.correction.checked : selectedMetrics.correction,
424
- factor: metricsEls.factor ? !!metricsEls.factor.checked : selectedMetrics.factor,
425
- leeway: metricsEls.leeway ? !!metricsEls.leeway.checked : selectedMetrics.leeway,
426
- trace: metricsEls.trace ? !!metricsEls.trace.checked : selectedMetrics.trace,
427
- N: metricsEls.N ? !!metricsEls.N.checked : selectedMetrics.N,
428
- };
429
- try {
430
- localStorage.setItem(LS_KEYS.metrics, JSON.stringify(selectedMetrics));
431
- } catch {}
432
- // Re-render with current data (trigger fetch or reuse last render by forcing a refresh)
433
- fetchAndUpdateData();
434
- }
435
-
436
-
437
- // Attach functions to the window object to make them globally accessible
438
- window.handleSpeedUnitChange = handleSpeedUnitChange;
439
- window.handleAngleUnitChange = handleAngleUnitChange;
440
- window.handleColorModeChange = handleColorModeChange;
441
- window.handleMetricsChange = handleMetricsChange;
442
- window.toggleUpdates = toggleUpdates;
443
-
444
-
445
- // Initial fetch and start updates
446
- initSettingsFromStorage();
447
- startUpdates();
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
+ });