@sailingrotevista/rotevista-dash 4.0.19 → 4.0.20

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.
Files changed (3) hide show
  1. package/app.js +214 -59
  2. package/package.json +1 -1
  3. package/style.css +6 -0
package/app.js CHANGED
@@ -17,7 +17,7 @@ let CONFIG = {
17
17
  smoothWindow: 2000,
18
18
  longWindow: 30000,
19
19
  stabilityTolerance: 2000,
20
- stabilityThreshold: 0.85,
20
+ stabilityThreshold: 0.9,
21
21
  minSpeed: 0,
22
22
  stabilityBreakout: 15
23
23
  },
@@ -35,6 +35,8 @@ const RENDER_INTERVAL_MS = 1000;
35
35
  const TIMEOUT_MS = 5000;
36
36
  const SIM_SAMPLE_INTERVAL = 1000;
37
37
  const DASH_VERSION = "2.4"; // Versione per la gestione della memoria locale
38
+ const sourceLocks = {};
39
+
38
40
 
39
41
  // ==========================================================================
40
42
  // 2. STATO GLOBALE E RIFERIMENTI UI
@@ -53,6 +55,10 @@ let twDirty = false, isNavigating = false, reconnectDelay = 1000;
53
55
 
54
56
  let pressTimer, isFocusActive = false;
55
57
 
58
+ let emaTwaSin = 0;
59
+ let emaTwaCos = 0;
60
+ let firstEmaRun = true;
61
+
56
62
  // Stato dei singoli grafici (Standard vs Hercules Zoom)
57
63
  const graphModes = {
58
64
  stw: 'standard',
@@ -110,39 +116,87 @@ function getShortestRotation(curr, target) {
110
116
  /**
111
117
  * Inserisce un dato nel buffer circolare limitandolo a 2000 campioni (30 min)
112
118
  */
113
- function safePush(buffer, val, time, maxLen = 2000) {
114
- buffer.push({ val: val, time: time });
115
- if (buffer.length > maxLen) { buffer.shift(); }
116
- }
117
-
118
119
  /**
119
- * Media Circolare Vettoriale: Calcola angolo medio, stabilità R e deviazione standard (±)
120
+ * Inserimento sicuro nel buffer con trigonometria precaricata e pruning automatico
120
121
  */
121
- function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false) {
122
- const now = Date.now();
123
- const validData = bufferArray.filter(item => (now - item.time) <= windowMs);
124
- if (validData.length === 0) return null;
125
-
126
- let sSin = 0, sCos = 0;
127
- validData.forEach(item => {
128
- sSin += Math.sin(item.val);
129
- sCos += Math.cos(item.val);
122
+ function safePush(buffer, val, time) {
123
+ buffer.push({
124
+ val: val,
125
+ time: time,
126
+ sin: Math.sin(val),
127
+ cos: Math.cos(val)
130
128
  });
131
-
132
- let R = Math.sqrt(sSin * sSin + sCos * sCos) / validData.length;
133
- let historyDuration = (validData.length > 2) ? (validData[validData.length - 1].time - validData[0].time) : 0;
134
-
135
- // Un dato è stabile se abbiamo abbastanza storia e la coerenza vettoriale R è alta
136
- let isStable = (historyDuration > 10000) && (R > CONFIG.averaging.stabilityThreshold);
137
- let avgRad = Math.atan2(sSin, sCos);
138
129
 
139
- // Calcolo della Deviazione Standard Circolare (±) in gradi
140
- let deviation = (R < 1 && R > 0) ? Math.round(Math.sqrt(-2 * Math.log(R)) * (180 / Math.PI)) : 0;
130
+ // Teniamo sempre in memoria il DOPPIO della storia impostata (per la modalità ancoraggio)
131
+ // + 1 minuto di margine
132
+ const maxHistoryMs = (CONFIG.graphs.historyMinutes * 60000 * 2) + 60000;
133
+
134
+ while (buffer.length > 0 && (time - buffer[0].time) > maxHistoryMs) {
135
+ buffer.shift();
136
+ }
137
+
138
+ // Tetto massimo di campioni per sicurezza (circa 2 ore a 5Hz)
139
+ if (buffer.length > 36000) buffer.shift();
140
+ }
141
+
142
+ function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false, now) {
143
+ now = now || Date.now();
144
+
145
+ const len = bufferArray.length;
146
+ if (len === 0) return null;
147
+
148
+ let sSin = 0;
149
+ let sCos = 0;
150
+ let count = 0;
151
+
152
+ let newestTime = 0;
153
+ let oldestTime = 0;
154
+
155
+ for (let i = len - 1; i >= 0; i--) {
156
+ const item = bufferArray[i];
157
+
158
+ if ((now - item.time) > windowMs) break;
159
+
160
+ sSin += item.sin;
161
+ sCos += item.cos;
162
+
163
+ if (count === 0) newestTime = item.time;
164
+ oldestTime = item.time;
165
+
166
+ count++;
167
+ }
168
+
169
+ if (count === 0) return null;
170
+
171
+ const R = Math.hypot(sSin, sCos) / count;
172
+
173
+ const avgRad = Math.atan2(sSin, sCos);
174
+
175
+ const finalVal = signed
176
+ ? avgRad
177
+ : (avgRad + Math.PI * 2) % (Math.PI * 2);
178
+
179
+ const historyDuration =
180
+ (count > 2)
181
+ ? (newestTime - oldestTime)
182
+ : 0;
183
+
184
+ const isStable =
185
+ historyDuration > 10000 &&
186
+ R > CONFIG.averaging.stabilityThreshold;
187
+
188
+ const safeR = Math.max(R, 1e-9);
141
189
 
142
190
  return {
143
- val: signed ? avgRad : (avgRad + 2 * Math.PI) % (2 * Math.PI),
191
+ val: finalVal,
144
192
  stable: isStable,
145
- dev: deviation
193
+ dev: (R < 1)
194
+ ? Math.round(
195
+ Math.sqrt(-2 * Math.log(safeR)) *
196
+ (180 / Math.PI)
197
+ )
198
+ : 0,
199
+ samples: count
146
200
  };
147
201
  }
148
202
 
@@ -291,14 +345,58 @@ function computeTrueWind() {
291
345
  }
292
346
  }
293
347
 
294
- function processIncomingData(path, val) {
295
- const now = Date.now(); store.timestamps[path] = now; store.raw[path] = val;
296
- if (path === "environment.wind.angleApparent") { safePush(store.smoothBuf.awa, val, now); safePush(store.longBuf.awa, val, now); }
297
- const twPaths = ["environment.wind.speedApparent", "environment.wind.angleApparent", "navigation.speedThroughWater", "navigation.speedOverGround", "navigation.headingTrue", "navigation.courseOverGroundTrue"];
298
- if (twPaths.includes(path)) twDirty = true;
299
- if (twDirty && (now - lastTWCompute > 100)) { computeTrueWind(); lastTWCompute = now; twDirty = false; }
300
- if (path === "navigation.headingTrue") { safePush(store.smoothBuf.hdg, val, now); safePush(store.longBuf.hdg, val, now); }
301
- if (path === "navigation.courseOverGroundTrue") { safePush(store.smoothBuf.cog, val, now); safePush(store.longBuf.cog, val, now); }
348
+ function processIncomingData(path, val, source) {
349
+ const now = Date.now();
350
+
351
+ // --- 1. FILTRO SORGENTE STICKY (Elimina conflitti yacht_device vs Unknown) ---
352
+ if (!sourceLocks[path] || sourceLocks[path].label === source || (now - sourceLocks[path].lastSeen > 2000)) {
353
+ sourceLocks[path] = { label: source, lastSeen: now };
354
+ } else {
355
+ // Se arriva un dato da un'altra sorgente mentre il lock è attivo, lo scartiamo
356
+ return;
357
+ }
358
+
359
+ // --- 2. AGGIORNAMENTO DATI (Solo per la sorgente eletta) ---
360
+ store.timestamps[path] = now;
361
+ store.raw[path] = val;
362
+
363
+ // Buffer per AWA (Vento Apparente)
364
+ if (path === "environment.wind.angleApparent") {
365
+ safePush(store.smoothBuf.awa, val, now);
366
+ safePush(store.longBuf.awa, val, now);
367
+ }
368
+
369
+ // Buffer per HDG (Prua)
370
+ if (path === "navigation.headingTrue") {
371
+ safePush(store.smoothBuf.hdg, val, now);
372
+ safePush(store.longBuf.hdg, val, now);
373
+ }
374
+
375
+ // Buffer per COG (Rotta Fondo)
376
+ if (path === "navigation.courseOverGroundTrue") {
377
+ safePush(store.smoothBuf.cog, val, now);
378
+ safePush(store.longBuf.cog, val, now);
379
+ }
380
+
381
+ // --- 3. TRIGGER CALCOLO VENTO REALE (TWA/TWS/TWD) ---
382
+ const twPaths = [
383
+ "environment.wind.speedApparent",
384
+ "environment.wind.angleApparent",
385
+ "navigation.speedThroughWater",
386
+ "navigation.speedOverGround",
387
+ "navigation.headingTrue",
388
+ "navigation.courseOverGroundTrue"
389
+ ];
390
+
391
+ if (twPaths.includes(path)) {
392
+ twDirty = true;
393
+ // Calcolo limitato a 10Hz per non pesare sulla CPU
394
+ if (twDirty && (now - lastTWCompute > 100)) {
395
+ computeTrueWind();
396
+ lastTWCompute = now;
397
+ twDirty = false;
398
+ }
399
+ }
302
400
  }
303
401
 
304
402
  // ==========================================================================
@@ -312,8 +410,9 @@ function updateWindTrend() {
312
410
 
313
411
  // STRATEGIA (Bussola TWD): 1 min vs 30 minuti (tendenza meteo profonda)
314
412
  const twdNow = getCircularAverageFromBuffer(store.longBuf.twd, 60000, false);
315
- const twdRef = getCircularAverageFromBuffer(store.longBuf.twd, 1800000, false); // 1.800.000 ms = 30 min
316
-
413
+ const multiplier = isNavigating ? 1 : 2;
414
+ const strategicWindowMs = CONFIG.graphs.historyMinutes * 60000 * multiplier;
415
+ const twdRef = getCircularAverageFromBuffer(store.longBuf.twd, strategicWindowMs, false);
317
416
  if (!twaNow || !twaRef || !twdNow || !twdRef) return;
318
417
  const compassDots = { cw: document.getElementById('trend-dot-cw'), ccw: document.getElementById('trend-dot-ccw') };
319
418
  const gaugeDots = { cw: document.getElementById('trend-gauge-cw'), ccw: document.getElementById('trend-gauge-ccw') };
@@ -357,12 +456,41 @@ function updateWindTrend() {
357
456
  }
358
457
  } else { [gaugeDots.cw, gaugeDots.ccw].forEach(el => { if(el){ el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }}); }
359
458
 
360
- // ALLARME STRAMBATA (GYBE)
361
- const instTwa = radToDeg(store.raw["environment.wind.angleTrueWater"] || 0);
362
- if (Math.abs(instTwa) > 155 && Math.sign(instTwa) !== Math.sign(lastInstantTwa)) {
363
- if (isNavigating && (now - lastGybeAlarmTime > 5000)) { lastGybeAlarmTime = now; playGybeAlarm(); }
364
- }
365
- lastInstantTwa = instTwa;
459
+ // --- LOGICA ALLARME STRAMBATA (GYBE) FILTRATA ---
460
+ const instTwaRad = store.raw["environment.wind.angleTrueWater"];
461
+
462
+ if (instTwaRad !== undefined) {
463
+ // Calcolo Alfa basato sulla Steering Precision (più precisione = filtro più lento e stabile)
464
+ const dynamicAlpha = Math.max(0.05, 1.1 - CONFIG.averaging.stabilityThreshold);
465
+
466
+ const currentSin = Math.sin(instTwaRad);
467
+ const currentCos = Math.cos(instTwaRad);
468
+
469
+ if (firstEmaRun) {
470
+ emaTwaSin = currentSin;
471
+ emaTwaCos = currentCos;
472
+ firstEmaRun = false;
473
+ } else {
474
+ // Media Esponenziale Vettoriale
475
+ emaTwaSin = (currentSin * dynamicAlpha) + (emaTwaSin * (1 - dynamicAlpha));
476
+ emaTwaCos = (currentCos * dynamicAlpha) + (emaTwaCos * (1 - dynamicAlpha));
477
+ }
478
+
479
+ // Angolo risultante filtrato
480
+ const smoothedTwaDeg = radToDeg(Math.atan2(emaTwaSin, emaTwaCos));
481
+
482
+ // Verifichiamo il cambio di mure (> 155° e inversione di segno confermata dal filtro)
483
+ if (Math.abs(smoothedTwaDeg) > 155) {
484
+ if (Math.sign(smoothedTwaDeg) !== Math.sign(lastInstantTwa) && lastInstantTwa !== null) {
485
+ if (isNavigating && (now - lastGybeAlarmTime > 60000)) {
486
+ lastGybeAlarmTime = now;
487
+ playGybeAlarm();
488
+ console.log(`⚠️ GYBE ALARM: TWA ${smoothedTwaDeg.toFixed(1)}°`);
489
+ }
490
+ }
491
+ }
492
+ lastInstantTwa = smoothedTwaDeg;
493
+ }
366
494
  }
367
495
 
368
496
  // ==========================================================================
@@ -390,7 +518,7 @@ const upUI = (el, obj, instantRaw, isCompass = false) => {
390
518
  } else {
391
519
  let valDeg = Math.round(radToDeg(obj.val));
392
520
  let mainVal = (isCompass ? ((valDeg + 360) % 360).toString().padStart(3, '0') : valDeg) + "&deg;";
393
- let dev = (obj.dev > 1 && obj.dev < 90) ? `<span style="font-size: 0.35em; opacity: 0.5; margin-left: 4px; vertical-align: middle;">&plusmn;${obj.dev}</span>` : "";
521
+ let dev = (obj.dev > 1 && obj.dev < 90) ? `<span style="font-size: 0.8em; opacity: 0.4; margin-left: 6px;">&plusmn;${obj.dev}</span>` : "";
394
522
  el.innerHTML = mainVal + dev;
395
523
 
396
524
  let diff = Math.abs((radToDeg(instantRaw) - radToDeg(obj.val) + 540) % 360 - 180);
@@ -512,30 +640,39 @@ function startDisplayLoop() {
512
640
  upUI(ui.twaAvg, twObj, store.raw["environment.wind.angleTrueWater"], false);
513
641
  upUI(ui.twdAvg, twdObj, store.raw["environment.wind.directionTrue"], true);
514
642
 
515
- // --- LOGICA TACK STRATEGICA (Riflessione geometrica su TWD) ---
643
+ // --- LOGICA TACK STRATEGICA (VETTORIALE) ---
516
644
  if (hObj && twdObj) {
517
- const tH = radToDeg((2 * twdObj.val - hObj.val + Math.PI * 2) % (Math.PI * 2));
518
- const unstableH = !hObj.stable || !twdObj.stable || hObj.dev > CONFIG.averaging.stabilityBreakout || twdObj.dev > CONFIG.averaging.stabilityBreakout;
645
+ // Funzione interna per riflettere un angolo rispetto all'asse del vento (TWD)
646
+ const reflectAngle = (targetRad, axisRad) => {
647
+ const diffSin = Math.sin(axisRad - targetRad);
648
+ const diffCos = Math.cos(axisRad - targetRad);
649
+ return Math.atan2(Math.sin(axisRad) * diffCos + Math.cos(axisRad) * diffSin,
650
+ Math.cos(axisRad) * diffCos - Math.sin(axisRad) * diffSin);
651
+ };
652
+
653
+ const unstableH = !hObj.stable || !twdObj.stable || hObj.dev > CONFIG.averaging.stabilityBreakout;
519
654
 
520
655
  if (!isNavigating) {
521
- ui.tackHdg.innerHTML = "---&deg;"; ui.tackHdg.classList.remove('unstable-data');
656
+ ui.tackHdg.innerHTML = "---&deg;";
522
657
  } else if (unstableH) {
523
658
  ui.tackHdg.innerHTML = "---&deg;"; ui.tackHdg.classList.add('unstable-data');
524
659
  } else {
525
- ui.tackHdg.innerHTML = `${Math.round((tH + 360) % 360).toString().padStart(3, '0')}&deg;`;
660
+ const reflectedH = reflectAngle(hObj.val, twdObj.val);
661
+ const outH = (radToDeg(reflectedH) + 360) % 360;
662
+ ui.tackHdg.innerHTML = `${Math.round(outH).toString().padStart(3, '0')}&deg;`;
526
663
  ui.tackHdg.classList.remove('unstable-data');
527
664
  }
528
665
 
529
666
  if (cObj) {
530
- const tC = radToDeg((2 * twdObj.val - cObj.val + Math.PI * 2) % (Math.PI * 2));
531
- const unstableC = !cObj.stable || !twdObj.stable || cObj.dev > CONFIG.averaging.stabilityBreakout || twdObj.dev > CONFIG.averaging.stabilityBreakout;
532
-
667
+ const unstableC = !cObj.stable || !twdObj.stable || cObj.dev > CONFIG.averaging.stabilityBreakout;
533
668
  if (!isNavigating) {
534
- ui.tackCog.innerHTML = "---&deg;"; ui.tackCog.classList.remove('unstable-data');
669
+ ui.tackCog.innerHTML = "---&deg;";
535
670
  } else if (unstableC) {
536
671
  ui.tackCog.innerHTML = "---&deg;"; ui.tackCog.classList.add('unstable-data');
537
672
  } else {
538
- ui.tackCog.innerHTML = `${Math.round((tC + 360) % 360).toString().padStart(3, '0')}&deg;`;
673
+ const reflectedC = reflectAngle(cObj.val, twdObj.val);
674
+ const outC = (radToDeg(reflectedC) + 360) % 360;
675
+ ui.tackCog.innerHTML = `${Math.round(outC).toString().padStart(3, '0')}&deg;`;
539
676
  ui.tackCog.classList.remove('unstable-data');
540
677
  }
541
678
  }
@@ -691,10 +828,13 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
691
828
  style="stroke: ${color}; stroke-width: ${strokeWidth}; stroke-linecap: round; shape-rendering: geometricPrecision;" />`;
692
829
 
693
830
  if (i === 1) areaPath += `L ${x1} ${y1} `;
694
- areaPath += `L ${x2} ${y2} `;
695
- }
696
-
697
- areaPath += `L ${w} ${h} Z`;
831
+ areaPath += `L ${x2} ${y2} `;
832
+
833
+ // Salviamo l'ultima coordinata X calcolata per chiudere correttamente il path
834
+ if (i === d.length - 1) {
835
+ areaPath += `L ${x2} ${h} Z`;
836
+ }
837
+ }
698
838
 
699
839
  // 4. Iniezione del Gradiente
700
840
  const gradId = `grad-${id}`;
@@ -775,7 +915,22 @@ function connect() {
775
915
  try {
776
916
  socket = new WebSocket(`ws://${addr}/signalk/v1/stream?subscribe=self`);
777
917
  socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; reconnectDelay = 1000; };
778
- socket.onmessage = (e) => { const d = JSON.parse(e.data); if (d.updates) d.updates.forEach(u => u.values && u.values.forEach(v => processIncomingData(v.path, v.value))); };
918
+
919
+ socket.onmessage = (e) => {
920
+ const d = JSON.parse(e.data);
921
+ if (d.updates) {
922
+ d.updates.forEach(u => {
923
+ // 1. Estraiamo il nome del sensore/sorgente (es. "yacht_device" o "Unknown")
924
+ const sourceLabel = u.source ? (u.source.label || u.source.talker || "Unknown") : "Unknown";
925
+
926
+ // 2. Passiamo il nome della sorgente come TERZO parametro
927
+ if (u.values) {
928
+ u.values.forEach(v => processIncomingData(v.path, v.value, sourceLabel));
929
+ }
930
+ });
931
+ }
932
+ };
933
+
779
934
  socket.onclose = () => { if (!simulationMode) { ui.status.className = "offline"; ui.status.innerText = "RECONNECTING..."; setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 1.5, 10000); } };
780
935
  } catch (e) { setTimeout(connect, reconnectDelay); }
781
936
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sailingrotevista/rotevista-dash",
3
- "version": "4.0.19",
3
+ "version": "4.0.20",
4
4
  "description": "Public Wind Dashboard with navigation and course aids",
5
5
  "main": "index.js",
6
6
  "publishConfig": {
package/style.css CHANGED
@@ -122,6 +122,12 @@ body {
122
122
  .box-twd #twd-avg { font-size: clamp(1.4rem, 45cqh, 20cqw) !important; }
123
123
  }
124
124
 
125
+ /* --- OTTIMIZZAZIONE SPECIFICA TWD PER EVITARE ANDATA A CAPO in landscape--- */
126
+ .box-twd .value-large {
127
+ /* Riduciamo il limite di larghezza (18cqw) per far spazio al simbolo ± rimpicciolendo il testo se serve */
128
+ font-size: clamp(1.2rem, 55cqh, 18cqw) !important;
129
+ }
130
+
125
131
  /* --- BOX TACK (Mure Opposte) --- */
126
132
  .box-tack .dual-value-container { display: flex; flex-direction: row; justify-content: space-between; align-items: center; width: 100%; height: 100%; }
127
133
  .box-tack .dual-value-col { display: flex; flex-direction: column; justify-content: center; }