@sailingrotevista/rotevista-dash 2.0.15 → 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 +356 -367
  2. package/index.html +37 -83
  3. package/package.json +1 -1
  4. package/style.css +82 -248
package/app.js CHANGED
@@ -1,33 +1,12 @@
1
1
  // ==========================================================================
2
- // 1. CONFIGURAZIONE E DEFAULT (Sincronizzato con il Plugin SignalK)
2
+ // 1. CONFIGURAZIONE E DEFAULT
3
3
  // ==========================================================================
4
4
  let CONFIG = {
5
- alarms: {
6
- depthDanger: 2.5,
7
- depthWarning: 5.0
8
- },
9
- averages: {
10
- smoothWindow: 2000,
11
- longWindow: 60000,
12
- stabilityTolerance: 2000,
13
- stabilityThreshold: 0.90,
14
- minSpeed: 0.5
15
- },
16
- graphs: {
17
- reef1: 15.0,
18
- reef2: 20.0,
19
- historyMinutes: 5,
20
- samples: 60
21
- },
22
- scales: {
23
- stw: { stdMax: 12, hercSpan: 4, step: 2 },
24
- sog: { stdMax: 12, hercSpan: 4, step: 2 },
25
- tws: { stdMax: 25, hercSpan: 10, step: 5 },
26
- depth: { stdMax: 20, hercSpan: 10, step: 10 }
27
- },
28
- server: {
29
- fallbackIp: "192.168.111.240:3000"
30
- }
5
+ alarms: { depthDanger: 2.5, depthWarning: 5.0 },
6
+ averages: { smoothWindow: 2000, longWindow: 60000, stabilityTolerance: 2000, stabilityThreshold: 0.90, minSpeed: 0.5 },
7
+ graphs: { reef1: 15.0, reef2: 20.0, historyMinutes: 5, samples: 60 },
8
+ scales: { stw: { stdMax: 12, hercSpan: 4, step: 2 }, sog: { stdMax: 12, hercSpan: 4, step: 2 }, tws: { stdMax: 25, hercSpan: 10, step: 5 }, depth: { stdMax: 20, hercSpan: 10, step: 10 } },
9
+ server: { fallbackIp: "192.168.111.240:3000" }
31
10
  };
32
11
 
33
12
  const RENDER_INTERVAL_MS = 1000;
@@ -38,14 +17,20 @@ const SIM_SAMPLE_INTERVAL = 1000;
38
17
  // 2. STATO GLOBALE E RIFERIMENTI UI
39
18
  // ==========================================================================
40
19
  let simulationMode = false;
20
+ let displayModeSog = 'SOG'; // 'SOG' o 'VMG'
41
21
  let socket, renderInterval, simInterval;
42
22
  let lastAvgUIUpdate = 0, audioCtx = null, lastAlarmTime = 0;
43
23
  let curAwaRot = 0, curTwaRot = 0, curTrackRot = 0, curTwdRoseRot = 0;
24
+ let curBoatCompassRot = 0, curWindCompassRot = 0;
25
+
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;
31
+ let isNavigating = false;
44
32
 
45
- // Variabili di stato per filtri e trend
46
- let smoothedLeeway = 0;
47
- let rotationTrend = 0;
48
- let pressTimer, isFocusActive = false, blockNextClick = false;
33
+ let pressTimer, isFocusActive = false;
49
34
 
50
35
  const graphModes = {
51
36
  stw: localStorage.getItem('mode_stw') || 'standard',
@@ -58,8 +43,8 @@ const store = {
58
43
  raw: {}, timestamps: {},
59
44
  smoothBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
60
45
  longBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
61
- histories: { stw: [], sog: [], depth: [], tws: [] },
62
- 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 }
63
48
  };
64
49
 
65
50
  const ui = {
@@ -78,34 +63,7 @@ const ui = {
78
63
  };
79
64
 
80
65
  // ==========================================================================
81
- // 3. CARICAMENTO CONFIGURAZIONE DAL SERVER
82
- // ==========================================================================
83
- async function fetchServerConfig() {
84
- if (!window.location.protocol.includes("http")) return;
85
- const pluginID = 'rotevista-dash';
86
- const possibleUrls = [`/skServer/plugins/${pluginID}/config`, `/plugins/${pluginID}/config` ];
87
- for (let url of possibleUrls) {
88
- try {
89
- const response = await fetch(url);
90
- if (response.ok) {
91
- const data = await response.json();
92
- const actual = data.configuration || data;
93
- if (actual && typeof actual === 'object') {
94
- const parseNumbers = (obj) => { for (let k in obj) { if (typeof obj[k] === 'object') parseNumbers(obj[k]); else if (!isNaN(obj[k]) && obj[k] !== "") obj[k] = parseFloat(obj[k]); } };
95
- parseNumbers(actual);
96
- if (actual.alarms) CONFIG.alarms = { ...CONFIG.alarms, ...actual.alarms };
97
- if (actual.graphs) CONFIG.graphs = { ...CONFIG.graphs, ...actual.graphs };
98
- if (actual.averaging) CONFIG.averages = { ...CONFIG.averages, ...actual.averaging };
99
- if (actual.scales) { for (let key in actual.scales) { CONFIG.scales[key] = { ...CONFIG.scales[key], ...actual.scales[key] }; } }
100
- console.log("SUCCESS: Config loaded."); return;
101
- }
102
- }
103
- } catch (e) { }
104
- }
105
- }
106
-
107
- // ==========================================================================
108
- // 4. MATEMATICA E GESTIONE DATI
66
+ // 3. UTILITIES DI SISTEMA (MATEMATICA E BUFFER)
109
67
  // ==========================================================================
110
68
  function radToDeg(rad) { return rad * (180 / Math.PI); }
111
69
  function degToRad(deg) { return deg * (Math.PI / 180); }
@@ -113,116 +71,212 @@ function msToKts(ms) { return ms * 1.94384; }
113
71
  function ktsToMs(kts) { return kts / 1.94384; }
114
72
  function getShortestRotation(curr, target) { let diff = (target - curr) % 360; if (diff > 180) diff -= 360; else if (diff < -180) diff += 360; return curr + diff; }
115
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
+
116
82
  function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false) {
117
- 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);
118
85
  if (validData.length === 0) return null;
119
- 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); });
120
88
  let R = Math.sqrt(sSin * sSin + sCos * sCos) / validData.length;
121
- let isStable = (validData[validData.length - 1].time - validData[0].time >= windowMs - CONFIG.averages.stabilityTolerance) && (R > CONFIG.averages.stabilityThreshold);
122
- let avgDeg = Math.round(radToDeg(Math.atan2(sSin, sCos)));
123
- return { val: signed ? avgDeg : (avgDeg + 360) % 360, stable: isStable };
89
+ let isStable = (validData.length > 2) && (validData[validData.length - 1].time - validData[0].time >= windowMs - CONFIG.averages.stabilityTolerance) && (R > CONFIG.averages.stabilityThreshold);
90
+ let avgRad = Math.atan2(sSin, sCos);
91
+ return { val: signed ? avgRad : (avgRad + 2 * Math.PI) % (2 * Math.PI), stable: isStable };
92
+ }
93
+
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);
124
121
  }
125
122
 
126
123
  function processIncomingData(path, val) {
127
124
  const now = Date.now(); store.timestamps[path] = now; store.raw[path] = val;
128
- if (path === "navigation.headingTrue") { store.smoothBuf.hdg.push({ val: val, time: now }); store.longBuf.hdg.push({ val: val, time: now }); }
129
- if (path === "navigation.courseOverGroundTrue") { store.smoothBuf.cog.push({ val: val, time: now }); store.longBuf.cog.push({ val: val, time: now }); }
130
- if (path === "environment.wind.angleApparent") { store.smoothBuf.awa.push({ val: val, time: now }); store.longBuf.awa.push({ val: val, time: now }); }
131
- if (path === "environment.wind.angleTrueWater") { store.smoothBuf.twa.push({ val: val, time: now }); store.longBuf.twa.push({ val: val, time: now }); }
132
- if (path === "navigation.headingTrue" || path === "environment.wind.angleTrueWater" || path === "environment.wind.directionTrue") {
133
- let twdRad = 0; if (path === "environment.wind.directionTrue") twdRad = val;
134
- else if (store.raw["navigation.headingTrue"] !== undefined && store.raw["environment.wind.angleTrueWater"] !== undefined) {
135
- twdRad = (store.raw["navigation.headingTrue"] + store.raw["environment.wind.angleTrueWater"]) % (2 * Math.PI);
136
- if (twdRad < 0) twdRad += (2 * Math.PI);
137
- } else return;
138
- store.smoothBuf.twd.push({ val: twdRad, time: now }); store.longBuf.twd.push({ val: twdRad, time: now });
139
- }
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); }
140
134
  }
141
135
 
142
136
  // ==========================================================================
143
- // 5. MOTORE RENDERING PRINCIPALE E TREND
137
+ // 4. AUDIO E ALLARMI
144
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); }
145
140
 
146
- // Calcolo Trend del Vento (Definita prima del loop per evitare ReferenceError)
147
- function updateWindTrend() {
148
- const longAvg = getCircularAverageFromBuffer(store.longBuf.twd, 60000, false);
149
- const shortAvg = getCircularAverageFromBuffer(store.longBuf.twd, 15000, false);
141
+ function playGybeAlarm() {
142
+ if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
143
+ const n = Date.now(); if (n - lastAlarmTime < 2000) return; lastAlarmTime = n;
144
+ function note(f, s, d) { const o = audioCtx.createOscillator(); const g = audioCtx.createGain(); o.connect(g); g.connect(audioCtx.destination); o.type = 'square'; o.frequency.value = f; g.gain.setValueAtTime(0.05, s); g.gain.exponentialRampToValueAtTime(0.001, s + d); o.start(s); o.stop(s + d); }
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); }
146
+ }
150
147
 
151
- if (!longAvg || !shortAvg) return;
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'); }
152
149
 
153
- let diff = (shortAvg.val - longAvg.val + 540) % 360 - 180;
150
+ // ==========================================================================
151
+ // 5. TREND E TATTICA
152
+ // ==========================================================================
153
+ /**
154
+ * Monitoraggio Trend Vento Professionale (Tattica + Meteo + Gybe Safety)
155
+ */
156
+ function updateWindTrend() {
157
+ const now = Date.now();
158
+ const twaAvgObj = getCircularAverageFromBuffer(store.longBuf.twa, 60000, true);
159
+ const shortAvg = getCircularAverageFromBuffer(store.longBuf.twd, 5000, false);
160
+ const instantTwaRad = store.raw["environment.wind.angleTrueWater"];
161
+
162
+ if (!shortAvg || !twaAvgObj || instantTwaRad === undefined) return;
163
+ const instantTwaDeg = radToDeg(instantTwaRad);
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));
172
+ lastInstantTwa = instantTwaDeg;
173
+ if (gybeDetected && isNavigating && (now - lastGybeAlarmTime > 5000)) { lastGybeAlarmTime = now; playGybeAlarm(); }
154
174
 
155
- // Smoothing del trend per evitare scatti
156
- rotationTrend = (rotationTrend * 0.95) + (diff * 0.05);
157
-
158
- // Seleziona sia i pallini della bussola che del gauge centrale
159
- const cwDots = [document.getElementById('trend-dot-cw'), document.getElementById('trend-gauge-cw')];
160
- const ccwDots = [document.getElementById('trend-dot-ccw'), document.getElementById('trend-gauge-ccw')];
161
-
162
- // Soglia: il vento deve ruotare di almeno 1.5 gradi di media per attivarsi
163
- if (Math.abs(rotationTrend) > 1.5) {
164
- if (rotationTrend > 0) { // Orario
165
- cwDots.forEach(el => el && el.classList.add('is-trending'));
166
- ccwDots.forEach(el => el && el.classList.remove('is-trending'));
167
- } else { // Antiorario
168
- ccwDots.forEach(el => el && el.classList.add('is-trending'));
169
- cwDots.forEach(el => el && el.classList.remove('is-trending'));
175
+ const compassDots = { cw: document.getElementById('trend-dot-cw'), ccw: document.getElementById('trend-dot-ccw') };
176
+ const gaugeDots = { cw: document.getElementById('trend-gauge-cw'), ccw: document.getElementById('trend-gauge-ccw') };
177
+
178
+ if (now - lastGybeAlarmTime < 4000 && isNavigating) {
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'); }});
180
+ return;
181
+ }
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
+
191
+ if (Math.abs(rotationTrend) > 1.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;
197
+ const tacticColor = isLift ? "#00ff00" : "#ff0000";
198
+
199
+ if (rotationTrend > 0) {
200
+ if (compassDots.cw) { compassDots.cw.classList.add('is-trending'); compassDots.cw.setAttribute('fill', meteoColor); }
201
+ if (gaugeDots.cw) { gaugeDots.cw.classList.add('is-trending'); gaugeDots.cw.setAttribute('fill', tacticColor); }
202
+ [compassDots.ccw, gaugeDots.ccw].forEach(el => { if(el) { el.classList.remove('is-trending', 'is-gybing'); el.setAttribute('fill', '#ffffff'); }});
203
+ } else {
204
+ if (compassDots.ccw) { compassDots.ccw.classList.add('is-trending'); compassDots.ccw.setAttribute('fill', meteoColor); }
205
+ if (gaugeDots.ccw) { gaugeDots.ccw.classList.add('is-trending'); gaugeDots.ccw.setAttribute('fill', tacticColor); }
206
+ [compassDots.cw, gaugeDots.cw].forEach(el => { if(el) { el.classList.remove('is-trending', 'is-gybing'); el.setAttribute('fill', '#ffffff'); }});
170
207
  }
171
208
  } else {
172
- // Vento stabile, spegni tutti i pallini
173
- cwDots.forEach(el => el && el.classList.remove('is-trending'));
174
- ccwDots.forEach(el => el && el.classList.remove('is-trending'));
209
+ [compassDots.cw, compassDots.ccw, gaugeDots.cw, gaugeDots.ccw].forEach(el => { if (el) { el.classList.remove('is-trending', 'is-gybing'); el.setAttribute('fill', '#ffffff'); }});
175
210
  }
176
211
  }
177
212
 
213
+ // ==========================================================================
214
+ // 6. RENDERING E LOOP PRINCIPALE
215
+ // ==========================================================================
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)}°`; }
217
+
178
218
  function startDisplayLoop() {
219
+ let tick = 0; // Contatore per i cicli di rendering
220
+
179
221
  renderInterval = setInterval(() => {
180
222
  const now = Date.now();
181
- 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 };
182
- for (let p in pathsToWatch) { if (!store.timestamps[p] || (now - store.timestamps[p] > TIMEOUT_MS)) { pathsToWatch[p][pathsToWatch[p] === ui.awsSvg ? 'textContent' : 'innerText'] = "---"; delete store.raw[p]; } }
223
+ tick++;
183
224
 
184
- if (store.raw["navigation.speedThroughWater"] !== undefined) { const v = msToKts(store.raw["navigation.speedThroughWater"]); ui.stw.innerText = v.toFixed(1); manageHistory('stw', v); }
185
- let curSog = 0; if (store.raw["navigation.speedOverGround"] !== undefined) { curSog = msToKts(store.raw["navigation.speedOverGround"]); ui.sog.innerText = curSog.toFixed(1); manageHistory('sog', curSog); }
186
- 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); }
187
-
188
- // --- 1. RENDERING TWS CON COLORE DINAMICO ---
189
- if (store.raw["environment.wind.speedTrue"] !== undefined) {
190
- const twsKts = msToKts(store.raw["environment.wind.speedTrue"]);
191
- ui.tws.innerText = twsKts.toFixed(1);
192
- let twsColor = "#fff"; // Default: Bianco
193
- if (twsKts >= CONFIG.graphs.reef2) twsColor = "#e74c3c"; // Rosso
194
- else if (twsKts >= CONFIG.graphs.reef1) twsColor = "#e67e22"; // Arancio
195
- ui.tws.style.color = twsColor;
196
- manageHistory('tws', twsKts);
225
+ // ==========================================================
226
+ // LIVE TIER (Ogni 1s) - Reattività, Numeri, Allarmi, Lancette
227
+ // ==========================================================
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 };
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]; } }
230
+
231
+ const stwKts = msToKts(store.raw["navigation.speedThroughWater"] || 0), sogKts = msToKts(store.raw["navigation.speedOverGround"] || 0);
232
+ isNavigating = stwKts > CONFIG.averages.minSpeed || sogKts > CONFIG.averages.minSpeed;
233
+
234
+ // Numeri veloci
235
+ if (store.raw["navigation.speedThroughWater"] !== undefined) { ui.stw.innerText = stwKts.toFixed(1); manageHistory('stw', stwKts); }
236
+ if (store.raw["navigation.speedOverGround"] !== undefined) {
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'; }
197
242
  }
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); }
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); }
198
245
  if (store.raw["environment.wind.speedApparent"] !== undefined) ui.awsSvg.textContent = msToKts(store.raw["environment.wind.speedApparent"]).toFixed(1);
199
246
 
200
- // Render Quadrante
247
+ // Lancette (Sempre fluide ogni 1s)
201
248
  const smAwa = getCircularAverageFromBuffer(store.smoothBuf.awa, CONFIG.averages.smoothWindow, true);
202
- 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)`); }
203
250
  const smTwa = getCircularAverageFromBuffer(store.smoothBuf.twa, CONFIG.averages.smoothWindow, true);
204
- 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)`); }
205
252
 
206
- // --- CALCOLO E SMUSSAMENTO LEEWAY ---
253
+ // Leeway (Ogni 1s)
207
254
  if (store.raw["navigation.courseOverGroundTrue"] !== undefined && store.raw["navigation.headingTrue"] !== undefined) {
208
- let rawDrift = (radToDeg(store.raw["navigation.courseOverGroundTrue"]) - radToDeg(store.raw["navigation.headingTrue"]) + 360) % 360;
209
- if (rawDrift > 180) rawDrift -= 360;
210
-
211
- if (curSog < CONFIG.averages.minSpeed) {
212
- smoothedLeeway = 0;
213
- } else {
214
- smoothedLeeway = (smoothedLeeway * 0.9) + (rawDrift * 0.1);
215
- }
216
-
217
- curTrackRot = getShortestRotation(curTrackRot, smoothedLeeway);
218
- ui.track.setAttribute('transform', `rotate(${curTrackRot}, 200, 200)`);
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);
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";
219
261
  updateLeewayDisplay(Math.max(-20, Math.min(20, smoothedLeeway)));
220
- } else {
221
- 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');
222
274
  }
223
275
 
224
- // --- 2. RENDERING MEDIE E BUSSOLA TATTICA ---
225
- if (now - lastAvgUIUpdate > 3000) {
276
+ // ==========================================================
277
+ // SLOW TIER (Ogni 3s) - Medie MEAN e Calcoli TACK
278
+ // ==========================================================
279
+ if (tick % 3 === 0) {
226
280
  let hObj = getCircularAverageFromBuffer(store.longBuf.hdg, CONFIG.averages.longWindow, false),
227
281
  cObj = getCircularAverageFromBuffer(store.longBuf.cog, CONFIG.averages.longWindow, false),
228
282
  awObj = getCircularAverageFromBuffer(store.longBuf.awa, CONFIG.averages.longWindow, true),
@@ -230,297 +284,232 @@ function startDisplayLoop() {
230
284
  twdObj = getCircularAverageFromBuffer(store.longBuf.twd, CONFIG.averages.longWindow, false);
231
285
 
232
286
  const upUI = (el, obj, isCompass = false) => {
233
- if (!obj || obj.val === null) {
234
- el.innerHTML = "---&deg;";
235
- el.classList.remove('unstable-data');
236
- } else {
237
- let val = Math.round(obj.val);
238
- let displayVal;
239
-
240
- if (isCompass) displayVal = ((val + 360) % 360).toString().padStart(3, '0');
241
- else displayVal = val.toString();
242
-
243
- el.innerHTML = `${displayVal}&deg;`;
244
-
245
- if (obj.stable || curSog < CONFIG.averages.minSpeed) el.classList.remove('unstable-data');
246
- else el.classList.add('unstable-data');
287
+ if (!obj || obj.val === null) { el.innerHTML = "---&deg;"; el.classList.remove('unstable-data'); }
288
+ else {
289
+ let valDeg = Math.round(radToDeg(obj.val)); el.innerHTML = (isCompass ? ((valDeg + 360) % 360).toString().padStart(3, '0') : valDeg) + "&deg;";
290
+ if (obj.stable || !isNavigating) el.classList.remove('unstable-data'); else el.classList.add('unstable-data');
247
291
  }
248
292
  };
249
-
250
- upUI(ui.hdg, hObj, true);
251
- upUI(ui.cog, cObj, true);
252
- upUI(ui.awaAvg, awObj, false);
253
- upUI(ui.twaAvg, twObj, false);
254
- upUI(ui.twdAvg, twdObj, true);
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);
255
294
 
256
295
  if (hObj && twObj && hObj.val !== null) {
257
- let tA = twObj.val * 2; ui.tackHdg.innerHTML = `${Math.round((hObj.val - tA + 360) % 360).toString().padStart(3, '0')}&deg;`;
258
- if (cObj) ui.tackCog.innerHTML = `${Math.round((cObj.val - tA + 360) % 360).toString().padStart(3, '0')}&deg;`;
259
- let tStable = (hObj.stable && twObj.stable) || (curSog < CONFIG.averages.minSpeed);
260
- if (tStable) { ui.tackHdg.classList.remove('unstable-data'); ui.tackCog.classList.remove('unstable-data'); }
261
- else { ui.tackHdg.classList.add('unstable-data'); ui.tackCog.classList.add('unstable-data'); }
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
+ }
262
302
  }
263
303
 
264
- // Rotazione Bussola Tattica e Colore Sincronizzato
265
- if (twdObj && hObj) {
266
- updateWindTrend(); // Aggiorna i pallini
267
-
268
- curTwdRoseRot = getShortestRotation(curTwdRoseRot, twdObj.val);
269
- ui.twdArrow.setAttribute('transform', `rotate(${curTwdRoseRot}, 20, 20)`);
270
- ui.twdBoat.setAttribute('transform', `rotate(${hObj.val}, 20, 20)`);
271
-
272
- let currentTwsKts = store.raw["environment.wind.speedTrue"] ? msToKts(store.raw["environment.wind.speedTrue"]) : 0;
273
- let reefColor = "#fff";
274
- if (currentTwsKts >= CONFIG.graphs.reef2) reefColor = "#e74c3c";
275
- else if (currentTwsKts >= CONFIG.graphs.reef1) reefColor = "#e67e22";
276
- if (ui.twdChevron) ui.twdChevron.setAttribute('stroke', reefColor);
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)`);
277
308
  }
278
- lastAvgUIUpdate = now;
279
309
  }
280
- 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(); }
281
- 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
+ }
282
317
  }, RENDER_INTERVAL_MS);
283
318
  }
284
319
 
285
320
  // ==========================================================================
286
- // 6. CONNESSIONE SIGNALK
321
+ // 7. CONFIGURAZIONE E GRAFICI
287
322
  // ==========================================================================
288
- function connect() {
289
- if (simulationMode) return;
290
- let addr = (window.location.protocol.includes("http")) ? window.location.host : CONFIG.server.fallbackIp;
291
- try {
292
- socket = new WebSocket(`ws://${addr}/signalk/v1/stream?subscribe=self`);
293
- socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; };
294
- 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))); };
295
- socket.onclose = () => !simulationMode && setTimeout(connect, 5000);
296
- } catch (e) { setTimeout(connect, 5000); }
323
+ async function fetchServerConfig() {
324
+ if (!window.location.protocol.includes("http")) return;
325
+ const pluginID = 'rotevista-dash';
326
+ const possibleUrls = [`/skServer/plugins/${pluginID}/config`, `/plugins/${pluginID}/config` ];
327
+ for (let url of possibleUrls) {
328
+ try {
329
+ const response = await fetch(url);
330
+ if (response.ok) {
331
+ const data = await response.json();
332
+ const actual = data.configuration || data;
333
+ if (actual && typeof actual === 'object') {
334
+ const parseNumbers = (obj) => { for (let k in obj) { if (typeof obj[k] === 'object') parseNumbers(obj[k]); else if (!isNaN(obj[k]) && obj[k] !== "") obj[k] = parseFloat(obj[k]); } };
335
+ parseNumbers(actual);
336
+ if (actual.alarms) CONFIG.alarms = { ...CONFIG.alarms, ...actual.alarms };
337
+ if (actual.graphs) CONFIG.graphs = { ...CONFIG.graphs, ...actual.graphs };
338
+ if (actual.averaging) CONFIG.averages = { ...CONFIG.averages, ...actual.averaging };
339
+ if (actual.scales) { for (let key in actual.scales) { CONFIG.scales[key] = { ...CONFIG.scales[key], ...actual.scales[key] }; } }
340
+ }
341
+ }
342
+ } catch (e) { }
343
+ }
297
344
  }
298
345
 
299
- // ==========================================================================
300
- // 7. FUNZIONI GRAFICHE E DISEGNO (BIANCO -> ARANCIO -> ROSSO)
301
- // ==========================================================================
302
- 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)}°`; }
303
-
346
+ /**
347
+ * Gestisce l'archiviazione dei dati storici.
348
+ * Il disegno grafico viene ora gestito separatamente nel loop pesante.
349
+ */
304
350
  function manageHistory(t, v) {
305
351
  const n = Date.now();
306
352
  const interval = simulationMode ? SIM_SAMPLE_INTERVAL : (CONFIG.graphs.historyMinutes * 60000) / CONFIG.graphs.samples;
307
- 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; }
308
- const mode = graphModes[t], cfg = calculateScale(t, store.histories[t], mode);
309
- const box = document.getElementById(t + '-graph').closest('.data-box');
310
- if (mode === 'hercules') box.classList.add('box-hercules'); else box.classList.remove('box-hercules');
311
- updateScaleLabels(t, cfg.min, cfg.max);
312
- drawGraph(store.histories[t], t + '-graph', cfg.min, cfg.max, t === 'tws', mode === 'hercules');
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
+ }
313
359
  }
314
360
 
315
- function calculateScale(type, data, mode) {
316
- const s = CONFIG.scales[type] || { stdMax: 12, hercSpan: 4, step: 2 };
317
- let aMin = Math.min(...data), aMax = Math.max(...data);
318
- if (mode === 'hercules') {
319
- let avg = (aMin + aMax) / 2; let span = Math.max(s.hercSpan, Math.ceil(aMax - aMin)); if (span % 2 !== 0) span += 1;
320
- let min = Math.max(0, Math.floor(avg - (span / 2))); return { min, max: min + span };
321
- } else return { min: 0, max: Math.max(s.stdMax, Math.ceil(aMax / s.step) * s.step) };
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');
322
383
  }
323
384
 
324
- function updateScaleLabels(t, min, max) {
325
- const el = document.getElementById(t + '-scale'); if (!el) return;
326
- el.innerHTML = `<span>${Math.round(max)}</span><span>${Math.round((min+max)/2)}</span><span>${Math.round(min)}</span>`;
385
+ function calculateScale(type, data, mode) {
386
+ const s = CONFIG.scales[type] || { stdMax: 12, hercSpan: 4, step: 2 }; let aMin = Math.min(...data), aMax = Math.max(...data);
387
+ if (mode === 'hercules') { let avg = (aMin + aMax) / 2; let span = Math.max(s.hercSpan, Math.ceil(aMax - aMin)); if (span % 2 !== 0) span += 1; let min = Math.max(0, Math.floor(avg - (span / 2))); return { min, max: min + span }; }
388
+ else return { min: 0, max: Math.max(s.stdMax, Math.ceil(aMax / s.step) * s.step) };
327
389
  }
328
390
 
391
+ function updateScaleLabels(t, min, max) { const el = document.getElementById(t + '-scale'); if (el) el.innerHTML = `<span>${Math.round(max)}</span><span>${Math.round((min+max)/2)}</span><span>${Math.round(min)}</span>`; }
392
+
329
393
  function drawGraph(d, id, min, max, isTws, isHercules) {
330
394
  const svg = document.getElementById(id); if (!svg || d.length < 2) return;
331
395
  const w = 200, h = 40, range = max - min || 1;
332
396
  let grids = ""; [0.25, 0.5, 0.75].forEach(p => { grids += `<line x1="0" y1="${h-(p*h)}" x2="${w}" y2="${h-(p*h)}" stroke="rgba(255,255,255,0.08)" stroke-width="0.5" />`; });
333
397
  for (let m = 1; m < CONFIG.graphs.historyMinutes; m++) { const x = w - (m / CONFIG.graphs.historyMinutes) * w; grids += `<line x1="${x}" y1="0" x2="${x}" y2="${h}" stroke="rgba(255,255,255,0.05)" stroke-width="0.5" />`; }
334
- let pD = "", cS = "";
398
+ let pD = ""; let cS = "";
335
399
  d.forEach((v, i) => {
336
400
  const x = (i/(CONFIG.graphs.samples-1))*w, y = h-(Math.max(0,Math.min(1,(v-min)/range))*h); pD += `${i===0?'M':'L'} ${x} ${y} `;
337
401
  if (isTws && i > 0) {
338
402
  const px = ((i-1)/(CONFIG.graphs.samples-1))*w, py = h-(Math.max(0,Math.min(1,(d[i-1]-min)/range))*h);
339
- let c = "#fff"; // Default: Bianco
340
- if (v >= CONFIG.graphs.reef2) c = "#e74c3c"; else if (v >= CONFIG.graphs.reef1) c = "#e67e22";
403
+ let c = (v >= CONFIG.graphs.reef2) ? "#e74c3c" : (v >= CONFIG.graphs.reef1 ? "#e67e22" : "#fff");
341
404
  cS += `<line x1="${px}" y1="${py}" x2="${x}" y2="${y}" stroke="${c}" class="${isHercules?'line-hercules':''}" />`;
342
405
  }
343
406
  });
344
- const aP = pD + ` L ${((d.length-1)/(CONFIG.graphs.samples-1))*w} ${h} L 0 ${h} Z`, clrs = { 'stw-graph': '#2ecc71', 'sog-graph': '#f39c12', 'depth-graph': '#3498db', 'tws-graph': '#ffffff' };
345
- svg.innerHTML = isTws ? `${grids}<path d="${aP}" fill="rgba(255,255,255,0.08)" stroke="none" />${cS}` : `${grids}<path d="${aP}" fill="${clrs[id]}22" stroke="none" /><path d="${pD}" class="${isHercules?'line-hercules':''}" fill="none" stroke="${clrs[id]}" />`;
407
+ const clrs = { 'stw-graph': '#2ecc71', 'sog-graph': '#f39c12', 'depth-graph': '#3498db', 'tws-graph': '#ffffff' };
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}" />`;
346
410
  }
347
411
 
348
412
  // ==========================================================================
349
- // 8. EVENTI E INTERAZIONI (SMART TOUCH MANAGER)
413
+ // 8. INTERAZIONI E SIMULATORE
350
414
  // ==========================================================================
351
- window.addEventListener('contextmenu', e => e.preventDefault(), true);
352
-
353
415
  function toggleFocusMode(type, element) {
354
- const container = document.querySelector('.main-container');
355
- const parentPanel = element.closest('.side-panel');
356
- const isLeft = parentPanel.classList.contains('left-panel');
416
+ const container = document.querySelector('.main-container'); const parentPanel = element.closest('.side-panel'); const isLeft = parentPanel.classList.contains('left-panel');
357
417
  isFocusActive = !isFocusActive;
358
- if (isFocusActive) {
359
- container.classList.add('focus-active');
360
- container.classList.add(isLeft ? 'focus-side-left' : 'focus-side-right');
361
- parentPanel.classList.add('has-focus');
362
- element.classList.add('is-focused');
363
- blockNextClick = true;
364
- } else {
365
- container.classList.remove('focus-active', 'focus-side-left', 'focus-side-right');
366
- document.querySelectorAll('.side-panel').forEach(p => p.classList.remove('has-focus'));
367
- document.querySelectorAll('.data-box').forEach(b => b.classList.remove('is-focused'));
368
- }
418
+ if (isFocusActive) { container.classList.add('focus-active', isLeft ? 'focus-side-left' : 'focus-side-right'); parentPanel.classList.add('has-focus'); element.classList.add('is-focused'); }
419
+ else { container.classList.remove('focus-active', 'focus-side-left', 'focus-side-right'); document.querySelectorAll('.side-panel').forEach(p => p.classList.remove('has-focus')); document.querySelectorAll('.data-box').forEach(b => b.classList.remove('is-focused')); }
369
420
  }
370
421
 
371
422
  ['stw', 'sog', 'tws', 'depth'].forEach(type => {
372
423
  const el = document.getElementById(type + '-graph').closest('.data-box');
373
- let lastTapTime = 0, tapTimeout;
374
-
375
- const handleInteraction = (e) => {
376
- if (e.cancelable) e.preventDefault();
377
- const currentTime = new Date().getTime();
378
- const tapDelay = currentTime - lastTapTime;
379
-
380
- pressTimer = setTimeout(() => { if (!isFocusActive) { toggleFocusMode(type, el); lastTapTime = 0; } }, 1000);
381
-
382
- if (tapDelay < 300 && tapDelay > 0) {
383
- clearTimeout(tapTimeout);
384
- if (!isFocusActive) {
385
- graphModes[type] = graphModes[type] === 'standard' ? 'hercules' : 'standard';
386
- localStorage.setItem('mode_' + type, graphModes[type]);
387
- el.style.backgroundColor = "rgba(255,255,255,0.15)"; setTimeout(() => el.style.backgroundColor = "", 200);
388
- }
389
- lastTapTime = 0;
390
- } else {
391
- lastTapTime = currentTime;
392
- tapTimeout = setTimeout(() => { if (isFocusActive && el.classList.contains('is-focused')) toggleFocusMode(type, el); }, 350);
393
- }
394
- };
395
- el.addEventListener('pointerdown', handleInteraction);
396
- el.addEventListener('pointerup', () => clearTimeout(pressTimer));
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;
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; }
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); }
431
+ });
397
432
  el.addEventListener('pointerleave', () => clearTimeout(pressTimer));
398
433
  });
399
434
 
400
- // ==========================================================================
401
- // GESTIONE HOTSPOT: Click (Fullscreen) e Long Press (Night Mode)
402
- // ==========================================================================
403
435
  if (ui.hotspot) {
404
- let pressTimer;
405
- const HOLD_DURATION = 1000;
406
-
407
- ui.hotspot.addEventListener('pointerdown', (e) => {
408
- pressTimer = setTimeout(() => {
409
- document.body.classList.toggle('night-mode');
410
- ui.hotspot.style.opacity = "0.5";
411
- setTimeout(() => ui.hotspot.style.opacity = "1", 200);
412
- pressTimer = null;
413
- }, HOLD_DURATION);
414
- });
436
+ ui.hotspot.addEventListener('pointerdown', (e) => { pressTimer = setTimeout(() => { document.body.classList.toggle('night-mode'); ui.hotspot.style.opacity = "0.5"; setTimeout(() => ui.hotspot.style.opacity = "1", 200); pressTimer = null; }, 1000); });
437
+ ui.hotspot.addEventListener('pointerup', (e) => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; const doc = document.documentElement, isF = document.fullscreenElement || document.webkitFullscreenElement; if (!isF) { if (doc.requestFullscreen) doc.requestFullscreen(); else if (doc.webkitRequestFullscreen) doc.webkitRequestFullscreen(); } else { if (document.exitFullscreen) document.exitFullscreen(); else if (document.webkitExitFullscreen) document.webkitExitFullscreen(); } } });
438
+ ui.hotspot.addEventListener('pointerleave', () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } });
439
+ }
415
440
 
416
- ui.hotspot.addEventListener('pointerup', (e) => {
417
- if (pressTimer) {
418
- clearTimeout(pressTimer);
419
- pressTimer = null;
420
- const doc = document.documentElement;
421
- const isF = document.fullscreenElement || document.webkitFullscreenElement;
422
- if (!isF) {
423
- if (doc.requestFullscreen) doc.requestFullscreen();
424
- else if (doc.webkitRequestFullscreen) doc.webkitRequestFullscreen();
425
- } else {
426
- if (document.exitFullscreen) document.exitFullscreen();
427
- else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
441
+ function connect() {
442
+ if (simulationMode) return;
443
+
444
+ let addr = (window.location.protocol.includes("http")) ? window.location.host : CONFIG.server.fallbackIp;
445
+ const protocol = (window.location.protocol === 'https:') ? 'wss' : 'ws';
446
+
447
+ try {
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
428
470
  }
429
- }
430
- });
471
+ };
431
472
 
432
- ui.hotspot.addEventListener('pointerleave', () => {
433
- if (pressTimer) {
434
- clearTimeout(pressTimer);
435
- pressTimer = null;
436
- }
437
- });
438
- }
439
- (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); } } })();
473
+ socket.onerror = () => {
474
+ socket.close(); // Forza il trigger di onclose
475
+ };
440
476
 
441
- async function init() { await fetchServerConfig(); startDisplayLoop(); connect(); }
442
- window.addEventListener('load', init);
443
- 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'); }
444
- function playBingBing() { if (!audioCtx) return; 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); }
477
+ } catch (e) {
478
+ setTimeout(connect, reconnectDelay);
479
+ reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
480
+ }
481
+ }
445
482
 
446
- // ==========================================================================
447
- // 9. MOTORE SIMULAZIONE DINAMICA E AVVIO (3 Click su Depth)
448
- // ==========================================================================
449
- ui.depth.closest('.data-box').addEventListener('click', (function() {
450
- let dC = 0, lC = 0;
451
- return function() {
452
- const n = Date.now();
453
- if (n - lC < 500) dC++; else dC = 1;
454
- lC = n;
455
- if (dC === 3) {
456
- simulationMode = !simulationMode;
457
- if (simulationMode) {
458
- if (socket) socket.close();
459
- startDynamicSimulation();
460
- } else {
461
- location.reload();
462
- }
463
- dC = 0;
464
- }
465
- };
466
- })());
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; } }; })());
467
484
 
468
485
  function startDynamicSimulation() {
469
486
  ui.status.innerText = "SIM ATTIVO";
470
-
471
- let sim = {
472
- hdg: Math.random() * 360,
473
- tws: 12,
474
- twd: Math.random() * 360,
475
- depth: 12,
476
- stw: 5,
477
- leeway: 0,
478
- startTime: Date.now()
479
- };
480
-
487
+ let sim = { hdg: 45, tws: 12, twd: 45, depth: 12, stw: 5, leeway: 0, currentSpeed: 1.5, currentDir: 90, startTime: Date.now() };
481
488
  simInterval = setInterval(() => {
482
489
  const elapsed = (Date.now() - sim.startTime) / 1000;
483
-
484
- // Vento: Rotazione lenta + Salto netto a 120s
485
490
  if (elapsed > 120 && elapsed < 121) sim.twd = Math.random() * 360;
486
491
  sim.twd = (sim.twd + (Math.sin(elapsed / 20) * 0.5) + 360) % 360;
487
-
488
- // Raffica
489
- const inGust = (elapsed % 40) < 5;
490
- const targetTws = (inGust ? 18 : 10) + Math.sin(elapsed / 10) * 2;
491
- sim.tws += (targetTws - sim.tws) * 0.05;
492
-
493
- // Barca segue il vento pigramente
494
- sim.hdg = (sim.hdg + (Math.random() - 0.5) * 1 + 360) % 360;
495
-
496
- let twaRel = (sim.twd - sim.hdg + 360) % 360;
497
- if (twaRel > 180) twaRel -= 360;
498
-
499
- // Polare smussata
500
- let targetStw = 3 + (4 * Math.sin((Math.abs(twaRel) - 45) * Math.PI / 125));
501
- sim.stw += (Math.max(3, Math.min(8, targetStw)) - sim.stw) * 0.05;
502
-
503
- // Leeway smussato
504
- const rawLeeway = Math.sin(degToRad(twaRel)) * 4;
505
- sim.leeway += (rawLeeway - sim.leeway) * 0.05;
506
-
507
- // Vettori Reali
508
- const cog = (sim.hdg - sim.leeway + 360) % 360;
509
- const twaRad = degToRad(twaRel);
510
- const aws = Math.sqrt(Math.pow(sim.stw, 2) + Math.pow(sim.tws, 2) + 2 * sim.stw * sim.tws * Math.cos(twaRad));
511
- const awa = radToDeg(Math.atan2(sim.tws * Math.sin(twaRad), sim.stw + sim.tws * Math.cos(twaRad)));
512
-
513
- // Invio Dati (Con tutto il set necessario per la UI)
514
- processIncomingData("environment.wind.speedTrue", ktsToMs(sim.tws));
515
- processIncomingData("environment.wind.directionTrue", degToRad(sim.twd));
516
- processIncomingData("environment.wind.angleTrueWater", degToRad(twaRel));
517
- processIncomingData("environment.wind.speedApparent", ktsToMs(aws));
518
- processIncomingData("environment.wind.angleApparent", degToRad(awa));
519
- processIncomingData("environment.depth.belowTransducer", sim.depth);
520
- processIncomingData("navigation.headingTrue", degToRad(sim.hdg));
521
- processIncomingData("navigation.speedThroughWater", ktsToMs(sim.stw));
522
- processIncomingData("navigation.speedOverGround", ktsToMs(sim.stw));
523
- processIncomingData("navigation.courseOverGroundTrue", degToRad(cog));
524
-
492
+ const inGust = (elapsed % 40) < 5; const targetTws = (inGust ? 18 : 10) + Math.sin(elapsed / 10) * 2;
493
+ sim.tws += (targetTws - sim.tws) * 0.05; sim.hdg = (sim.hdg + (Math.random() - 0.5) * 1 + 360) % 360;
494
+ let twaRel = (sim.twd - sim.hdg + 360) % 360; if (twaRel > 180) twaRel -= 360;
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;
496
+ const rawLeeway = Math.sin(degToRad(twaRel)) * 4; sim.leeway += (rawLeeway - sim.leeway) * 0.05;
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));
501
+ const awa = Math.atan2(sim.tws * Math.sin(twaRad), sim.stw + sim.tws * Math.cos(twaRad));
502
+ processIncomingData("environment.wind.speedApparent", ktsToMs(aws)); processIncomingData("environment.wind.angleApparent", awa);
503
+ processIncomingData("environment.depth.belowTransducer", sim.depth); processIncomingData("navigation.headingTrue", degToRad(sim.hdg));
504
+ processIncomingData("navigation.speedThroughWater", ktsToMs(sim.stw)); processIncomingData("navigation.speedOverGround", ktsToMs(sog));
505
+ processIncomingData("navigation.courseOverGroundTrue", degToRad(cog)); processIncomingData("navigation.position", { latitude: 45.0, longitude: 12.0 });
525
506
  }, 1000);
526
507
  }
508
+
509
+ // ==========================================================================
510
+ // 9. INIT
511
+ // ==========================================================================
512
+ window.addEventListener('contextmenu', e => e.preventDefault(), true);
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); } } })();
514
+ async function init() { await fetchServerConfig(); startDisplayLoop(); connect(); }
515
+ window.addEventListener('load', init);