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