@sailingrotevista/rotevista-dash 6.2.7 → 7.0.1

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/app.js CHANGED
@@ -44,11 +44,9 @@ let simulationMode = false;
44
44
  let displayModeSog = 'SOG';
45
45
  let displayModeTws = 'TWS';
46
46
  let socket, renderInterval, simInterval;
47
- let lastAvgUIUpdate = 0, audioCtx = null, lastAlarmTime = 0;
48
- let curAwaRot = 0, curTwaRot = 0, curTrackRot = 0, curTwdRoseRot = 0;
49
- let curBoatCompassRot = 0, curWindCompassRot = 0;
47
+ let lastAvgUIUpdate = 0; // Chirurgico: Rimosse audioCtx e lastAlarmTime poiché sono già dichiarate in utils.js
50
48
 
51
- let smoothedLeeway = 0, rotationTrend = 0, meteoTrend = 0;
49
+ let rotationTrend = 0, meteoTrend = 0;
52
50
  let lastShortAvgVal = null, lastInstantTwa = null;
53
51
  let lastTrendTime = Date.now(), lastGybeAlarmTime = 0, lastTWCompute = 0;
54
52
  let twDirty = false, isNavigating = false, reconnectDelay = 1000;
@@ -101,34 +99,8 @@ const ui = {
101
99
  };
102
100
 
103
101
  // ==========================================================================
104
- // 3. UTILITIES (MATEMATICA, BUFFER E MEMORIA)
102
+ // 3. UTILITIES (MATEMATICA, BUFFER E MEMORIA) - [Esportate in utils.js]
105
103
  // ==========================================================================
106
- function radToDeg(rad) { return rad * (180 / Math.PI); }
107
- function degToRad(deg) { return deg * (Math.PI / 180); }
108
- function msToKts(ms) { return ms * 1.94384; }
109
- function ktsToMs(kts) { return kts / 1.94384; }
110
-
111
- function getShortestRotation(curr, target) {
112
- let diff = (target - curr) % 360;
113
- if (diff > 180) diff -= 360;
114
- else if (diff < -180) diff += 360;
115
- return curr + diff;
116
- }
117
-
118
- /**
119
- * Scrive il testo nel DOM/SVG solo se il valore è realmente cambiato.
120
- * Utilizza innerHTML perché è l'unico metodo sicuro al 100% per forzare il ridisegno dei testi negli SVG.
121
- */
122
- function safeSetText(el, text) {
123
- if (!el) return;
124
- // Se l'elemento è parte di un SVG, usiamo textContent, altrimenti innerHTML
125
- const isSVG = el instanceof SVGElement;
126
- if (isSVG) {
127
- if (el.textContent !== text) el.textContent = text;
128
- } else {
129
- if (el.innerHTML !== text) el.innerHTML = text;
130
- }
131
- }
132
104
 
133
105
  /**
134
106
  * Inserimento sicuro nel buffer con trigonometria precaricata e pruning automatico
@@ -155,68 +127,6 @@ function safePush(buffer, val, time) {
155
127
  if (buffer.length > 36000) buffer.shift();
156
128
  }
157
129
 
158
- /**
159
- * Media Circolare Vettoriale - Versione "Soft Outlier Rejection"
160
- */
161
- function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false, now) {
162
- now = now || Date.now();
163
- const len = bufferArray.length;
164
- if (len === 0) return null;
165
-
166
- let sSin = 0, sCos = 0, count = 0;
167
- let newestTime = 0, oldestTime = 0;
168
-
169
- let pilotSin = 0, pilotCos = 0;
170
- const pilotSamples = Math.min(len, 15);
171
- for (let i = len - 1; i >= len - pilotSamples; i--) {
172
- pilotSin += bufferArray[i].sin;
173
- pilotCos += bufferArray[i].cos;
174
- }
175
- const pilotRad = Math.atan2(pilotSin, pilotCos);
176
- const limitRad = (CONFIG.averaging.stabilityBreakout || 15) * (Math.PI / 180);
177
-
178
- for (let i = len - 1; i >= 0; i--) {
179
- const item = bufferArray[i];
180
- if ((now - item.time) > windowMs) break;
181
-
182
- let diffRad = Math.atan2(Math.sin(item.val - pilotRad), Math.cos(item.val - pilotRad));
183
- let finalSin, finalCos;
184
-
185
- if (Math.abs(diffRad) > limitRad) {
186
- const clampedDiff = Math.sign(diffRad) * limitRad;
187
- const clampedRad = pilotRad + clampedDiff;
188
- finalSin = Math.sin(clampedRad);
189
- const relativeCos = Math.cos(clampedRad);
190
- finalCos = relativeCos;
191
- } else {
192
- finalSin = item.sin;
193
- finalCos = item.cos;
194
- }
195
-
196
- sSin += finalSin;
197
- sCos += finalCos;
198
-
199
- if (count === 0) newestTime = item.time;
200
- oldestTime = item.time;
201
- count++;
202
- }
203
-
204
- if (count === 0) return null;
205
-
206
- const R = Math.hypot(sSin, sCos) / count;
207
- const avgRad = Math.atan2(sSin, sCos);
208
- const finalVal = signed ? avgRad : (avgRad + Math.PI * 2) % (Math.PI * 2);
209
- const historyDuration = (count > 2) ? (newestTime - oldestTime) : 0;
210
- const safeR = Math.max(R, 1e-9);
211
-
212
- return {
213
- val: finalVal,
214
- stable: historyDuration > 10000 && R > CONFIG.averaging.stabilityThreshold,
215
- dev: (R < 1) ? Math.round(Math.sqrt(-2 * Math.log(safeR)) * (180 / Math.PI)) : 0,
216
- samples: count
217
- };
218
- }
219
-
220
130
  function saveDashboardState() {
221
131
  try {
222
132
  const focusedBox = document.querySelector('.data-box.is-focused');
@@ -277,34 +187,8 @@ function loadDashboardState() {
277
187
  }
278
188
 
279
189
  // ==========================================================================
280
- // 4. AUDIO E ALLARMI
190
+ // 4. AUDIO E ALLARMI - [Esportati in utils.js]
281
191
  // ==========================================================================
282
- function playBingBing() {
283
- if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
284
- const n = Date.now(); if (n - lastAlarmTime < 3000) return; lastAlarmTime = n;
285
- function b(f, s) {
286
- const o = audioCtx.createOscillator(); const g = audioCtx.createGain();
287
- o.connect(g); g.connect(audioCtx.destination); o.frequency.value = f;
288
- g.gain.setValueAtTime(0.1, s); g.gain.exponentialRampToValueAtTime(0.01, s + 0.4);
289
- o.start(s); o.stop(s + 0.5);
290
- }
291
- b(880, audioCtx.currentTime); b(880, audioCtx.currentTime + 0.6);
292
- }
293
-
294
- function playGybeAlarm() {
295
- if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
296
- const n = Date.now(); if (n - lastAlarmTime < 2000) return; lastAlarmTime = n;
297
- function note(f, s, d) {
298
- const o = audioCtx.createOscillator(); const g = audioCtx.createGain();
299
- o.connect(g); g.connect(audioCtx.destination); o.type = 'square';
300
- o.frequency.value = f; g.gain.setValueAtTime(0.05, s);
301
- g.gain.exponentialRampToValueAtTime(0.001, s + d); o.start(s); o.stop(s + d);
302
- }
303
- for (let i = 0; i < 4; i++) {
304
- note(1800, audioCtx.currentTime + (i * 0.15), 0.1);
305
- note(1200, audioCtx.currentTime + (i * 0.15) + 0.07, 0.1);
306
- }
307
- }
308
192
 
309
193
  function checkDepthAlarm(m) {
310
194
  ui.depth.classList.remove('alarm-warning', 'alarm-danger', 'blink-alarm');
@@ -316,12 +200,6 @@ function checkDepthAlarm(m) {
316
200
  }
317
201
  }
318
202
 
319
- function updateLeewayDisplay(deg) {
320
- const c = 125, px = 125/20; let w = Math.min(Math.abs(deg)*px, 125);
321
- ui.leewayMask.setAttribute('x', deg >= 0 ? c : c - w); ui.leewayMask.setAttribute('width', w);
322
- ui.leewayVal.textContent = `LEEWAY: ${deg.toFixed(1)}°`;
323
- }
324
-
325
203
  /**
326
204
  * computeTrueWind: Calcola TWS, TWA e TWD strategico.
327
205
  * Gestisce il fallback separato (Split-Fallback) in caso di dati parzialmente nativi di bordo.
@@ -670,22 +548,29 @@ function startDisplayLoop() {
670
548
  updateWindTrend();
671
549
 
672
550
  // --- AGGIORNAMENTO STATUS CON CONTEGGIO MINUTI REALE ---
673
- const viewportMinutes = CONFIG.graphs.historyMinutes * (isNavigating ? 1 : 2);
674
- const requiredMs = viewportMinutes * 60000;
675
- const oldestStw = store.histories.stw ? store.histories.stw[0] : null;
676
-
677
- if (oldestStw) {
678
- const availableMs = now - oldestStw.time;
679
- if (availableMs >= requiredMs) {
680
- ui.status.innerText = `ONLINE ${viewportMinutes}min`;
681
- } else {
682
- const availableMin = Math.max(1, Math.floor(availableMs / 60000));
683
- ui.status.innerText = `ONLINE ${availableMin}/${viewportMinutes}min`;
684
- }
685
- } else {
686
- ui.status.innerText = `ONLINE`;
687
- }
688
- ui.status.className = (socket && socket.readyState === WebSocket.OPEN) ? "online" : "offline";
551
+ const isSocketOpen = socket && socket.readyState === WebSocket.OPEN;
552
+
553
+ if (isSocketOpen) {
554
+ ui.status.className = "online"; // Colore Verde
555
+ const viewportMinutes = CONFIG.graphs.historyMinutes * (isNavigating ? 1 : 2);
556
+ const requiredMs = viewportMinutes * 60000;
557
+ const oldestStw = store.histories.stw ? store.histories.stw[0] : null;
558
+
559
+ if (oldestStw) {
560
+ const availableMs = now - oldestStw.time;
561
+ if (availableMs >= requiredMs) {
562
+ ui.status.innerText = `ONLINE ${viewportMinutes}min`;
563
+ } else {
564
+ const availableMin = Math.max(1, Math.floor(availableMs / 60000));
565
+ ui.status.innerText = `ONLINE ${availableMin}/${viewportMinutes}min`;
566
+ }
567
+ } else {
568
+ ui.status.innerText = `ONLINE`;
569
+ }
570
+ } else {
571
+ ui.status.className = "offline"; // Colore Rosso
572
+ ui.status.innerText = "OFFLINE"; // Chirurgico: Forza il testo a OFFLINE se il socket è chiuso, evitando scritte verdi in rosso
573
+ }
689
574
 
690
575
  // --- WATCHDOG: CONTROLLO TIMEOUT ---
691
576
  const watch = {
@@ -738,131 +623,93 @@ function startDisplayLoop() {
738
623
  }
739
624
 
740
625
  // --- AGGIORNAMENTO PROFONDITÀ (DEPTH) ---
741
- if (store.raw["environment.depth.belowTransducer"] !== undefined) {
742
- safeSetText(ui.depth, store.raw["environment.depth.belowTransducer"].toFixed(1));
743
- checkDepthAlarm(store.raw["environment.depth.belowTransducer"]);
744
- manageHistory('depth', store.raw["environment.depth.belowTransducer"]);
745
- }
626
+ if (store.raw["environment.depth.belowTransducer"] !== undefined) {
627
+ safeSetText(ui.depth, store.raw["environment.depth.belowTransducer"].toFixed(1));
628
+ checkDepthAlarm(store.raw["environment.depth.belowTransducer"]);
629
+ manageHistory('depth', store.raw["environment.depth.belowTransducer"]);
630
+ }
746
631
 
747
- // --- GESTIONE VENTO (TWS / AWS SWITCH & BUSSOLA) ---
632
+ // --- GESTIONE VENTO (TWS / AWS SWITCH & BUSSOLA) ---
633
+
634
+ // Estrazione dati sicura: controlliamo esplicitamente se il dato esiste, altrimenti 0
635
+ const rawTws = store.raw["environment.wind.speedTrue"];
636
+ const rawAws = store.raw["environment.wind.speedApparent"];
748
637
 
749
- // Estrazione dati sicura: controlliamo esplicitamente se il dato esiste, altrimenti 0
750
- const rawTws = store.raw["environment.wind.speedTrue"];
751
- const rawAws = store.raw["environment.wind.speedApparent"];
752
-
753
- const twsVal = (rawTws !== undefined && rawTws !== null) ? msToKts(rawTws) : 0;
754
- const awsVal = (rawAws !== undefined && rawAws !== null) ? msToKts(rawAws) : 0;
755
-
756
- if (rawTws !== undefined && rawTws !== null) manageHistory('tws', twsVal);
757
- if (rawAws !== undefined && rawAws !== null) manageHistory('aws', awsVal);
758
-
759
- // Disegno testo casella destra (TWS o AWS)
760
- if (rawTws !== undefined || rawAws !== undefined) {
761
- const labelWind = document.getElementById('tws-aws-label');
762
- const currentWind = (displayModeTws === 'AWS') ? awsVal : twsVal;
763
-
764
- safeSetText(ui.tws, currentWind.toFixed(1));
765
- if (labelWind) labelWind.textContent = displayModeTws;
766
-
767
- if (currentWind >= CONFIG.graphs.reef2) {
768
- ui.tws.style.setProperty('color', '#ff3b30', 'important');
769
- } else if (currentWind >= CONFIG.graphs.reef1) {
770
- ui.tws.style.setProperty('color', '#ff9800', 'important');
771
- } else {
772
- if (displayModeTws === 'AWS') {
773
- ui.tws.style.setProperty('color', '#5c6bc0', 'important');
774
- } else {
775
- const navyNight = isNight ? '#6c8ea0' : '#2c3e50';
776
- ui.tws.style.setProperty('color', navyNight, 'important');
638
+ const twsVal = (rawTws !== undefined && rawTws !== null) ? msToKts(rawTws) : 0;
639
+ const awsVal = (rawAws !== undefined && rawAws !== null) ? msToKts(rawAws) : 0;
640
+
641
+ if (rawTws !== undefined && rawTws !== null) manageHistory('tws', twsVal);
642
+ if (rawAws !== undefined && rawAws !== null) manageHistory('aws', awsVal);
643
+
644
+ // Disegno testo casella destra (TWS o AWS)
645
+ if (rawTws !== undefined || rawAws !== undefined) {
646
+ const labelWind = document.getElementById('tws-aws-label');
647
+ const currentWind = (displayModeTws === 'AWS') ? awsVal : twsVal;
648
+
649
+ safeSetText(ui.tws, currentWind.toFixed(1));
650
+ if (labelWind) labelWind.textContent = displayModeTws;
651
+
652
+ if (currentWind >= CONFIG.graphs.reef2) {
653
+ ui.tws.style.setProperty('color', '#ff3b30', 'important');
654
+ } else if (currentWind >= CONFIG.graphs.reef1) {
655
+ ui.tws.style.setProperty('color', '#ff9800', 'important');
656
+ } else {
657
+ if (displayModeTws === 'AWS') {
658
+ ui.tws.style.setProperty('color', '#5c6bc0', 'important');
659
+ } else {
660
+ const navyNight = isNight ? '#6c8ea0' : '#2c3e50';
661
+ ui.tws.style.setProperty('color', navyNight, 'important');
662
+ }
663
+ }
777
664
  }
778
- }
779
- }
780
665
 
781
- // --- SBLOCCO FORZATO TESTO BUSSOLA (AWS) ---
782
- if (rawAws !== undefined && rawAws !== null && !isNaN(awsVal)) {
783
- const strVal = awsVal.toFixed(1);
784
- // Recupero robusto dell'elemento
785
- let awsSvgEl = document.getElementById('aws-val-svg');
786
-
787
- if (awsSvgEl) {
788
- // Trucco anti-congelamento SVG: se il testo non combacia, lo forziamo usando
789
- // textContent (standard SVG) invece di innerHTML (standard HTML).
790
- if (awsSvgEl.textContent !== strVal) {
791
- awsSvgEl.textContent = strVal;
666
+ // --- AGGIORNAMENTO DELLA BUSSOLA CENTRALE E MINI-COMPASS ---
667
+ updateCentralGauge(store, ui, now, isNavigating, sogKts, stwKts, rawAws, awsVal);
668
+
669
+ // --- SLOW TIER (Salvataggio stato ogni 10 secondi) ---
670
+ if (lastAvgUIUpdate++ % 10 === 0) {
671
+ saveDashboardState();
792
672
  }
793
- } else {
794
- console.error("ERRORE: Elemento SVG bussola ('aws-val-svg') non trovato nel documento!");
795
- }
796
- }
797
-
798
- // --- PUNTATORI ANALOGICI ---
799
- const smAwa = getCircularAverageFromBuffer(store.smoothBuf.awa, 2000, true);
800
- const smTwa = getCircularAverageFromBuffer(store.smoothBuf.twa, 2000, true);
801
- if (smAwa) ui.awa.setAttribute('transform', `rotate(${curAwaRot = getShortestRotation(curAwaRot, radToDeg(smAwa.val))}, 200, 200)`);
802
- if (smTwa) ui.twa.setAttribute('transform', `rotate(${curTwaRot = getShortestRotation(curTwaRot, radToDeg(smTwa.val))}, 200, 200)`);
803
-
804
- if (store.raw["navigation.courseOverGroundTrue"] !== undefined && store.raw["navigation.headingTrue"] !== undefined) {
805
- let driftDeg = radToDeg((store.raw["navigation.courseOverGroundTrue"] - store.raw["navigation.headingTrue"] + Math.PI * 3) % (2 * Math.PI) - Math.PI);
806
- smoothedLeeway = (sogKts < CONFIG.averaging.minSpeed) ? 0 : (smoothedLeeway * 0.9) + (driftDeg * 0.1);
807
- curTrackRot = getShortestRotation(curTrackRot, smoothedLeeway);
808
- ui.track.setAttribute('transform', `rotate(${curTrackRot}, 200, 200)`);
809
- ui.leewayVal.style.color = (Math.abs(sogKts - stwKts) > 0.5 && Math.abs(smoothedLeeway) > 7) ? "#ff9800" : "";
810
- updateLeewayDisplay(Math.max(-20, Math.min(20, smoothedLeeway)));
811
- }
812
-
813
- // --- SLOW TIER (Salvataggio stato ogni 10 secondi) ---
814
- if (lastAvgUIUpdate++ % 10 === 0) {
815
- saveDashboardState();
816
- }
817
673
 
818
- if (lastAvgUIUpdate % 3 === 0) {
819
- let hObj = getCircularAverageFromBuffer(store.longBuf.hdg, CONFIG.averaging.longWindow * 2, false);
820
- let cObj = getCircularAverageFromBuffer(store.longBuf.cog, CONFIG.averaging.longWindow, false);
821
- let awObj = getCircularAverageFromBuffer(store.longBuf.awa, CONFIG.averaging.longWindow, true);
822
- let twObj = getCircularAverageFromBuffer(store.longBuf.twa, CONFIG.averaging.longWindow, true);
823
- let twdObj = getCircularAverageFromBuffer(store.longBuf.twd, CONFIG.averaging.longWindow, false);
824
-
825
- upUI(ui.hdg, hObj, store.raw["navigation.headingTrue"], true);
826
- upUI(ui.cog, cObj, store.raw["navigation.courseOverGroundTrue"], true);
827
- upUI(ui.awaAvg, awObj, store.raw["environment.wind.angleApparent"], false);
828
- upUI(ui.twaAvg, twObj, store.raw["environment.wind.angleTrueWater"], false);
829
- upUI(ui.twdAvg, twdObj, store.raw["environment.wind.directionTrue"], true);
830
-
831
- if (hObj && twdObj) {
832
- const reflectAngle = (targetRad, axisRad) => {
833
- const dS = Math.sin(axisRad - targetRad);
834
- const dC = Math.cos(axisRad - targetRad);
835
- return Math.atan2(Math.sin(axisRad) * dC + Math.cos(axisRad) * dS, Math.cos(axisRad) * dC - Math.sin(axisRad) * dS);
836
- };
837
- const unstableH = !hObj.stable || !twdObj.stable || hObj.dev > CONFIG.averaging.stabilityBreakout;
838
- if (!isNavigating) ui.tackHdg.innerHTML = "---&deg;";
839
- else if (unstableH) { ui.tackHdg.innerHTML = "---&deg;"; ui.tackHdg.classList.add('unstable-data'); }
840
- else {
841
- const rH = (radToDeg(reflectAngle(hObj.val, twdObj.val)) + 360) % 360;
842
- ui.tackHdg.innerHTML = `${Math.round(rH).toString().padStart(3, '0')}&deg;`;
843
- ui.tackHdg.classList.remove('unstable-data');
844
- }
845
- if (cObj) {
846
- const unstableC = !cObj.stable || !twdObj.stable || cObj.dev > CONFIG.averaging.stabilityBreakout;
847
- if (!isNavigating) ui.tackCog.innerHTML = "---&deg;";
848
- else if (unstableC) { ui.tackCog.innerHTML = "---&deg;"; ui.tackCog.classList.add('unstable-data'); }
849
- else {
850
- const rC = (radToDeg(reflectAngle(cObj.val, twdObj.val)) + 360) % 360;
851
- ui.tackCog.innerHTML = `${Math.round(rC).toString().padStart(3, '0')}&deg;`;
852
- ui.tackCog.classList.remove('unstable-data');
674
+ if (lastAvgUIUpdate % 3 === 0) {
675
+ let hObj = getCircularAverageFromBuffer(store.longBuf.hdg, CONFIG.averaging.longWindow * 2, false);
676
+ let cObj = getCircularAverageFromBuffer(store.longBuf.cog, CONFIG.averaging.longWindow, false);
677
+ let awObj = getCircularAverageFromBuffer(store.longBuf.awa, CONFIG.averaging.longWindow, true);
678
+ let twObj = getCircularAverageFromBuffer(store.longBuf.twa, CONFIG.averaging.longWindow, true);
679
+ let twdObj = getCircularAverageFromBuffer(store.longBuf.twd, CONFIG.averaging.longWindow, false);
680
+
681
+ upUI(ui.hdg, hObj, store.raw["navigation.headingTrue"], true);
682
+ upUI(ui.cog, cObj, store.raw["navigation.courseOverGroundTrue"], true);
683
+ upUI(ui.awaAvg, awObj, store.raw["environment.wind.angleApparent"], false);
684
+ upUI(ui.twaAvg, twObj, store.raw["environment.wind.angleTrueWater"], false);
685
+ upUI(ui.twdAvg, twdObj, store.raw["environment.wind.directionTrue"], true);
686
+
687
+ if (hObj && twdObj) {
688
+ const reflectAngle = (targetRad, axisRad) => {
689
+ const dS = Math.sin(axisRad - targetRad);
690
+ const dC = Math.cos(axisRad - targetRad);
691
+ return Math.atan2(Math.sin(axisRad) * dC + Math.cos(axisRad) * dS, Math.cos(axisRad) * dC - Math.sin(axisRad) * dS);
692
+ };
693
+ const unstableH = !hObj.stable || !twdObj.stable || hObj.dev > CONFIG.averaging.stabilityBreakout;
694
+ if (!isNavigating) ui.tackHdg.innerHTML = "---&deg;";
695
+ else if (unstableH) { ui.tackHdg.innerHTML = "---&deg;"; ui.tackHdg.classList.add('unstable-data'); }
696
+ else {
697
+ const rH = (radToDeg(reflectAngle(hObj.val, twdObj.val)) + 360) % 360;
698
+ ui.tackHdg.innerHTML = `${Math.round(rH).toString().padStart(3, '0')}&deg;`;
699
+ ui.tackHdg.classList.remove('unstable-data');
700
+ }
701
+ if (cObj) {
702
+ const unstableC = !cObj.stable || !twdObj.stable || cObj.dev > CONFIG.averaging.stabilityBreakout;
703
+ if (!isNavigating) ui.tackCog.innerHTML = "---&deg;";
704
+ else if (unstableC) { ui.tackCog.innerHTML = "---&deg;"; ui.tackCog.classList.add('unstable-data'); }
705
+ else {
706
+ const rC = (radToDeg(reflectAngle(cObj.val, twdObj.val)) + 360) % 360;
707
+ ui.tackCog.innerHTML = `${Math.round(rC).toString().padStart(3, '0')}&deg;`;
708
+ ui.tackCog.classList.remove('unstable-data');
709
+ }
710
+ }
853
711
  }
854
712
  }
855
- }
856
-
857
- const smHdgIcons = getCircularAverageFromBuffer(store.smoothBuf.hdg, 2000, false);
858
- const smTwdIcons = getCircularAverageFromBuffer(store.smoothBuf.twd, 2000, false);
859
- if (smHdgIcons && smTwdIcons) {
860
- curWindCompassRot = getShortestRotation(curWindCompassRot, radToDeg(smTwdIcons.val));
861
- ui.twdArrow.setAttribute('transform', `rotate(${curWindCompassRot}, 20, 20)`);
862
- curBoatCompassRot = getShortestRotation(curBoatCompassRot, radToDeg(smHdgIcons.val));
863
- ui.twdBoat.setAttribute('transform', `rotate(${curBoatCompassRot}, 20, 20)`);
864
- }
865
- }
866
713
  }, RENDER_INTERVAL_MS);
867
714
  }
868
715
 
@@ -1085,321 +932,6 @@ function manageHistory(type, value) {
1085
932
  store.lastUpdates[type] = Math.floor(now / bucketIntervalMs) * bucketIntervalMs;
1086
933
  }
1087
934
 
1088
- /**
1089
- * Gestione dinamica delle scale dei grafici (Involucro Elastico Snapped e Safety Zoom)
1090
- * Implementa lo "Snap a Griglia" basato sull'hercSpan senza padding per tutti i sensori.
1091
- */
1092
- function calculateScale(type, data, mode) {
1093
- const s = CONFIG.scales[type];
1094
- const currentVal = data[data.length - 1];
1095
-
1096
- // Fallback di emergenza se il buffer è momentaneamente vuoto
1097
- if (currentVal === undefined || currentVal === null) {
1098
- return { min: 0, max: s ? s.stdMax : 10 };
1099
- }
1100
-
1101
- // Inizializzazione della memoria delle scale nello store se non esiste
1102
- if (!store.herculesScales) store.herculesScales = {};
1103
- if (!store.herculesScales[type]) {
1104
- store.herculesScales[type] = { min: 0, max: s ? s.stdMax : 10 };
1105
- }
1106
- let currentScale = store.herculesScales[type];
1107
-
1108
- // ==========================================================================
1109
- // 1. SEZIONE PROFONDITÀ (DEDICATA A 2 MINUTI DI SICUREZZA, MINIMO FISSO A 0)
1110
- // ==========================================================================
1111
- if (type === 'depth') {
1112
- const shallowThreshold = Math.max(s.stdMax, 10); // Es. 20m
1113
- if (store.depthProtectedActive === undefined) store.depthProtectedActive = false;
1114
-
1115
- const now = Date.now();
1116
- const depthSafetyWindowMs = 120000; // 2 minuti fissi per la sicurezza
1117
-
1118
- // Estrazione dati reali degli ultimi 2 minuti con timestamp
1119
- const recentPoints = store.histories.depth.filter(p => (now - p.time) <= depthSafetyWindowMs);
1120
- const recentVals = recentPoints.map(p => p.val);
1121
-
1122
- const localMax = recentVals.length > 0 ? Math.max(...recentVals) : currentVal;
1123
- const localMin = recentVals.length > 0 ? Math.min(...recentVals) : currentVal;
1124
-
1125
- // --- 1.1 NORMALE PROFONDITÀ (CON SOGLIA STANDARD E FILTRO 2 MINUTI) ---
1126
- if (mode !== 'hercules') {
1127
- if (!store.depthProtectedActive && currentVal <= shallowThreshold) {
1128
- store.depthProtectedActive = true;
1129
- }
1130
- if (store.depthProtectedActive) {
1131
- if (localMin > shallowThreshold) {
1132
- store.depthProtectedActive = false;
1133
- }
1134
- }
1135
- if (store.depthProtectedActive) {
1136
- return { min: 0, max: shallowThreshold };
1137
- }
1138
- const maxHistorico = Math.max(...data);
1139
- return { min: 0, max: Math.max(s.stdMax, Math.ceil(maxHistorico / s.step) * s.step) };
1140
- }
1141
-
1142
- // --- 1.2 HERCULES PROFONDITÀ (BASE 0 IN ACQUE BASSE, FLUTTUANTE AL LARGO) ---
1143
- if (mode === 'hercules') {
1144
- const roundStep = s.hercSpan; // Passo di griglia selezionato dall'utente
1145
- const shallowThreshold = Math.max(s.stdMax, 10); // Es. 20m
1146
-
1147
- let targetMin = 0;
1148
- // Se siamo in acque profonde, lasciamo che il minimo fluttui per mostrare i micro-dettagli
1149
- if (localMin > shallowThreshold) {
1150
- targetMin = Math.max(0, Math.floor(localMin / roundStep) * roundStep);
1151
- }
1152
-
1153
- let targetMax = Math.ceil(localMax / roundStep) * roundStep;
1154
-
1155
- // Impediamo uno span inferiore a 4 metri per evitare grafici piatti
1156
- if (targetMax - targetMin < 4) {
1157
- targetMax = targetMin + 4;
1158
- }
1159
-
1160
- // Regola asimmetrica per il MIN e il MAX (Espansione istantanea, contrazione a 2 minuti)
1161
- if (currentVal < currentScale.min || currentVal > currentScale.max) {
1162
- currentScale.min = Math.min(currentScale.min, targetMin);
1163
- currentScale.max = Math.max(currentScale.max, targetMax);
1164
- } else {
1165
- const allStableInTarget = recentVals.every(val => val >= targetMin && val <= targetMax);
1166
- if (allStableInTarget) {
1167
- currentScale.min = targetMin;
1168
- currentScale.max = targetMax;
1169
- }
1170
- }
1171
-
1172
- return { min: currentScale.min, max: currentScale.max };
1173
- }
1174
- }
1175
-
1176
- // ==========================================================================
1177
- // 2. ALTRI GRAFICI (STW, SOG, TWS): COMPORTAMENTO ADATTIVO SU TERZO DEL GRAFICO
1178
- // ==========================================================================
1179
-
1180
- // --- 2.1 MODALITÀ NORMALE ---
1181
- if (mode !== 'hercules') {
1182
- const maxHistorico = Math.max(...data);
1183
- return { min: 0, max: Math.max(s.stdMax, Math.ceil(maxHistorico / s.step) * s.step) };
1184
- }
1185
-
1186
- // --- 2.2 MODALITÀ HERCULES AD ALTO CONTRASTO (SNAP SENZA PADDING) ---
1187
- if (mode === 'hercules') {
1188
- const roundStep = s.hercSpan; // Passo di griglia (ex hercSpan)
1189
-
1190
- const oneThirdCount = Math.max(1, Math.floor(data.length / 3));
1191
- const recentThirdData = data.slice(-oneThirdCount);
1192
-
1193
- const localMin = Math.min(...recentThirdData);
1194
- const localMax = Math.max(...recentThirdData);
1195
-
1196
- // Applichiamo lo snap diretto ai multipli della griglia
1197
- let targetMin = Math.max(0, Math.floor(localMin / roundStep) * roundStep);
1198
- let targetMax = Math.ceil(localMax / roundStep) * roundStep;
1199
-
1200
- // Se lo span calcolato è nullo (es. velocità costante), forziamo lo span minimo
1201
- if (targetMax - targetMin === 0) {
1202
- targetMax = targetMin + roundStep;
1203
- }
1204
-
1205
- // APPLICAZIONE REGOLE ASIMMETRICHE ANTI-CLIPPING
1206
- // A. Espansione ISTANTANEA in alto o in basso (sicurezza)
1207
- if (currentVal < currentScale.min || currentVal > currentScale.max) {
1208
- currentScale.min = Math.min(currentScale.min, targetMin);
1209
- currentScale.max = Math.max(currentScale.max, targetMax);
1210
- }
1211
- // B. Contrazione RITARDATA (solo se tutto il terzo recente si è assestato nel target)
1212
- else {
1213
- const allStableInTarget = recentThirdData.every(val => val >= targetMin && val <= targetMax);
1214
- if (allStableInTarget) {
1215
- currentScale.min = targetMin;
1216
- currentScale.max = targetMax;
1217
- }
1218
- }
1219
-
1220
- return { min: currentScale.min, max: currentScale.max };
1221
- }
1222
- }
1223
-
1224
- function updateScaleLabels(t, min, max) {
1225
- const el = document.getElementById(t + '-scale');
1226
- if (el) el.innerHTML = `<span>${Math.round(max)}</span><span>${Math.round((min+max)/2)}</span><span>${Math.round(min)}</span>`;
1227
- }
1228
-
1229
- /**
1230
- * refreshGraph: Recupero dati, switch AWS/TWS/VMG e passaggio a motore grafico.
1231
- * Redirige e protegge i canali secondari (AWS/VMG) evitando ridisegni inutili.
1232
- */
1233
- function refreshGraph(t) {
1234
- // Redirezione di sicurezza dei canali secondari (AWS / VMG) verso i contenitori principali
1235
- if (t === 'aws') {
1236
- if (displayModeTws !== 'AWS') return; // Se non stiamo visualizzando AWS a schermo, ignora il rinfresco
1237
- t = 'tws';
1238
- }
1239
- if (t === 'vmg') {
1240
- if (displayModeSog !== 'VMG') return; // Se non stiamo visualizzando VMG a schermo, ignora il rinfresco
1241
- t = 'sog';
1242
- }
1243
-
1244
- const boxType = t;
1245
- let rawData;
1246
-
1247
- if (t === 'tws' && displayModeTws === 'AWS') {
1248
- rawData = store.histories['aws'];
1249
- } else {
1250
- rawData = store.histories[t];
1251
- }
1252
-
1253
- if (!rawData || rawData.length < 2) return;
1254
-
1255
- const values = rawData.map(p => p.val);
1256
- const mode = graphModes[boxType];
1257
- const cfg = calculateScale(boxType, values, mode);
1258
-
1259
- const box = document.querySelector(`.box-${boxType}`);
1260
- if (box) box.classList.toggle('box-hercules', mode === 'hercules');
1261
-
1262
- updateScaleLabels(boxType, cfg.min, cfg.max);
1263
- drawGraph(rawData, boxType + '-graph', cfg.min, cfg.max, t === 'tws', mode === 'hercules');
1264
- }
1265
-
1266
- /**
1267
- * drawGraph: Motore SVG con Timeline Reale e Gestione GAP
1268
- * Risolve i conflitti di orologio (Clock Drift) tra Cerbo GX e Tablet.
1269
- * Integra la reattività dello spessore della curva in modalità Dual Screen (Focus).
1270
- */
1271
- function drawGraph(d, id, min, max, isTws, isHercules) {
1272
- const svg = document.getElementById(id);
1273
- if (!svg || d.length < 2) return;
1274
-
1275
- const w = 200, h = 40;
1276
- const range = max - min || 1;
1277
- const isDepth = (id === 'depth-graph');
1278
-
1279
- // --- RISOLUZIONE DISALLINEAMENTO ORARIO ---
1280
- // Usiamo il tempo del sensore (l'ultimo dato ricevuto) invece dell'orologio del tablet
1281
- const latestPoint = d[d.length - 1];
1282
- const now = latestPoint ? latestPoint.time : Date.now();
1283
-
1284
- // --- RILEVAMENTO DINAMICO DUAL SCREEN BLINDATO ---
1285
- const box = svg.closest('.data-box');
1286
- const isFocused = isFocusActive && box && box.classList.contains('is-focused');
1287
-
1288
- const visibleMinutes = CONFIG.graphs.historyMinutes * (isNavigating ? 1 : 2);
1289
- const viewportMs = visibleMinutes * 60000;
1290
- const viewportStart = now - viewportMs;
1291
-
1292
- const visibleData = d.filter(p => p.time >= viewportStart);
1293
- if (visibleData.length < 2) return;
1294
-
1295
- const colDanger = "#ff3b30", colWarning = "#ff9800", colTws = "#2c3e50", colAws = "#5c6bc0";
1296
- const colDepth = "#0088cc", colStw = "#00C851", colStwBorder = "#007a3d", colSog = "#ffbb33", colVmg = "#00b8d4";
1297
-
1298
- const getColorProps = (val) => {
1299
- // Se siamo in Dual Screen (focus), aumentiamo lo spessore base della curva di un filino (da 1.6 a 2.2)
1300
- const baseStroke = isFocused ? "4.2" : "1.6";
1301
- const alertStroke = isFocused ? "4.8" : "2.2";
1302
- const warnStroke = isFocused ? "4.4" : "1.8";
1303
-
1304
- let color = colTws, opacity = "0.15", stroke = baseStroke;
1305
- if (isTws) {
1306
- const baseWind = (displayModeTws === 'AWS') ? colAws : colTws;
1307
- if (val >= CONFIG.graphs.reef2) { color = colDanger; opacity = "0.55"; stroke = alertStroke; }
1308
- else if (val >= CONFIG.graphs.reef1) { color = colWarning; opacity = "0.45"; stroke = warnStroke; }
1309
- else color = baseWind;
1310
- } else if (isDepth) {
1311
- if (val < CONFIG.alarms.depthDanger) { color = colDanger; opacity = "0.55"; stroke = alertStroke; }
1312
- else if (val < CONFIG.alarms.depthWarning) { color = colWarning; opacity = "0.45"; stroke = warnStroke; }
1313
- else color = colDepth;
1314
- } else {
1315
- if (id === 'stw-graph') color = colStw;
1316
- else if (id === 'sog-graph') color = (displayModeSog === 'VMG') ? colVmg : colSog;
1317
- }
1318
- return { color, opacity, stroke };
1319
- };
1320
-
1321
- let grids = "";
1322
- // --- GRIGLIE ORIZZONTALI (Spessore fisso a 0.5px, non deformabile) ---
1323
- [0.25, 0.5, 0.75].forEach(p => grids += `<line x1="0" y1="${h-(p*h)}" x2="${w}" y2="${h-(p*h)}" stroke="rgba(0,0,0,0.12)" stroke-width="0.5" vector-effect="non-scaling-stroke" />`);
1324
-
1325
- // --- GRIGLIE VERTICALI (Spessore fisso a 0.4px, non deformabile) ---
1326
- const gridInterval = (visibleMinutes <= 15) ? 1 : 5;
1327
- for (let m = gridInterval; m < visibleMinutes; m += gridInterval) {
1328
- const x = w - ((m / visibleMinutes) * w);
1329
- grids += `<line x1="${x}" y1="0" x2="${x}" y2="${h}" stroke="rgba(0,0,0,0.08)" stroke-width="0.4" vector-effect="non-scaling-stroke" />`;
1330
- }
1331
-
1332
- // --- DISEGNO LINEE DI SICUREZZA ALLARME PROFONDITÀ (DANGER & WARNING) ---
1333
- if (isDepth) {
1334
- const dangerVal = CONFIG.alarms.depthDanger; // Es. 2.5m
1335
- const warningVal = CONFIG.alarms.depthWarning; // Es. 3.5m
1336
- const marginX = 4; // Margine per non toccare i bordi esterni
1337
-
1338
- // Spessore dinamico per gli allarmi: 4.8px in Dual Screen, 1.8px nella griglia normale
1339
- const alarmStrokeWidth = isFocused ? "4.8" : "1.8";
1340
-
1341
- // Linea Rossa Viva (Spessore dinamico, con il tuo tratteggio alternato personalizzato)
1342
- if (dangerVal >= min && dangerVal <= max) {
1343
- const p = (dangerVal - min) / range;
1344
- const y = h - (p * h);
1345
- grids += `<line x1="${marginX}" y1="${y}" x2="${w - marginX}" y2="${y}" stroke="rgba(255, 59, 48, 0.95)" stroke-width="${alarmStrokeWidth}" stroke-dasharray="12, 6, 2, 6" vector-effect="non-scaling-stroke" />`;
1346
- }
1347
- // Linea Giallo Oro Viva (Spessore dinamico, con il tuo tratteggio alternato personalizzato)
1348
- if (warningVal >= min && warningVal <= max) {
1349
- const p = (warningVal - min) / range;
1350
- const y = h - (p * h);
1351
- grids += `<line x1="${marginX}" y1="${y}" x2="${w - marginX}" y2="${y}" stroke="rgba(255, 204, 0, 0.95)" stroke-width="${alarmStrokeWidth}" stroke-dasharray="6 , 2, 6, 12" vector-effect="non-scaling-stroke" />`;
1352
- }
1353
- }
1354
-
1355
- let gradientStops = "", lines = "", areaPath = "";
1356
- let started = false;
1357
-
1358
- for (let i = 1; i < visibleData.length; i++) {
1359
- const pA = visibleData[i - 1];
1360
- const pB = visibleData[i];
1361
-
1362
- const x1 = ((pA.time - viewportStart) / viewportMs) * w;
1363
- const x2 = ((pB.time - viewportStart) / viewportMs) * w;
1364
- const y1 = h - (Math.max(0, Math.min(1, (pA.val - min) / range)) * h);
1365
- const y2 = h - (Math.max(0, Math.min(1, (pB.val - min) / range)) * h);
1366
-
1367
- const props = getColorProps(pB.val);
1368
- const deltaTime = pB.time - pA.time;
1369
- const expectedInterval = viewportMs / CONFIG.graphs.samples;
1370
- const isGap = deltaTime > (expectedInterval * 2.5);
1371
-
1372
- const offset1 = (x1 / w) * 100, offset2 = (x2 / w) * 100;
1373
- gradientStops += `<stop offset="${offset1}%" stop-color="${props.color}" stop-opacity="${props.opacity}" />`;
1374
- gradientStops += `<stop offset="${offset2}%" stop-color="${props.color}" stop-opacity="${props.opacity}" />`;
1375
-
1376
- if (isGap) {
1377
- if (started) {
1378
- areaPath += `L ${x1} ${h} `;
1379
- started = false;
1380
- }
1381
- } else {
1382
- // Curva del grafico: spessore reattivo al focus e protezione da deformazione (non-scaling)
1383
- lines += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" style="stroke:${props.color}; stroke-width:${props.stroke}; stroke-linecap:round; shape-rendering:geometricPrecision;" vector-effect="non-scaling-stroke" />`;
1384
- if (!started) {
1385
- areaPath += `M ${Math.max(0, x1)} ${h} L ${Math.max(0, x1)} ${y1} `;
1386
- started = true;
1387
- }
1388
- areaPath += `L ${x2} ${y2} `;
1389
- }
1390
- }
1391
-
1392
- if (started) {
1393
- const last = visibleData[visibleData.length - 1];
1394
- const lastX = ((last.time - viewportStart) / viewportMs) * w;
1395
- areaPath += `L ${lastX} ${h} Z`;
1396
- }
1397
-
1398
- const gradId = `grad-${id}`;
1399
- const defs = `<defs><linearGradient id="${gradId}" x1="0" y1="0" x2="${w}" y2="0" gradientUnits="userSpaceOnUse">${gradientStops}</linearGradient></defs>`;
1400
- svg.innerHTML = `${defs}${grids}<path d="${areaPath}" fill="url(#${gradId})" stroke="none" />${lines}`;
1401
- }
1402
-
1403
935
  // ==========================================================================
1404
936
  // 9. INTERAZIONI E GESTI
1405
937
  // ==========================================================================
@@ -1565,21 +1097,10 @@ function connect() {
1565
1097
  // 11. INIT E CICLO DI VITA
1566
1098
  // ==========================================================================
1567
1099
  window.addEventListener('contextmenu', e => e.preventDefault(), true);
1568
- (function genTicks() {
1569
- const c = document.getElementById('ticks');
1570
- if (c) {
1571
- for (let i = 0; i < 360; i += 10) {
1572
- const l = document.createElementNS("http://www.w3.org/2000/svg", "line");
1573
- const m = i % 30 === 0;
1574
- l.setAttribute("x1", "200"); l.setAttribute("y1", "40"); l.setAttribute("x2", "200"); l.setAttribute("y2", (m ? 60 : 50));
1575
- l.setAttribute("stroke", m ? "#000" : "#bbb"); l.setAttribute("stroke-width", m ? "2" : "1");
1576
- l.setAttribute("transform", `rotate(${i}, 200, 200)`); c.appendChild(l);
1577
- }
1578
- }
1579
- })();
1580
1100
 
1581
1101
  async function init() {
1582
1102
  loadDashboardState();
1103
+ initCompassTicks(); // Genera i ticks sul quadrante usando il modulo gauge.js
1583
1104
 
1584
1105
  // Rileviamo se siamo sul Mac tramite file:// (Ambiente di sviluppo locale)
1585
1106
  const isLocalFile = (window.location.protocol === 'file:');