speedandcurrent 2.3.3 → 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/public/app.js CHANGED
@@ -97,7 +97,6 @@ function normaliseState(data) {
97
97
 
98
98
  // ─── Static meta (fetched once at startup from /api/meta) ─────────────────────
99
99
  let metaById = {}; // keyed by item id
100
- let lifecycleWarnings = [];
101
100
 
102
101
  async function loadMeta() {
103
102
  const data = await apiGet('/api/meta');
@@ -117,90 +116,99 @@ async function loadMeta() {
117
116
  applyUnitLabels();
118
117
  }
119
118
 
120
- function isStale(item) {
121
- if (!item) return true;
122
- return item.state?.isStale === true;
123
- }
124
-
125
- function isNotReady(item) {
126
- if (!item) return true;
127
- return item.state?.ready !== true;
128
- }
129
-
130
- // Returns a human-readable reason why an item is not ready.
131
- // Handles three state shapes:
132
- // handler-nested (MessageSmoother, SmoothedAngle): state.handler.*
133
- // magnitude/angle (PolarSmoother): state.magnitude.* / state.angle.*
134
- // flat top-level (MessageHandler, Polar): state.*
135
- function getNotReadyReason(item) {
136
- if (!item) return 'not available';
137
- const s = item.state;
138
- if (!s) return 'not available';
119
+ const computedItemIds = new Set([
120
+ 'current.smoothed', 'boatSpeedRefGround', 'speedCorrection',
121
+ 'correctedBoatSpeed', 'residual', 'residual.smoothed'
122
+ ]);
139
123
 
140
- let subscribed, pathKnown;
141
- if (s.handler !== undefined) {
142
- subscribed = s.handler.subscribed;
143
- pathKnown = s.handler.pathKnown;
144
- } else if (s.magnitude !== undefined || s.angle !== undefined) {
145
- const mag = s.magnitude, ang = s.angle;
146
- subscribed = (mag?.subscribed !== false) && (ang?.subscribed !== false);
147
- pathKnown = (mag?.pathKnown !== false) && (ang?.pathKnown !== false);
148
- } else {
149
- subscribed = s.subscribed;
150
- pathKnown = s.pathKnown;
124
+ function getPathStatus(pathState, hasValue, computed = false, computedReady = false) {
125
+ if (computed) {
126
+ return hasValue && computedReady
127
+ ? { category: 'computed', reason: '' }
128
+ : { category: 'inactive', reason: 'not computed' };
129
+ }
130
+ if (pathState?.subscribed === false) {
131
+ return { category: 'inactive', reason: 'not subscribed' };
132
+ }
133
+ if (hasValue && pathState?.ready === true) {
134
+ return { category: 'subscribed', reason: '' };
135
+ }
136
+ if (!pathState) {
137
+ return { category: 'problem', reason: 'not available' };
138
+ }
139
+ if (pathState.pathKnown === false) {
140
+ return { category: 'problem', reason: 'path not found in Signal K' };
141
+ }
142
+ if (pathState.hasDelta === false) {
143
+ return { category: 'problem', reason: 'waiting for first data' };
144
+ }
145
+ if (pathState.isStale === true) {
146
+ return { category: 'problem', reason: 'data is stale' };
147
+ }
148
+ return { category: 'problem', reason: 'not available' };
149
+ }
150
+
151
+ function getScalarPathState(state) {
152
+ if (!state?.handler) return state;
153
+ return {
154
+ ...state,
155
+ subscribed: state.handler.subscribed,
156
+ pathKnown: state.handler.pathKnown,
157
+ hasDelta: state.hasDelta === true && state.handler.hasDelta === true,
158
+ isStale: state.isStale === true || state.handler.isStale === true,
159
+ ready: state.ready === true && state.handler.ready === true
160
+ };
161
+ }
162
+
163
+ function getItemStatuses(item) {
164
+ const label = itemLabel(item);
165
+ const computed = computedItemIds.has(item.id);
166
+ const computedReady = item.state?.ready === true;
167
+ if (state.polarsById[item.id] === item) {
168
+ return [
169
+ {
170
+ label: `${label} magnitude`,
171
+ status: getPathStatus(item.state?.magnitude, Number.isFinite(item.magnitude), computed, computedReady)
172
+ },
173
+ {
174
+ label: `${label} angle`,
175
+ status: getPathStatus(
176
+ item.state?.angle,
177
+ Number.isFinite(item.angle),
178
+ computed || item.state?.angleFallbackActive === true,
179
+ computedReady
180
+ )
181
+ }
182
+ ];
151
183
  }
152
184
 
153
- if (subscribed === false) return 'not subscribed to Signal K';
154
- if (pathKnown === false) return 'path not found in Signal K';
155
- if (!s.hasDelta) return 'waiting for first data';
156
- if (s.isStale) return 'data is stale';
157
- return 'not available';
185
+ const pathState = getScalarPathState(item.state);
186
+ const hasValue = state.attitudesById[item.id] === item
187
+ ? Number.isFinite(item.value?.roll) || Number.isFinite(item.value?.pitch)
188
+ : Number.isFinite(item.value);
189
+ return [{ label, status: getPathStatus(pathState, hasValue, computed, computedReady) }];
158
190
  }
159
191
 
160
- function getAnyItem(id) {
161
- return state.polarsById[id] || state.deltasById[id] || state.attitudesById[id] || null;
192
+ function isAvailableStatus(status) {
193
+ return status.category === 'subscribed' || status.category === 'computed';
162
194
  }
163
195
 
164
- function renderWarnings(elId, ids) {
196
+ function renderWarnings(elId, items) {
165
197
  const el = document.getElementById(elId);
166
198
  if (!el) return;
167
199
  el.innerHTML = '';
168
- const notReady = ids.filter(id => isNotReady(getAnyItem(id)));
169
- if (notReady.length === 0) return;
200
+ const problems = items.flatMap(item => getItemStatuses(item))
201
+ .filter(({ status }) => status.category === 'problem');
202
+ if (problems.length === 0) return;
170
203
  const h = document.createElement('h6');
171
204
  h.className = 'text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small';
172
205
  h.textContent = 'Warnings';
173
206
  el.appendChild(h);
174
207
  const ul = document.createElement('ul');
175
208
  ul.className = 'list-unstyled text-danger small ps-3';
176
- notReady.forEach(id => {
177
- const item = getAnyItem(id);
178
- const label = metaById[id]?.displayName ?? id;
209
+ problems.forEach(({ label, status }) => {
179
210
  const li = document.createElement('li');
180
- li.textContent = `"${label}" — ${getNotReadyReason(item)}`;
181
- ul.appendChild(li);
182
- });
183
- el.appendChild(ul);
184
- }
185
-
186
- function renderInputWarnings(elId, ids) {
187
- const el = document.getElementById(elId);
188
- if (!el) return;
189
- el.innerHTML = '';
190
- const lines = [];
191
- lifecycleWarnings.forEach(w => {
192
- if (w && typeof w.message === 'string') lines.push(w.message);
193
- });
194
- if (lines.length === 0) return;
195
- const h = document.createElement('h6');
196
- h.className = 'text-uppercase fw-bold text-muted border-bottom pb-1 mt-3 mb-1 small';
197
- h.textContent = 'Warnings';
198
- el.appendChild(h);
199
- const ul = document.createElement('ul');
200
- ul.className = 'list-unstyled text-danger small ps-3';
201
- lines.forEach(line => {
202
- const li = document.createElement('li');
203
- li.textContent = line;
211
+ li.textContent = `"${label}" — ${status.reason}`;
204
212
  ul.appendChild(li);
205
213
  });
206
214
  el.appendChild(ul);
@@ -227,7 +235,12 @@ async function apiGet(path) {
227
235
  const ct = res.headers.get('content-type') || '';
228
236
  if (!ct.includes('application/json')) { showMessage('Unexpected server response.'); return null; }
229
237
  showMessage('');
230
- return res.json();
238
+ try {
239
+ return await res.json();
240
+ } catch (err) {
241
+ showMessage('Unexpected server response.');
242
+ return null;
243
+ }
231
244
  }
232
245
 
233
246
  // PUT /api/settings: server returns full merged config — store it and re-render.
@@ -289,46 +302,21 @@ function _refreshMessage() {
289
302
  const paramMeta = {
290
303
  estimateBoatSpeed: { label: 'Estimate boat speed', type: 'boolean' },
291
304
  updateCorrectionTable: { label: 'Update correction table', type: 'boolean' },
292
- 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.' },
293
- 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.' },
294
306
  sogFallback: { label: 'Groundspeed fallback', type: 'boolean', description: 'Output Groundspeed as Boatspeed when the paddlewheel sensor is malfunctioning or stalled.' },
295
- 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.' },
296
308
  showStatistics: { label: 'Show statistics (σ)', type: 'boolean', description: 'Display standard deviation alongside smoothed values for debugging.' },
297
- smootherClass: {
298
- label: 'Smoother type', type: 'select',
299
- description: 'Smoothing applied to all sensor inputs for learning only.',
300
- options: [
301
- { value: 'MovingAverageSmoother', label: 'Moving average (window)' },
302
- { value: 'ExponentialSmoother', label: 'Exponential (τ)' },
303
- { value: 'KalmanSmoother', label: 'Kalman filter' },
304
- ]
305
- },
306
- smootherTau: {
307
- label: 'Time constant (τ)', type: 'number', unit: 's',
308
- min: 1, max: 60, step: 0.5, default: 3,
309
- description: 'Time constant in seconds.'
310
- },
311
309
  smootherTimeSpan: {
312
310
  label: 'Window size', type: 'number', unit: 's',
313
- min: 2, max: 60, step: 0.5, default: 5,
314
- description: 'Moving-average window in seconds.'
315
- },
316
- smootherSteadyState: {
317
- label: 'Kalman gain', type: 'number',
318
- min: 0.01, max: 0.99, step: 0.01, default: 0.2,
319
- 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.'
320
313
  },
321
314
  };
322
315
 
323
316
  // Settings groups for each UI section
324
317
  const ESTIMATION_SETTING_KEYS = ['sogFallback'];
325
- const LEARNING_SETTING_KEYS = ['stability', 'suspendLearningOnNavigationState', 'assumeCurrent', 'showStatistics'];
326
- const SMOOTHER_SETTING_KEYS = [
327
- 'smootherClass',
328
- { key: 'smootherTau', showIf: cfg => cfg.smootherClass === 'ExponentialSmoother' },
329
- { key: 'smootherTimeSpan', showIf: cfg => (cfg.smootherClass || 'MovingAverageSmoother') === 'MovingAverageSmoother' },
330
- { key: 'smootherSteadyState', showIf: cfg => cfg.smootherClass === 'KalmanSmoother' },
331
- ];
318
+ const LEARNING_SETTING_KEYS = ['correctionDriftRate', 'suspendLearningOnNavigationState', 'showStatistics'];
319
+ const SMOOTHER_SETTING_KEYS = ['smootherTimeSpan'];
332
320
 
333
321
  // Build one settings control for a key.
334
322
  function createSettingControl(key, meta, value) {
@@ -447,18 +435,24 @@ function renderSettingsPanel() {
447
435
 
448
436
  function formatPolarValue(p) {
449
437
  if (!p) return '—';
438
+ const statuses = getItemStatuses(p);
450
439
  const m = metaById[p.id];
451
440
  const speedC = buildConverter(m?.magnitude?.displayUnits) || DEFAULTS.speed;
452
441
  const angleC = buildConverter(m?.angle?.displayUnits) || DEFAULTS.angle;
453
- const spd = typeof p.magnitude === 'number' ? speedC.convert(p.magnitude).toFixed(speedC.decimals) : '—';
454
- const ang = typeof p.angle === 'number' ? angleC.convert(p.angle).toFixed(angleC.decimals) : '—';
455
- const sigma = (config?.showStatistics && p.id.endsWith('.smoothed') && typeof p.trace === 'number')
442
+ const spd = isAvailableStatus(statuses[0].status)
443
+ ? `${speedC.convert(p.magnitude).toFixed(speedC.decimals)} ${speedC.symbol}`
444
+ : statuses[0].status.reason;
445
+ const ang = isAvailableStatus(statuses[1].status)
446
+ ? `${angleC.convert(p.angle).toFixed(angleC.decimals)}${angleC.symbol}`
447
+ : statuses[1].status.reason;
448
+ const sigma = (isAvailableStatus(statuses[0].status) && config?.showStatistics && p.id.endsWith('.smoothed') && typeof p.trace === 'number')
456
449
  ? ` (σ=${speedC.convert(Math.sqrt(p.trace)).toFixed(speedC.decimals + 2)})` : '';
457
- return `${spd} ${speedC.symbol} / ${ang}${angleC.symbol}${sigma}`;
450
+ return `${spd}${sigma} / ${ang}`;
458
451
  }
459
452
 
460
453
  function formatDeltaValue(d) {
461
- if (!d || typeof d.value !== 'number') return '—';
454
+ const status = getItemStatuses(d)[0].status;
455
+ if (!isAvailableStatus(status)) return status.reason;
462
456
  const m = metaById[d.id];
463
457
  const uc = buildConverter(m?.displayUnits)
464
458
  || (m?.units === 'm/s' ? DEFAULTS.speed : DEFAULTS.angle);
@@ -468,6 +462,8 @@ function formatDeltaValue(d) {
468
462
  }
469
463
 
470
464
  function formatAttitudeValue(a) {
465
+ const status = getItemStatuses(a)[0].status;
466
+ if (!isAvailableStatus(status)) return status.reason;
471
467
  const v = (a && a.value) || {};
472
468
  const variance = a && a.variance;
473
469
  const m = metaById[a?.id];
@@ -484,13 +480,21 @@ function itemLabel(item) {
484
480
  return metaById[item.id]?.displayName ?? item.path ?? item.id;
485
481
  }
486
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
+
487
492
  function buildDataTable(rows) {
488
493
  const tbl = document.createElement('table');
489
494
  tbl.className = 'table table-sm table-borderless mb-0';
490
495
  const tbody = document.createElement('tbody');
491
496
  rows.forEach(row => {
492
497
  const tr = document.createElement('tr');
493
- if (row.stale) tr.className = 'stale';
494
498
  const tdL = document.createElement('td'); tdL.textContent = row.label;
495
499
  const tdV = document.createElement('td'); tdV.textContent = row.value;
496
500
  tr.appendChild(tdL); tr.appendChild(tdV);
@@ -513,18 +517,46 @@ function filterById(arr, ids) {
513
517
  });
514
518
  }
515
519
 
516
- function renderGroupInto(elId, polars, deltas, attitudes) {
520
+ function renderGroupInto(elId, polars, deltas, attitudes, extraRows = []) {
517
521
  const el = document.getElementById(elId);
518
522
  if (!el) return;
519
523
  el.innerHTML = '';
520
524
  const rows = [
521
- ...polars .map(p => ({ label: itemLabel(p), value: formatPolarValue(p), stale: isNotReady(p) })),
522
- ...deltas .map(d => ({ label: itemLabel(d), value: formatDeltaValue(d), stale: isNotReady(d) })),
523
- ...attitudes.map(a => ({ label: itemLabel(a), value: formatAttitudeValue(a), stale: isNotReady(a) }))
525
+ ...polars .map(p => ({ label: itemLabel(p), value: formatPolarValue(p) })),
526
+ ...deltas .map(d => ({ label: itemLabel(d), value: formatDeltaValue(d) })),
527
+ ...attitudes.map(a => ({ label: itemLabel(a), value: formatAttitudeValue(a) })),
528
+ ...extraRows
524
529
  ];
525
530
  if (rows.length) el.appendChild(buildDataTable(rows));
526
531
  }
527
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
+
528
560
  function renderLiveSections() {
529
561
  const learningState = state.learningState || null;
530
562
  const inputPolars = filterById(state.polarsAll, ['groundSpeed']);
@@ -533,50 +565,57 @@ function renderLiveSections() {
533
565
  const fallbackInputPolars = inputPolars.length ? inputPolars : filterById(state.polarsAll, ['groundSpeed.smoothed']);
534
566
  const fallbackInputDeltas = inputDeltas.length ? inputDeltas : filterById(state.deltasAll, ['heading.smoothed', 'boatSpeed.smoothed']);
535
567
  const fallbackInputAttitudes = inputAttitudes.length ? inputAttitudes : filterById(state.attitudesAll, ['attitude.smoothed']);
568
+ const displayedInputs = [...fallbackInputPolars, ...fallbackInputDeltas, ...fallbackInputAttitudes];
536
569
 
537
570
  // Inputs section — raw sensor readings only (smoothing is internal to the plugin)
571
+ const navigationStateRows = [{ label: 'navigation.state', value: formatNavigationStateValue(learningState?.navigationState) }];
538
572
  renderGroupInto('inputs-values',
539
573
  fallbackInputPolars,
540
574
  fallbackInputDeltas,
541
- fallbackInputAttitudes
575
+ fallbackInputAttitudes,
576
+ navigationStateRows
542
577
  );
543
- const inputWarningIds = ['boatSpeed', 'attitude', 'heading.angle', 'groundSpeed', 'boatSpeed.smoothed', 'groundSpeed.smoothed', 'heading.smoothed', 'attitude.smoothed'];
544
- if (config && config.assumeCurrent) inputWarningIds.push('current.smoothed');
545
- renderInputWarnings('inputs-warnings', inputWarningIds);
578
+ renderWarnings('inputs-warnings', displayedInputs);
546
579
 
547
580
  // Estimation — inputs (raw sensor data used for boat speed estimation)
548
- renderGroupInto('estimation-inputs',
549
- filterById(state.polarsAll, ['groundSpeed']),
550
- filterById(state.deltasAll, ['heading.angle', 'boatSpeed']),
551
- filterById(state.attitudesAll, ['attitude'])
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']),
588
+ ...filterById(state.attitudesAll, ['attitude'])
552
589
  );
590
+ renderItemsInto('estimation-inputs', estimationInputs);
553
591
  // Estimation — intermediates
554
- renderGroupInto('estimation-intermediates',
555
- filterById(state.polarsAll, ['boatSpeedRefGround', 'speedCorrection', 'residual', 'residual.smoothed']),
556
- [], []
557
- );
592
+ const estimationIntermediates = filterById(state.polarsAll, ['boatSpeedRefGround', 'speedCorrection', 'residual', 'residual.smoothed']);
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);
558
600
  // Estimation — outputs
559
- renderGroupInto('estimation-outputs',
560
- filterById(state.polarsAll, ['correctedBoatSpeed', 'current.smoothed']),
561
- [], []
562
- );
601
+ const estimationOutputs = filterById(state.polarsAll, ['correctedBoatSpeed', 'current.smoothed']);
602
+ renderGroupInto('estimation-outputs', estimationOutputs, [], []);
563
603
  // Estimation — warnings
564
- const estimationWarnings = document.getElementById('estimation-warnings');
565
- if (estimationWarnings) estimationWarnings.innerHTML = '';
604
+ renderWarnings('estimation-warnings', [...estimationInputs, ...estimationIntermediates, ...estimationOutputs]);
566
605
 
567
- // Learning — inputs: smoothed sensors + current if assumeCurrent
568
- const learningCurrentPolars = (config && config.assumeCurrent)
569
- ? filterById(state.polarsAll, ['current.smoothed'])
570
- : [];
606
+ // Learning — inputs
607
+ const learningPolars = filterById(state.polarsAll, ['groundSpeed.smoothed']);
608
+ const learningDeltas = filterById(state.deltasAll, ['heading.smoothed', 'boatSpeed.smoothed']);
609
+ const learningAttitudes = filterById(state.attitudesAll, ['attitude.smoothed']);
571
610
  renderGroupInto('learning-inputs',
572
- [...filterById(state.polarsAll, ['groundSpeed.smoothed']), ...learningCurrentPolars],
573
- filterById(state.deltasAll, ['heading.smoothed', 'boatSpeed.smoothed']),
574
- filterById(state.attitudesAll, ['attitude.smoothed'])
611
+ learningPolars,
612
+ learningDeltas,
613
+ learningAttitudes
575
614
  );
576
615
  // Learning — warnings
577
616
  const learningWarnings = document.getElementById('learning-warnings');
617
+ renderWarnings('learning-warnings', [...learningPolars, ...learningDeltas, ...learningAttitudes]);
578
618
  if (learningWarnings) {
579
- learningWarnings.innerHTML = '';
580
619
  const navState = learningState?.navigationState;
581
620
  if (navState?.enabled && navState.pathKnown === false) {
582
621
  const ul = document.createElement('ul');
@@ -607,7 +646,7 @@ function renderLiveSections() {
607
646
  startup: 'Startup stabilising window',
608
647
  observation_reset: 'Recent rejected or invalid observation',
609
648
  nav_state_change: 'navigation.state changed',
610
- nav_state: 'Blocked by navigation.state',
649
+ not_moving: 'Vessel not moving',
611
650
  cog_override: 'Blocked by COG override',
612
651
  accepted: 'Observation recorded',
613
652
  estimator_outlier: 'Estimator rejected observation',
@@ -615,7 +654,7 @@ function renderLiveSections() {
615
654
  missing_current_when_required: 'Current estimate unavailable',
616
655
  learning_off: 'Learning is off',
617
656
  stabilizing: 'Stabilising window active',
618
- nav_state_blocked: 'Learning blocked by navigation.state',
657
+ not_moving_blocked: 'Learning blocked \u2014 vessel not moving',
619
658
  cog_override_active: 'Learning blocked by COG override',
620
659
  stw_below_threshold: 'Below minimum STW for learning',
621
660
  sog_below_threshold: 'Below minimum SOG for learning',
@@ -625,10 +664,11 @@ function renderLiveSections() {
625
664
  const reason = reasonTextMap[learningState?.observationReason]
626
665
  || reasonTextMap[learningState?.reason]
627
666
  || '\u2014';
667
+ const showReason = learningState?.observationState && learningState.observationState !== 'accepted';
628
668
  statusTbody.innerHTML =
629
669
  `<tr><td class="text-muted small">Learning</td><td class="small">${learningText}</td></tr>` +
630
670
  `<tr><td class="text-muted small">Observation</td><td class="small">${obsText}</td></tr>` +
631
- `<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>` : '');
632
672
  }
633
673
  // Correction table
634
674
  const tableEl = document.getElementById('table-container');
@@ -642,46 +682,70 @@ function renderLiveSections() {
642
682
  speedSymbol: speedC.symbol,
643
683
  heelSymbol: angleC.symbol,
644
684
  };
645
- 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
+ });
646
697
  }
647
698
  }
648
699
 
649
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;
650
705
  let updateTimer = null;
651
706
  let lastTickOk = false;
652
707
 
653
708
  async function tick() {
654
- const data = await apiGet('/api/report');
655
- if (data) {
656
- // Recovered from an error — reload meta and config so unit converters,
657
- // source lists and settings reflect the (possibly restarted) plugin state.
658
- if (!lastTickOk) {
659
- await loadMeta();
660
- config = await apiGet('/api/settings');
661
- if (config && config.tableName) setTableName(config.tableName);
662
- 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;
663
730
  }
664
- lastTickOk = true;
665
- normaliseState(data);
666
- state.learningState = data.learningState || null;
667
- renderLiveSections();
668
- } else {
669
- lastTickOk = false;
670
- 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();
671
739
  }
672
- // Always poll status separately so the message reflects plugin state
673
- // even when /api/report fails (plugin stopped/restarting).
674
- const statusData = await fetch(`${API_BASE}/api/status`, { credentials: 'same-origin' })
675
- .then(r => r.ok ? r.json() : null)
676
- .catch(() => null);
677
- _pluginStatus = statusData?.status ?? '';
678
- lifecycleWarnings = Array.isArray(statusData?.lifecycleWarnings) ? statusData.lifecycleWarnings : [];
679
- _refreshMessage();
680
740
  }
681
741
 
682
742
  function startUpdates() {
683
- if (updateTimer) clearInterval(updateTimer);
684
- updateTimer = setInterval(tick, 1000);
743
+ if (updateTimer) clearTimeout(updateTimer);
744
+ const poll = async () => {
745
+ await tick();
746
+ updateTimer = setTimeout(poll, pollIntervalMs);
747
+ };
748
+ poll();
685
749
  }
686
750
 
687
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; }