@sailingrotevista/rotevista-dash 2.0.16 → 2.0.17

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 (4) hide show
  1. package/app.js +260 -131
  2. package/index.html +35 -81
  3. package/package.json +1 -1
  4. package/style.css +76 -249
package/app.js CHANGED
@@ -17,18 +17,20 @@ const SIM_SAMPLE_INTERVAL = 1000;
17
17
  // 2. STATO GLOBALE E RIFERIMENTI UI
18
18
  // ==========================================================================
19
19
  let simulationMode = false;
20
+ let displayModeSog = 'SOG'; // 'SOG' o 'VMG'
20
21
  let socket, renderInterval, simInterval;
21
22
  let lastAvgUIUpdate = 0, audioCtx = null, lastAlarmTime = 0;
22
23
  let curAwaRot = 0, curTwaRot = 0, curTrackRot = 0, curTwdRoseRot = 0;
24
+ let curBoatCompassRot = 0, curWindCompassRot = 0;
23
25
 
24
- // Variabili di stato per filtri e logica tattica
25
- let smoothedLeeway = 0;
26
- let rotationTrend = 0;
27
- let lastShortAvgVal = null;
28
- let lastInstantTwa = null;
26
+ let smoothedLeeway = 0, rotationTrend = 0;
27
+ let lastShortAvgVal = null, lastInstantTwa = null;
28
+ let lastTrendTime = Date.now(), lastGybeAlarmTime = 0, lastTWCompute = 0;
29
+ let twDirty = false;
30
+ let reconnectDelay = 1000;
29
31
  let isNavigating = false;
30
32
 
31
- let pressTimer, isFocusActive = false, blockNextClick = false;
33
+ let pressTimer, isFocusActive = false;
32
34
 
33
35
  const graphModes = {
34
36
  stw: localStorage.getItem('mode_stw') || 'standard',
@@ -41,8 +43,8 @@ const store = {
41
43
  raw: {}, timestamps: {},
42
44
  smoothBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
43
45
  longBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
44
- histories: { stw: [], sog: [], depth: [], tws: [] },
45
- lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0 }
46
+ histories: { stw: [], sog: [], depth: [], tws: [], vmg: [] },
47
+ lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0 }
46
48
  };
47
49
 
48
50
  const ui = {
@@ -61,7 +63,7 @@ const ui = {
61
63
  };
62
64
 
63
65
  // ==========================================================================
64
- // 3. MATEMATICA E UTILS
66
+ // 3. UTILITIES DI SISTEMA (MATEMATICA E BUFFER)
65
67
  // ==========================================================================
66
68
  function radToDeg(rad) { return rad * (180 / Math.PI); }
67
69
  function degToRad(deg) { return deg * (Math.PI / 180); }
@@ -69,27 +71,73 @@ function msToKts(ms) { return ms * 1.94384; }
69
71
  function ktsToMs(kts) { return kts / 1.94384; }
70
72
  function getShortestRotation(curr, target) { let diff = (target - curr) % 360; if (diff > 180) diff -= 360; else if (diff < -180) diff += 360; return curr + diff; }
71
73
 
74
+ /**
75
+ * Gestione sicura dei buffer per prevenire memory leak.
76
+ */
77
+ function safePush(buffer, val, time, maxLen = 200) {
78
+ buffer.push({ val: val, time: time });
79
+ if (buffer.length > maxLen) { buffer.shift(); }
80
+ }
81
+
72
82
  function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false) {
73
- const now = Date.now(); const validData = bufferArray.filter(item => (now - item.time) <= windowMs);
83
+ const now = Date.now();
84
+ const validData = bufferArray.filter(item => (now - item.time) <= windowMs);
74
85
  if (validData.length === 0) return null;
75
- let sSin = 0, sCos = 0; validData.forEach(item => { sSin += Math.sin(item.val); sCos += Math.cos(item.val); });
86
+ let sSin = 0, sCos = 0;
87
+ validData.forEach(item => { sSin += Math.sin(item.val); sCos += Math.cos(item.val); });
76
88
  let R = Math.sqrt(sSin * sSin + sCos * sCos) / validData.length;
77
89
  let isStable = (validData.length > 2) && (validData[validData.length - 1].time - validData[0].time >= windowMs - CONFIG.averages.stabilityTolerance) && (R > CONFIG.averages.stabilityThreshold);
78
- let avgDeg = Math.round(radToDeg(Math.atan2(sSin, sCos)));
79
- return { val: signed ? avgDeg : (avgDeg + 360) % 360, stable: isStable };
90
+ let avgRad = Math.atan2(sSin, sCos);
91
+ return { val: signed ? avgRad : (avgRad + 2 * Math.PI) % (2 * Math.PI), stable: isStable };
80
92
  }
81
93
 
82
- // ==========================================================================
83
- // 4. LOGICA FISICA (VENTO E SICUREZZA)
84
- // ==========================================================================
94
+ /**
95
+ * Calcola il Vento Reale (Acqua e Terra) partendo dagli Apparenti.
96
+ */
97
+ function computeTrueWind() {
98
+ const aws = store.raw["environment.wind.speedApparent"];
99
+ let awa = store.raw["environment.wind.angleApparent"];
100
+ const stw = store.raw["navigation.speedThroughWater"] || 0, sog = store.raw["navigation.speedOverGround"] || 0;
101
+ const hdg = store.raw["navigation.headingTrue"] || 0, cog = store.raw["navigation.courseOverGroundTrue"] || 0;
102
+
103
+ if (aws === undefined || awa === undefined) return;
104
+ if (awa > Math.PI) awa -= 2 * Math.PI;
105
+
106
+ // Logica Acqua (TWA/TWS)
107
+ const tw_water_x = aws * Math.cos(awa) - stw, tw_water_y = aws * Math.sin(awa);
108
+ const tws_water = Math.sqrt(tw_water_x * tw_water_x + tw_water_y * tw_water_y), twa_water = Math.atan2(tw_water_y, tw_water_x);
109
+
110
+ // Logica Terra (TWD) con gestione deriva normalizzata
111
+ const drift_angle = (cog - hdg + Math.PI * 3) % (2 * Math.PI) - Math.PI;
112
+ const sog_vec_x = sog * Math.cos(drift_angle), sog_vec_y = sog * Math.sin(drift_angle);
113
+ const tw_ground_x = aws * Math.cos(awa) - sog_vec_x, tw_ground_y = aws * Math.sin(awa) - sog_vec_y;
114
+ let twd_ground = (hdg + Math.atan2(tw_ground_y, tw_ground_x) + 2 * Math.PI) % (2 * Math.PI);
115
+
116
+ const now = Date.now();
117
+ store.raw["environment.wind.speedTrue"] = tws_water; store.raw["environment.wind.angleTrueWater"] = twa_water; store.raw["environment.wind.directionTrue"] = twd_ground;
118
+
119
+ safePush(store.smoothBuf.twa, twa_water, now); safePush(store.longBuf.twa, twa_water, now);
120
+ safePush(store.smoothBuf.twd, twd_ground, now); safePush(store.longBuf.twd, twd_ground, now);
121
+ }
85
122
 
86
- function playBingBing() {
87
- if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
88
- const n = Date.now(); if (n - lastAlarmTime < 3000) return; lastAlarmTime = n;
89
- function b(f, s) { const o = audioCtx.createOscillator(); const g = audioCtx.createGain(); o.connect(g); g.connect(audioCtx.destination); o.frequency.value = f; g.gain.setValueAtTime(0.1, s); g.gain.exponentialRampToValueAtTime(0.01, s + 0.4); o.start(s); o.stop(s + 0.5); }
90
- b(880, audioCtx.currentTime); b(880, audioCtx.currentTime + 0.6);
123
+ function processIncomingData(path, val) {
124
+ const now = Date.now(); store.timestamps[path] = now; store.raw[path] = val;
125
+ if (path === "navigation.position") store.raw["navigation.position"] = val;
126
+ if (path === "environment.wind.angleApparent") { safePush(store.smoothBuf.awa, val, now); safePush(store.longBuf.awa, val, now); }
127
+
128
+ const twPaths = ["environment.wind.speedApparent", "environment.wind.angleApparent", "navigation.speedThroughWater", "navigation.speedOverGround", "navigation.headingTrue", "navigation.courseOverGroundTrue"];
129
+ if (twPaths.includes(path)) twDirty = true;
130
+ if (twDirty && (now - lastTWCompute > 100)) { computeTrueWind(); lastTWCompute = now; twDirty = false; }
131
+
132
+ if (path === "navigation.headingTrue") { safePush(store.smoothBuf.hdg, val, now); safePush(store.longBuf.hdg, val, now); }
133
+ if (path === "navigation.courseOverGroundTrue") { safePush(store.smoothBuf.cog, val, now); safePush(store.longBuf.cog, val, now); }
91
134
  }
92
135
 
136
+ // ==========================================================================
137
+ // 4. AUDIO E ALLARMI
138
+ // ==========================================================================
139
+ function playBingBing() { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const n = Date.now(); if (n - lastAlarmTime < 3000) return; lastAlarmTime = n; function b(f, s) { const o = audioCtx.createOscillator(); const g = audioCtx.createGain(); o.connect(g); g.connect(audioCtx.destination); o.frequency.value = f; g.gain.setValueAtTime(0.1, s); g.gain.exponentialRampToValueAtTime(0.01, s + 0.4); o.start(s); o.stop(s + 0.5); } b(880, audioCtx.currentTime); b(880, audioCtx.currentTime + 0.6); }
140
+
93
141
  function playGybeAlarm() {
94
142
  if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
95
143
  const n = Date.now(); if (n - lastAlarmTime < 2000) return; lastAlarmTime = n;
@@ -97,65 +145,57 @@ function playGybeAlarm() {
97
145
  for (let i = 0; i < 4; i++) { note(1800, audioCtx.currentTime + (i * 0.15), 0.1); note(1200, audioCtx.currentTime + (i * 0.15) + 0.07, 0.1); }
98
146
  }
99
147
 
100
- function checkDepthAlarm(m) {
101
- ui.depth.classList.remove('alarm-warning', 'alarm-danger');
102
- if (m < CONFIG.alarms.depthDanger) { ui.depth.classList.add('alarm-danger'); playBingBing(); }
103
- else if (m < CONFIG.alarms.depthWarning) ui.depth.classList.add('alarm-warning');
104
- }
105
-
106
- function computeTrueWind() {
107
- const aws = store.raw["environment.wind.speedApparent"];
108
- const awa = store.raw["environment.wind.angleApparent"];
109
- const stw = store.raw["navigation.speedThroughWater"] || 0;
110
- const sog = store.raw["navigation.speedOverGround"] || 0;
111
- const hdg = store.raw["navigation.headingTrue"] || 0;
112
- const cog = store.raw["navigation.courseOverGroundTrue"] || 0;
113
- if (aws === undefined || awa === undefined) return;
114
- const tw_water_x = aws * Math.cos(awa) - stw;
115
- const tw_water_y = aws * Math.sin(awa);
116
- const tws_water = Math.sqrt(tw_water_x * tw_water_x + tw_water_y * tw_water_y);
117
- const twa_water = Math.atan2(tw_water_y, tw_water_x);
118
- const drift_angle = cog - hdg;
119
- const sog_vec_x = sog * Math.cos(drift_angle);
120
- const sog_vec_y = sog * Math.sin(drift_angle);
121
- const tw_ground_x = aws * Math.cos(awa) - sog_vec_x;
122
- const tw_ground_y = aws * Math.sin(awa) - sog_vec_y;
123
- const twd_ground = (hdg + Math.atan2(tw_ground_y, tw_ground_x) + 2 * Math.PI) % (2 * Math.PI);
124
- const now = Date.now();
125
- store.raw["environment.wind.speedTrue"] = tws_water;
126
- store.raw["environment.wind.angleTrueWater"] = twa_water;
127
- store.raw["environment.wind.directionTrue"] = twd_ground;
128
- store.smoothBuf.twa.push({ val: twa_water, time: now });
129
- store.longBuf.twa.push({ val: twa_water, time: now });
130
- store.smoothBuf.twd.push({ val: twd_ground, time: now });
131
- store.longBuf.twd.push({ val: twd_ground, time: now });
132
- }
148
+ function checkDepthAlarm(m) { ui.depth.classList.remove('alarm-warning', 'alarm-danger'); if (m < CONFIG.alarms.depthDanger) { ui.depth.classList.add('alarm-danger'); playBingBing(); } else if (m < CONFIG.alarms.depthWarning) ui.depth.classList.add('alarm-warning'); }
133
149
 
150
+ // ==========================================================================
151
+ // 5. TREND E TATTICA
152
+ // ==========================================================================
153
+ /**
154
+ * Monitoraggio Trend Vento Professionale (Tattica + Meteo + Gybe Safety)
155
+ */
134
156
  function updateWindTrend() {
157
+ const now = Date.now();
135
158
  const twaAvgObj = getCircularAverageFromBuffer(store.longBuf.twa, 60000, true);
136
159
  const shortAvg = getCircularAverageFromBuffer(store.longBuf.twd, 5000, false);
137
160
  const instantTwaRad = store.raw["environment.wind.angleTrueWater"];
161
+
138
162
  if (!shortAvg || !twaAvgObj || instantTwaRad === undefined) return;
139
163
  const instantTwaDeg = radToDeg(instantTwaRad);
140
- if (lastShortAvgVal === null) { lastShortAvgVal = shortAvg.val; lastInstantTwa = instantTwaDeg; return; }
141
- const gybeDetected = (Math.abs(instantTwaDeg) > 150 && Math.abs(lastInstantTwa) > 150 && Math.sign(instantTwaDeg) !== Math.sign(lastInstantTwa));
164
+ const shortAvgDeg = radToDeg(shortAvg.val);
165
+ const twaAvgDeg = radToDeg(twaAvgObj.val);
166
+
167
+ if (lastShortAvgVal === null) { lastShortAvgVal = shortAvgDeg; lastInstantTwa = instantTwaDeg; return; }
168
+ const dt = (now - lastTrendTime) / 1000; lastTrendTime = now;
169
+
170
+ // --- GYBE SAFETY ---
171
+ const gybeDetected = (Math.abs(instantTwaDeg) > 155 && Math.sign(instantTwaDeg) !== Math.sign(lastInstantTwa));
142
172
  lastInstantTwa = instantTwaDeg;
173
+ if (gybeDetected && isNavigating && (now - lastGybeAlarmTime > 5000)) { lastGybeAlarmTime = now; playGybeAlarm(); }
174
+
143
175
  const compassDots = { cw: document.getElementById('trend-dot-cw'), ccw: document.getElementById('trend-dot-ccw') };
144
176
  const gaugeDots = { cw: document.getElementById('trend-gauge-cw'), ccw: document.getElementById('trend-gauge-ccw') };
145
- if (gybeDetected && isNavigating) {
146
- playGybeAlarm();
177
+
178
+ if (now - lastGybeAlarmTime < 4000 && isNavigating) {
147
179
  [compassDots.cw, compassDots.ccw, gaugeDots.cw, gaugeDots.ccw].forEach(el => { if (el) { el.classList.add('is-gybing'); el.classList.remove('is-trending'); el.setAttribute('fill', '#ff0000'); }});
148
180
  return;
149
181
  }
150
- let diff = (shortAvg.val - lastShortAvgVal + 540) % 360 - 180;
151
- lastShortAvgVal = shortAvg.val;
152
- rotationTrend = (rotationTrend * 0.95) + (diff * 0.05);
182
+
183
+ // --- TREND CALCULATION (°/sec) ---
184
+ let diff = (shortAvgDeg - lastShortAvgVal + 540) % 360 - 180; lastShortAvgVal = shortAvgDeg;
185
+ if (dt > 0) {
186
+ let currentRate = Math.max(-10, Math.min(10, diff / dt)); // Clamp a 10°/sec per evitare rumore
187
+ const alpha = Math.min(1, dt / 5);
188
+ rotationTrend = rotationTrend * (1 - alpha) + currentRate * alpha;
189
+ }
190
+
153
191
  if (Math.abs(rotationTrend) > 1.0) {
154
- const pos = store.raw["navigation.position"];
155
- const isSouthernHemisphere = pos && pos.latitude < 0;
156
- let meteoColor = (!isSouthernHemisphere) ? (rotationTrend < 0 ? "#00ff00" : "#ff0000") : (rotationTrend > 0 ? "#00ff00" : "#ff0000");
157
- let isLift = (twaAvgObj.val > 0) ? (rotationTrend > 0) : (rotationTrend < 0);
192
+ const pos = store.raw["navigation.position"], isSouth = pos && pos.latitude < 0;
193
+ let meteoColor = (!isSouth) ? (rotationTrend < 0 ? "#00ff00" : "#ff0000") : (rotationTrend > 0 ? "#00ff00" : "#ff0000");
194
+ let isLift = (twaAvgDeg > 0) ? (rotationTrend > 0) : (rotationTrend < 0);
195
+ const absTwa = Math.abs(twaAvgDeg);
196
+ if (absTwa >= 90 && absTwa < 160) isLift = !isLift; else if (absTwa >= 160) isLift = false;
158
197
  const tacticColor = isLift ? "#00ff00" : "#ff0000";
198
+
159
199
  if (rotationTrend > 0) {
160
200
  if (compassDots.cw) { compassDots.cw.classList.add('is-trending'); compassDots.cw.setAttribute('fill', meteoColor); }
161
201
  if (gaugeDots.cw) { gaugeDots.cw.classList.add('is-trending'); gaugeDots.cw.setAttribute('fill', tacticColor); }
@@ -171,60 +211,72 @@ function updateWindTrend() {
171
211
  }
172
212
 
173
213
  // ==========================================================================
174
- // 5. DATA ROUTING
175
- // ==========================================================================
176
- function processIncomingData(path, val) {
177
- const now = Date.now(); store.timestamps[path] = now; store.raw[path] = val;
178
- if (path === "navigation.position") store.raw["navigation.position"] = val;
179
- if (path === "environment.wind.angleApparent") { store.smoothBuf.awa.push({ val: val, time: now }); store.longBuf.awa.push({ val: val, time: now }); }
180
- if (path === "environment.wind.speedApparent" || path === "environment.wind.angleApparent" || path === "navigation.speedThroughWater") computeTrueWind();
181
- if (path === "navigation.headingTrue") { store.smoothBuf.hdg.push({ val: val, time: now }); store.longBuf.hdg.push({ val: val, time: now }); }
182
- if (path === "navigation.courseOverGroundTrue") { store.smoothBuf.cog.push({ val: val, time: now }); store.longBuf.cog.push({ val: val, time: now }); }
183
- }
184
-
185
- // ==========================================================================
186
- // 6. MOTORE RENDERING UI
214
+ // 6. RENDERING E LOOP PRINCIPALE
187
215
  // ==========================================================================
188
216
  function updateLeewayDisplay(deg) { const c = 125, px = 125/20; let w = Math.min(Math.abs(deg)*px, 125); ui.leewayMask.setAttribute('x', deg >= 0 ? c : c - w); ui.leewayMask.setAttribute('width', w); ui.leewayVal.textContent = `LEEWAY: ${deg.toFixed(1)}°`; }
189
217
 
190
218
  function startDisplayLoop() {
219
+ let tick = 0; // Contatore per i cicli di rendering
220
+
191
221
  renderInterval = setInterval(() => {
192
222
  const now = Date.now();
223
+ tick++;
224
+
225
+ // ==========================================================
226
+ // LIVE TIER (Ogni 1s) - Reattività, Numeri, Allarmi, Lancette
227
+ // ==========================================================
193
228
  const pathsToWatch = { "navigation.speedThroughWater": ui.stw, "navigation.speedOverGround": ui.sog, "navigation.headingTrue": ui.hdg, "navigation.courseOverGroundTrue": ui.cog, "environment.wind.speedApparent": ui.awsSvg, "environment.depth.belowTransducer": ui.depth, "environment.wind.speedTrue": ui.tws };
194
229
  for (let p in pathsToWatch) { if (!store.timestamps[p] || (now - store.timestamps[p] > TIMEOUT_MS)) { if (pathsToWatch[p] === ui.awsSvg) ui.awsSvg.textContent = "---"; else pathsToWatch[p].innerText = "---"; delete store.raw[p]; } }
195
230
 
196
- const stwKts = msToKts(store.raw["navigation.speedThroughWater"] || 0);
197
- const sogKts = msToKts(store.raw["navigation.speedOverGround"] || 0);
231
+ const stwKts = msToKts(store.raw["navigation.speedThroughWater"] || 0), sogKts = msToKts(store.raw["navigation.speedOverGround"] || 0);
198
232
  isNavigating = stwKts > CONFIG.averages.minSpeed || sogKts > CONFIG.averages.minSpeed;
199
233
 
234
+ // Numeri veloci
200
235
  if (store.raw["navigation.speedThroughWater"] !== undefined) { ui.stw.innerText = stwKts.toFixed(1); manageHistory('stw', stwKts); }
201
236
  if (store.raw["navigation.speedOverGround"] !== undefined) {
202
- ui.sog.innerText = sogKts.toFixed(1);
203
- let sogColor = "#fff"; const currentEffect = sogKts - stwKts;
204
- if (currentEffect > 0.3) sogColor = "#2ecc71"; else if (currentEffect < -0.3) sogColor = "#e74c3c";
205
- ui.sog.style.color = sogColor; manageHistory('sog', sogKts);
237
+ const twaRad = store.raw["environment.wind.angleTrueWater"];
238
+ const vmgKts = (twaRad !== undefined) ? Math.abs(stwKts * Math.cos(twaRad)) : 0;
239
+ manageHistory('vmg', vmgKts); manageHistory('sog', sogKts);
240
+ if (displayModeSog === 'VMG') { ui.sog.innerText = vmgKts.toFixed(1); ui.sog.style.color = "#64ffda"; document.getElementById('sog-vmg-label').textContent = 'VMG'; }
241
+ else { ui.sog.innerText = sogKts.toFixed(1); ui.sog.style.color = (sogKts-stwKts > 0.3) ? "#2ecc71" : (sogKts-stwKts < -0.3 ? "#e74c3c" : "#fff"); document.getElementById('sog-vmg-label').textContent = 'SOG'; }
206
242
  }
207
243
  if (store.raw["environment.depth.belowTransducer"] !== undefined) { const d = store.raw["environment.depth.belowTransducer"]; ui.depth.innerText = d.toFixed(1); checkDepthAlarm(d); manageHistory('depth', d); }
208
- if (store.raw["environment.wind.speedTrue"] !== undefined) {
209
- const twsKts = msToKts(store.raw["environment.wind.speedTrue"]); ui.tws.innerText = twsKts.toFixed(1);
210
- ui.tws.style.color = (twsKts >= CONFIG.graphs.reef2) ? "#e74c3c" : (twsKts >= CONFIG.graphs.reef1 ? "#e67e22" : "#fff");
211
- manageHistory('tws', twsKts);
212
- }
244
+ if (store.raw["environment.wind.speedTrue"] !== undefined) { const twsKts = msToKts(store.raw["environment.wind.speedTrue"]); ui.tws.innerText = twsKts.toFixed(1); ui.tws.style.color = (twsKts >= CONFIG.graphs.reef2) ? "#e74c3c" : (twsKts >= CONFIG.graphs.reef1 ? "#e67e22" : "#fff"); manageHistory('tws', twsKts); }
213
245
  if (store.raw["environment.wind.speedApparent"] !== undefined) ui.awsSvg.textContent = msToKts(store.raw["environment.wind.speedApparent"]).toFixed(1);
214
246
 
247
+ // Lancette (Sempre fluide ogni 1s)
215
248
  const smAwa = getCircularAverageFromBuffer(store.smoothBuf.awa, CONFIG.averages.smoothWindow, true);
216
- if (smAwa) { curAwaRot = getShortestRotation(curAwaRot, smAwa.val); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
249
+ if (smAwa) { curAwaRot = getShortestRotation(curAwaRot, radToDeg(smAwa.val)); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
217
250
  const smTwa = getCircularAverageFromBuffer(store.smoothBuf.twa, CONFIG.averages.smoothWindow, true);
218
- if (smTwa) { curTwaRot = getShortestRotation(curTwaRot, smTwa.val); ui.twa.setAttribute('transform', `rotate(${curTwaRot}, 200, 200)`); }
251
+ if (smTwa) { curTwaRot = getShortestRotation(curTwaRot, radToDeg(smTwa.val)); ui.twa.setAttribute('transform', `rotate(${curTwaRot}, 200, 200)`); }
219
252
 
253
+ // Leeway (Ogni 1s)
220
254
  if (store.raw["navigation.courseOverGroundTrue"] !== undefined && store.raw["navigation.headingTrue"] !== undefined) {
221
- let drift = (radToDeg(store.raw["navigation.courseOverGroundTrue"]) - radToDeg(store.raw["navigation.headingTrue"]) + 540) % 360 - 180;
222
- if (sogKts < CONFIG.averages.minSpeed) smoothedLeeway = 0; else smoothedLeeway = (smoothedLeeway * 0.9) + (drift * 0.1);
255
+ let driftRad = (store.raw["navigation.courseOverGroundTrue"] - store.raw["navigation.headingTrue"] + Math.PI * 3) % (Math.PI * 2) - Math.PI;
256
+ let driftDeg = radToDeg(driftRad);
257
+ if (sogKts < CONFIG.averages.minSpeed) smoothedLeeway = 0; else smoothedLeeway = (smoothedLeeway * 0.9) + (driftDeg * 0.1);
223
258
  curTrackRot = getShortestRotation(curTrackRot, smoothedLeeway); ui.track.setAttribute('transform', `rotate(${curTrackRot}, 200, 200)`);
259
+ const isContaminated = Math.abs(sogKts - stwKts) > 0.5 && Math.abs(smoothedLeeway) > 7;
260
+ ui.leewayVal.style.color = isContaminated ? "#f39c12" : "#fff";
224
261
  updateLeewayDisplay(Math.max(-20, Math.min(20, smoothedLeeway)));
225
- } else updateLeewayDisplay(0);
262
+ }
263
+
264
+ updateWindTrend(); // Trend calcolato ogni secondo
265
+
266
+ // ==========================================================
267
+ // HEAVY TIER (Ogni 2s) - Disegno Sparklines SVG
268
+ // ==========================================================
269
+ if (tick % 2 === 0) {
270
+ refreshGraph('stw');
271
+ refreshGraph(displayModeSog === 'VMG' ? 'vmg' : 'sog');
272
+ refreshGraph('depth');
273
+ refreshGraph('tws');
274
+ }
226
275
 
227
- if (now - lastAvgUIUpdate > 3000) {
276
+ // ==========================================================
277
+ // SLOW TIER (Ogni 3s) - Medie MEAN e Calcoli TACK
278
+ // ==========================================================
279
+ if (tick % 3 === 0) {
228
280
  let hObj = getCircularAverageFromBuffer(store.longBuf.hdg, CONFIG.averages.longWindow, false),
229
281
  cObj = getCircularAverageFromBuffer(store.longBuf.cog, CONFIG.averages.longWindow, false),
230
282
  awObj = getCircularAverageFromBuffer(store.longBuf.awa, CONFIG.averages.longWindow, true),
@@ -234,26 +286,39 @@ function startDisplayLoop() {
234
286
  const upUI = (el, obj, isCompass = false) => {
235
287
  if (!obj || obj.val === null) { el.innerHTML = "---&deg;"; el.classList.remove('unstable-data'); }
236
288
  else {
237
- let val = Math.round(obj.val); el.innerHTML = (isCompass ? ((val + 360) % 360).toString().padStart(3, '0') : val) + "&deg;";
289
+ let valDeg = Math.round(radToDeg(obj.val)); el.innerHTML = (isCompass ? ((valDeg + 360) % 360).toString().padStart(3, '0') : valDeg) + "&deg;";
238
290
  if (obj.stable || !isNavigating) el.classList.remove('unstable-data'); else el.classList.add('unstable-data');
239
291
  }
240
292
  };
241
293
  upUI(ui.hdg, hObj, true); upUI(ui.cog, cObj, true); upUI(ui.awaAvg, awObj, false); upUI(ui.twaAvg, twObj, false); upUI(ui.twdAvg, twdObj, true);
294
+
242
295
  if (hObj && twObj && hObj.val !== null) {
243
- const tA = twObj.val * 2;
244
- ui.tackHdg.innerHTML = `${Math.round((hObj.val - tA + 360) % 360).toString().padStart(3, '0')}&deg;`;
245
- if (cObj) ui.tackCog.innerHTML = `${Math.round((cObj.val - tA + 360) % 360).toString().padStart(3, '0')}&deg;`;
296
+ const tackHdgDeg = radToDeg((hObj.val - (twObj.val * 2) + Math.PI * 2) % (Math.PI * 2));
297
+ ui.tackHdg.innerHTML = `${Math.round((tackHdgDeg + 360) % 360).toString().padStart(3, '0')}&deg;`;
298
+ if (cObj) {
299
+ const tackCogDeg = radToDeg((cObj.val - (twObj.val * 2) + Math.PI * 2) % (Math.PI * 2));
300
+ ui.tackCog.innerHTML = `${Math.round((tackCogDeg + 360) % 360).toString().padStart(3, '0')}&deg;`;
301
+ }
302
+ }
303
+
304
+ const smHdg = getCircularAverageFromBuffer(store.smoothBuf.hdg, 2000, false), smTwd = getCircularAverageFromBuffer(store.smoothBuf.twd, 2000, false);
305
+ if (smHdg && smTwd) {
306
+ curWindCompassRot = getShortestRotation(curWindCompassRot, radToDeg(smTwd.val)); ui.twdArrow.setAttribute('transform', `rotate(${curWindCompassRot}, 20, 20)`);
307
+ curBoatCompassRot = getShortestRotation(curBoatCompassRot, radToDeg(smHdg.val)); ui.twdBoat.setAttribute('transform', `rotate(${curBoatCompassRot}, 20, 20)`);
246
308
  }
247
- if (twdObj && hObj) { updateWindTrend(); curTwdRoseRot = getShortestRotation(curTwdRoseRot, twdObj.val); ui.twdArrow.setAttribute('transform', `rotate(${curTwdRoseRot}, 20, 20)`); ui.twdBoat.setAttribute('transform', `rotate(${hObj.val}, 20, 20)`); }
248
- lastAvgUIUpdate = now;
249
309
  }
250
- for (let b in store.smoothBuf) { while (store.smoothBuf[b].length > 0 && (now - store.smoothBuf[b][0].time) > CONFIG.averages.smoothWindow) store.smoothBuf[b].shift(); }
251
- for (let b in store.longBuf) { while (store.longBuf[b].length > 0 && (now - store.longBuf[b][0].time) > CONFIG.averages.longWindow) store.longBuf[b].shift(); }
310
+
311
+ // Pulizia buffer
312
+ if (tick % 60 === 0) { // Ogni minuto pulisce tutto per sicurezza
313
+ for (let b in store.smoothBuf) { while (store.smoothBuf[b].length > 0 && (now - store.smoothBuf[b][0].time) > CONFIG.averages.smoothWindow) store.smoothBuf[b].shift(); }
314
+ for (let b in store.longBuf) { while (store.longBuf[b].length > 0 && (now - store.longBuf[b][0].time) > CONFIG.averages.longWindow) store.longBuf[b].shift(); }
315
+ tick = 0; // Reset contatore
316
+ }
252
317
  }, RENDER_INTERVAL_MS);
253
318
  }
254
319
 
255
320
  // ==========================================================================
256
- // 7. CONFIG E GRAFICI
321
+ // 7. CONFIGURAZIONE E GRAFICI
257
322
  // ==========================================================================
258
323
  async function fetchServerConfig() {
259
324
  if (!window.location.protocol.includes("http")) return;
@@ -278,11 +343,43 @@ async function fetchServerConfig() {
278
343
  }
279
344
  }
280
345
 
346
+ /**
347
+ * Gestisce l'archiviazione dei dati storici.
348
+ * Il disegno grafico viene ora gestito separatamente nel loop pesante.
349
+ */
281
350
  function manageHistory(t, v) {
282
- const n = Date.now(); const interval = simulationMode ? SIM_SAMPLE_INTERVAL : (CONFIG.graphs.historyMinutes * 60000) / CONFIG.graphs.samples;
283
- if (n - store.lastUpdates[t] > interval || store.histories[t].length === 0) { store.histories[t].push(v); if (store.histories[t].length > CONFIG.graphs.samples) store.histories[t].shift(); store.lastUpdates[t] = n; }
284
- const mode = graphModes[t], cfg = calculateScale(t, store.histories[t], mode);
285
- updateScaleLabels(t, cfg.min, cfg.max); drawGraph(store.histories[t], t + '-graph', cfg.min, cfg.max, t === 'tws', mode === 'hercules');
351
+ const n = Date.now();
352
+ const interval = simulationMode ? SIM_SAMPLE_INTERVAL : (CONFIG.graphs.historyMinutes * 60000) / CONFIG.graphs.samples;
353
+
354
+ if (n - store.lastUpdates[t] > interval || store.histories[t].length === 0) {
355
+ store.histories[t].push(v);
356
+ if (store.histories[t].length > CONFIG.graphs.samples) store.histories[t].shift();
357
+ store.lastUpdates[t] = n;
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Funzione dedicata al ridisegno dei grafici (chiamata dal loop HEAVY)
363
+ */
364
+ function refreshGraph(t) {
365
+ const type = (t === 'vmg') ? 'sog' : t;
366
+ const data = store.histories[t];
367
+ if (!data || data.length < 2) return;
368
+
369
+ const mode = graphModes[type];
370
+ const cfg = calculateScale(type, data, mode);
371
+
372
+ // Aggiornamento stile box Hercules
373
+ const graphEl = document.getElementById(type + '-graph');
374
+ if (graphEl) {
375
+ const box = graphEl.closest('.data-box');
376
+ if (box) {
377
+ if (mode === 'hercules') box.classList.add('box-hercules');
378
+ else box.classList.remove('box-hercules');
379
+ }
380
+ }
381
+ updateScaleLabels(type, cfg.min, cfg.max);
382
+ drawGraph(data, type + '-graph', cfg.min, cfg.max, t === 'tws', mode === 'hercules');
286
383
  }
287
384
 
288
385
  function calculateScale(type, data, mode) {
@@ -308,14 +405,13 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
308
405
  }
309
406
  });
310
407
  const clrs = { 'stw-graph': '#2ecc71', 'sog-graph': '#f39c12', 'depth-graph': '#3498db', 'tws-graph': '#ffffff' };
311
- svg.innerHTML = isTws ? `${grids}<path d="${pD} L ${((d.length-1)/(CONFIG.graphs.samples-1))*w} ${h} L 0 ${h} Z" fill="rgba(255,255,255,0.08)" stroke="none" />${cS}` : `${grids}<path d="${pD} L ${((d.length-1)/(CONFIG.graphs.samples-1))*w} ${h} L 0 ${h} Z" fill="${clrs[id]}22" stroke="none" /><path d="${pD}" class="${isHercules?'line-hercules':''}" fill="none" stroke="${clrs[id]}" />`;
408
+ const colorKey = id === 'sog-graph' && displayModeSog === 'VMG' ? '#64ffda' : clrs[id];
409
+ svg.innerHTML = isTws ? `${grids}<path d="${pD} L ${((d.length-1)/(CONFIG.graphs.samples-1))*w} ${h} L 0 ${h} Z" fill="rgba(255,255,255,0.08)" stroke="none" />${cS}` : `${grids}<path d="${pD} L ${((d.length-1)/(CONFIG.graphs.samples-1))*w} ${h} L 0 ${h} Z" fill="${colorKey}22" stroke="none" /><path d="${pD}" class="${isHercules?'line-hercules':''}" fill="none" stroke="${colorKey}" />`;
312
410
  }
313
411
 
314
412
  // ==========================================================================
315
- // 8. INTERAZIONI
413
+ // 8. INTERAZIONI E SIMULATORE
316
414
  // ==========================================================================
317
- window.addEventListener('contextmenu', e => e.preventDefault(), true);
318
-
319
415
  function toggleFocusMode(type, element) {
320
416
  const container = document.querySelector('.main-container'); const parentPanel = element.closest('.side-panel'); const isLeft = parentPanel.classList.contains('left-panel');
321
417
  isFocusActive = !isFocusActive;
@@ -324,14 +420,16 @@ function toggleFocusMode(type, element) {
324
420
  }
325
421
 
326
422
  ['stw', 'sog', 'tws', 'depth'].forEach(type => {
327
- const el = document.getElementById(type + '-graph').closest('.data-box'); let lastTapTime = 0, tapTimeout;
328
- el.addEventListener('pointerdown', (e) => {
329
- if (e.cancelable) e.preventDefault(); const currentTime = new Date().getTime(); const tapDelay = currentTime - lastTapTime;
330
- pressTimer = setTimeout(() => { if (!isFocusActive) { toggleFocusMode(type, el); lastTapTime = 0; } }, 1000);
423
+ const el = document.getElementById(type + '-graph').closest('.data-box');
424
+ let lastTapTime = 0, tapTimeout, isLongPressActive = false;
425
+ el.addEventListener('pointerdown', (e) => { isLongPressActive = false; pressTimer = setTimeout(() => { if (!isFocusActive) { isLongPressActive = true; toggleFocusMode(type, el); lastTapTime = 0; } }, 1000); });
426
+ el.addEventListener('pointerup', (e) => {
427
+ clearTimeout(pressTimer); if (isLongPressActive) return;
428
+ const currentTime = new Date().getTime(), tapDelay = currentTime - lastTapTime;
331
429
  if (tapDelay < 300 && tapDelay > 0) { clearTimeout(tapTimeout); if (!isFocusActive) { graphModes[type] = graphModes[type] === 'standard' ? 'hercules' : 'standard'; localStorage.setItem('mode_' + type, graphModes[type]); } lastTapTime = 0; }
332
- else { lastTapTime = currentTime; tapTimeout = setTimeout(() => { if (isFocusActive && el.classList.contains('is-focused')) toggleFocusMode(type, el); }, 350); }
430
+ else { lastTapTime = currentTime; tapTimeout = setTimeout(() => { if (isFocusActive && el.classList.contains('is-focused')) toggleFocusMode(type, el); else if (!isFocusActive && type === 'sog') { displayModeSog = (displayModeSog === 'SOG') ? 'VMG' : 'SOG'; el.style.backgroundColor = "rgba(100, 255, 218, 0.1)"; setTimeout(() => el.style.backgroundColor = "", 150); } }, 250); }
333
431
  });
334
- el.addEventListener('pointerup', () => clearTimeout(pressTimer)); el.addEventListener('pointerleave', () => clearTimeout(pressTimer));
432
+ el.addEventListener('pointerleave', () => clearTimeout(pressTimer));
335
433
  });
336
434
 
337
435
  if (ui.hotspot) {
@@ -340,25 +438,53 @@ if (ui.hotspot) {
340
438
  ui.hotspot.addEventListener('pointerleave', () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } });
341
439
  }
342
440
 
343
- // ==========================================================================
344
- // 9. CONNESSIONE E SIMULATORE
345
- // ==========================================================================
346
441
  function connect() {
347
442
  if (simulationMode) return;
443
+
348
444
  let addr = (window.location.protocol.includes("http")) ? window.location.host : CONFIG.server.fallbackIp;
445
+ const protocol = (window.location.protocol === 'https:') ? 'wss' : 'ws';
446
+
349
447
  try {
350
- socket = new WebSocket(`ws://${addr}/signalk/v1/stream?subscribe=self`);
351
- socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; };
352
- socket.onmessage = (e) => { if (simulationMode) return; const d = JSON.parse(e.data); if (d.updates) d.updates.forEach(u => u.values && u.values.forEach(v => processIncomingData(v.path, v.value))); };
353
- socket.onclose = () => !simulationMode && setTimeout(connect, 5000);
354
- } catch (e) { setTimeout(connect, 5000); }
448
+ socket = new WebSocket(`${protocol}://${addr}/signalk/v1/stream?subscribe=self`);
449
+
450
+ socket.onopen = () => {
451
+ ui.status.className = "online";
452
+ ui.status.innerText = "ONLINE";
453
+ reconnectDelay = 1000; // Reset del ritardo dopo una connessione riuscita
454
+ };
455
+
456
+ socket.onmessage = (e) => {
457
+ if (simulationMode) return;
458
+ const d = JSON.parse(e.data);
459
+ if (d.updates) d.updates.forEach(u => u.values && u.values.forEach(v => processIncomingData(v.path, v.value)));
460
+ };
461
+
462
+ socket.onclose = () => {
463
+ if (!simulationMode) {
464
+ ui.status.className = "offline";
465
+ ui.status.innerText = "RECONNECTING...";
466
+
467
+ // Exponential Backoff: aumenta il tempo ad ogni tentativo fallito
468
+ setTimeout(connect, reconnectDelay);
469
+ reconnectDelay = Math.min(reconnectDelay * 1.5, 10000); // Max 10 secondi
470
+ }
471
+ };
472
+
473
+ socket.onerror = () => {
474
+ socket.close(); // Forza il trigger di onclose
475
+ };
476
+
477
+ } catch (e) {
478
+ setTimeout(connect, reconnectDelay);
479
+ reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
480
+ }
355
481
  }
356
482
 
357
483
  ui.depth.closest('.data-box').addEventListener('click', (function() { let dC = 0, lC = 0; return function() { const n = Date.now(); if (n - lC < 500) dC++; else dC = 1; lC = n; if (dC === 3) { simulationMode = !simulationMode; if (simulationMode) { if (socket) socket.close(); startDynamicSimulation(); } else location.reload(); dC = 0; } }; })());
358
484
 
359
485
  function startDynamicSimulation() {
360
486
  ui.status.innerText = "SIM ATTIVO";
361
- let sim = { hdg: 45, tws: 12, twd: 45, depth: 12, stw: 5, leeway: 0, startTime: Date.now() };
487
+ let sim = { hdg: 45, tws: 12, twd: 45, depth: 12, stw: 5, leeway: 0, currentSpeed: 1.5, currentDir: 90, startTime: Date.now() };
362
488
  simInterval = setInterval(() => {
363
489
  const elapsed = (Date.now() - sim.startTime) / 1000;
364
490
  if (elapsed > 120 && elapsed < 121) sim.twd = Math.random() * 360;
@@ -368,19 +494,22 @@ function startDynamicSimulation() {
368
494
  let twaRel = (sim.twd - sim.hdg + 360) % 360; if (twaRel > 180) twaRel -= 360;
369
495
  let targetStw = 3 + (4 * Math.sin((Math.abs(twaRel) - 45) * Math.PI / 125)); sim.stw += (Math.max(3, Math.min(8, targetStw)) - sim.stw) * 0.05;
370
496
  const rawLeeway = Math.sin(degToRad(twaRel)) * 4; sim.leeway += (rawLeeway - sim.leeway) * 0.05;
371
- const cog = (sim.hdg - sim.leeway + 360) % 360; const twaRad = degToRad(twaRel);
372
- const aws = Math.sqrt(Math.pow(sim.stw, 2) + Math.pow(sim.tws, 2) + 2 * sim.stw * sim.tws * Math.cos(twaRad));
497
+ const bX = sim.stw * Math.sin(degToRad(sim.hdg + sim.leeway)), bY = sim.stw * Math.cos(degToRad(sim.hdg + sim.leeway));
498
+ const cX = sim.currentSpeed * Math.sin(degToRad(sim.currentDir)), cY = sim.currentSpeed * Math.cos(degToRad(sim.currentDir));
499
+ const sog = Math.sqrt(Math.pow(bX + cX, 2) + Math.pow(bY + cY, 2)), cog = (radToDeg(Math.atan2(bX + cX, bY + cY)) + 360) % 360;
500
+ const twaRad = degToRad(twaRel), aws = Math.sqrt(Math.pow(sim.stw, 2) + Math.pow(sim.tws, 2) + 2 * sim.stw * sim.tws * Math.cos(twaRad));
373
501
  const awa = Math.atan2(sim.tws * Math.sin(twaRad), sim.stw + sim.tws * Math.cos(twaRad));
374
502
  processIncomingData("environment.wind.speedApparent", ktsToMs(aws)); processIncomingData("environment.wind.angleApparent", awa);
375
503
  processIncomingData("environment.depth.belowTransducer", sim.depth); processIncomingData("navigation.headingTrue", degToRad(sim.hdg));
376
- processIncomingData("navigation.speedThroughWater", ktsToMs(sim.stw)); processIncomingData("navigation.speedOverGround", ktsToMs(sim.stw));
504
+ processIncomingData("navigation.speedThroughWater", ktsToMs(sim.stw)); processIncomingData("navigation.speedOverGround", ktsToMs(sog));
377
505
  processIncomingData("navigation.courseOverGroundTrue", degToRad(cog)); processIncomingData("navigation.position", { latitude: 45.0, longitude: 12.0 });
378
506
  }, 1000);
379
507
  }
380
508
 
381
509
  // ==========================================================================
382
- // 10. INIT
510
+ // 9. INIT
383
511
  // ==========================================================================
512
+ window.addEventListener('contextmenu', e => e.preventDefault(), true);
384
513
  (function genTicks() { const c = document.getElementById('ticks'); if (c) { for (let i = 0; i < 360; i += 10) { const l = document.createElementNS("http://www.w3.org/2000/svg", "line"); const m = i % 30 === 0; l.setAttribute("x1", "200"); l.setAttribute("y1", "40"); l.setAttribute("x2", "200"); l.setAttribute("y2", (m ? 60 : 50)); l.setAttribute("stroke", m ? "#fff" : "#666"); l.setAttribute("stroke-width", m ? "2" : "1"); l.setAttribute("transform", `rotate(${i}, 200, 200)`); c.appendChild(l); } } })();
385
514
  async function init() { await fetchServerConfig(); startDisplayLoop(); connect(); }
386
515
  window.addEventListener('load', init);