@sailingrotevista/rotevista-dash 3.0.5 → 4.0.2

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 (5) hide show
  1. package/app.js +459 -278
  2. package/index.js +72 -54
  3. package/package.json +1 -1
  4. package/settings.json +15 -9
  5. package/style.css +86 -57
package/app.js CHANGED
@@ -1,35 +1,59 @@
1
+ /**
2
+ * ==========================================================================
3
+ * Signal K Wind Dashboard - Pro Version 2.4 (Definitive)
4
+ * ==========================================================================
5
+ * Autore: Sailing Rotevista
6
+ * Motore di calcolo tattico per navigazione e crociera.
7
+ * Gestisce: Medie Vettoriali, Deviazione Standard, Trend Strategico a 15min,
8
+ * Memoria UI persistente, Modalità Hercules e Focus Split Screen.
9
+ */
10
+
1
11
  // ==========================================================================
2
12
  // 1. CONFIGURAZIONE E DEFAULT
3
13
  // ==========================================================================
4
14
  let CONFIG = {
5
- alarms: { depthDanger: 2.5, depthWarning: 5.0 },
6
- // Ottimizzato per mare formato: 30s di media e soglia stabilità 85%
15
+ alarms: {
16
+ depthDanger: 2.5,
17
+ depthWarning: 5.0
18
+ },
19
+ // Gestione parametri di stabilità e medie
7
20
  averages: {
8
- smoothWindow: 2000,
9
- longWindow: 30000,
10
- stabilityTolerance: 2000,
11
- stabilityThreshold: 0.85,
12
- minSpeed: 0.5
21
+ smoothWindow: 2000, // Smoothing puntatori (2s)
22
+ longWindow: 30000, // Finestra per i valori MEAN (30s)
23
+ stabilityTolerance: 2000, // Millisecondi per considerare il buffer "pieno"
24
+ stabilityThreshold: 0.85, // Soglia coerenza R per il lampeggio (0.7 - 0.98)
25
+ minSpeed: 0, // Nodi minimi per attivare gli allarmi di instabilità
26
+ stabilityBreakout: 15
27
+ },
28
+ // Parametri per i grafici sparkline
29
+ graphs: {
30
+ reef1: 15.0, // Soglia primo reef (Orange)
31
+ reef2: 20.0, // Soglia secondo reef (Red)
32
+ historyMinutes: 5, // Finestra temporale visualizzata
33
+ samples: 60 // Numero di punti di campionamento
13
34
  },
14
- graphs: { reef1: 15.0, reef2: 20.0, historyMinutes: 5, samples: 60 },
35
+ // Configurazioni scale automatiche
15
36
  scales: {
16
37
  stw: { stdMax: 12, hercSpan: 4, step: 2 },
17
38
  sog: { stdMax: 12, hercSpan: 4, step: 2 },
18
39
  tws: { stdMax: 25, hercSpan: 10, step: 5 },
19
40
  depth: { stdMax: 20, hercSpan: 10, step: 10 }
20
41
  },
21
- server: { fallbackIp: "192.168.111.240:3000" }
42
+ server: {
43
+ fallbackIp: "192.168.111.240:3000"
44
+ }
22
45
  };
23
46
 
24
47
  const RENDER_INTERVAL_MS = 1000;
25
48
  const TIMEOUT_MS = 5000;
26
49
  const SIM_SAMPLE_INTERVAL = 1000;
50
+ const DASH_VERSION = "2.4"; // Versione per la gestione della memoria locale
27
51
 
28
52
  // ==========================================================================
29
53
  // 2. STATO GLOBALE E RIFERIMENTI UI
30
54
  // ==========================================================================
31
55
  let simulationMode = false;
32
- let displayModeSog = 'SOG';
56
+ let displayModeSog = 'SOG'; // Può essere 'SOG' o 'VMG'
33
57
  let socket, renderInterval, simInterval;
34
58
  let lastAvgUIUpdate = 0, audioCtx = null, lastAlarmTime = 0;
35
59
  let curAwaRot = 0, curTwaRot = 0, curTrackRot = 0, curTwdRoseRot = 0;
@@ -42,23 +66,27 @@ let twDirty = false, isNavigating = false, reconnectDelay = 1000;
42
66
 
43
67
  let pressTimer, isFocusActive = false;
44
68
 
69
+ // Stato dei singoli grafici (Standard vs Hercules Zoom)
45
70
  const graphModes = {
46
- stw: localStorage.getItem('mode_stw') || 'standard',
47
- sog: localStorage.getItem('mode_sog') || 'standard',
48
- tws: localStorage.getItem('mode_tws') || 'standard',
49
- depth: localStorage.getItem('mode_depth') || 'standard'
71
+ stw: 'standard',
72
+ sog: 'standard',
73
+ tws: 'standard',
74
+ depth: 'standard'
50
75
  };
51
76
 
77
+ // Database centrale dello store dati
52
78
  const store = {
53
- raw: {}, timestamps: {},
79
+ raw: {},
80
+ timestamps: {},
54
81
  smoothBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
55
82
  longBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
56
83
  histories: { stw: [], sog: [], depth: [], tws: [], vmg: [] },
57
- // Buffer temporaneo per calcolare la media dell'intervallo nei grafici
84
+ // Buffer temporaneo per il calcolo della media dell'intervallo del grafico
58
85
  graphTempBuf: { stw: [], sog: [], depth: [], tws: [], vmg: [] },
59
86
  lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0 }
60
87
  };
61
88
 
89
+ // Riferimenti agli elementi DOM mappati all'avvio
62
90
  const ui = {
63
91
  stw: document.getElementById('stw'), sog: document.getElementById('sog'),
64
92
  hdg: document.getElementById('hdg'), cog: document.getElementById('cog'),
@@ -75,43 +103,154 @@ const ui = {
75
103
  };
76
104
 
77
105
  // ==========================================================================
78
- // 3. UTILITIES (MATEMATICA, BUFFER, AUDIO)
106
+ // 3. UTILITIES (MATEMATICA, BUFFER, AUDIO, MEMORIA)
79
107
  // ==========================================================================
80
108
  function radToDeg(rad) { return rad * (180 / Math.PI); }
81
109
  function degToRad(deg) { return deg * (Math.PI / 180); }
82
110
  function msToKts(ms) { return ms * 1.94384; }
83
111
  function ktsToMs(kts) { return kts / 1.94384; }
84
- function getShortestRotation(curr, target) { let diff = (target - curr) % 360; if (diff > 180) diff -= 360; else if (diff < -180) diff += 360; return curr + diff; }
85
112
 
86
- function safePush(buffer, val, time, maxLen = 200) {
113
+ /**
114
+ * Calcola il percorso più breve per la rotazione di un puntatore (evita giri di 360°)
115
+ */
116
+ function getShortestRotation(curr, target) {
117
+ let diff = (target - curr) % 360;
118
+ if (diff > 180) diff -= 360;
119
+ else if (diff < -180) diff += 360;
120
+ return curr + diff;
121
+ }
122
+
123
+ /**
124
+ * Inserisce un dato nel buffer circolare limitandolo a 2000 campioni (30 min)
125
+ */
126
+ function safePush(buffer, val, time, maxLen = 2000) {
87
127
  buffer.push({ val: val, time: time });
88
128
  if (buffer.length > maxLen) { buffer.shift(); }
89
129
  }
90
130
 
131
+ /**
132
+ * Media Circolare Vettoriale: Calcola angolo medio, stabilità R e deviazione standard (±)
133
+ */
91
134
  function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false) {
92
135
  const now = Date.now();
93
136
  const validData = bufferArray.filter(item => (now - item.time) <= windowMs);
94
137
  if (validData.length === 0) return null;
138
+
95
139
  let sSin = 0, sCos = 0;
96
- validData.forEach(item => { sSin += Math.sin(item.val); sCos += Math.cos(item.val); });
140
+ validData.forEach(item => {
141
+ sSin += Math.sin(item.val);
142
+ sCos += Math.cos(item.val);
143
+ });
144
+
97
145
  let R = Math.sqrt(sSin * sSin + sCos * sCos) / validData.length;
98
- let isStable = (validData.length > 2) && (validData[validData.length - 1].time - validData[0].time >= windowMs - CONFIG.averages.stabilityTolerance) && (R > CONFIG.averages.stabilityThreshold);
146
+ let historyDuration = (validData.length > 2) ? (validData[validData.length - 1].time - validData[0].time) : 0;
147
+
148
+ // Un dato è stabile se abbiamo abbastanza storia e la coerenza vettoriale R è alta
149
+ let isStable = (historyDuration > 10000) && (R > CONFIG.averages.stabilityThreshold);
99
150
  let avgRad = Math.atan2(sSin, sCos);
100
- return { val: signed ? avgRad : (avgRad + 2 * Math.PI) % (2 * Math.PI), stable: isStable };
151
+
152
+ // Calcolo della Deviazione Standard Circolare (±) in gradi
153
+ let deviation = (R < 1 && R > 0) ? Math.round(Math.sqrt(-2 * Math.log(R)) * (180 / Math.PI)) : 0;
154
+
155
+ return {
156
+ val: signed ? avgRad : (avgRad + 2 * Math.PI) % (2 * Math.PI),
157
+ stable: isStable,
158
+ dev: deviation
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Salva lo stato attuale della dashboard (dati e preferenze UI) nel browser
164
+ */
165
+ function saveDashboardState() {
166
+ try {
167
+ const focusedBox = document.querySelector('.data-box.is-focused');
168
+ let focusedType = null;
169
+ if (focusedBox) {
170
+ const match = focusedBox.className.match(/box-([a-z]+)/);
171
+ if (match) focusedType = match[1];
172
+ }
173
+
174
+ const state = {
175
+ version: DASH_VERSION,
176
+ histories: store.histories,
177
+ longBuf: store.longBuf,
178
+ displayModeSog: displayModeSog,
179
+ graphModes: graphModes,
180
+ isNightMode: document.body.classList.contains('night-mode'),
181
+ isFocusActive: isFocusActive,
182
+ focusedBoxType: focusedType,
183
+ timestamp: Date.now()
184
+ };
185
+ localStorage.setItem('rotevista_dash_state', JSON.stringify(state));
186
+ } catch (e) { console.error("Save error:", e); }
187
+ }
188
+
189
+ /**
190
+ * Carica e ripristina lo stato salvato (entro i 20 minuti di vecchiaia)
191
+ */
192
+ function loadDashboardState() {
193
+ const saved = localStorage.getItem('rotevista_dash_state');
194
+ if (!saved) return;
195
+ try {
196
+ const state = JSON.parse(saved);
197
+ if (state.version !== DASH_VERSION) { localStorage.removeItem('rotevista_dash_state'); return; }
198
+
199
+ if ((Date.now() - state.timestamp) / 60000 < 20) {
200
+ if (state.histories) Object.assign(store.histories, state.histories);
201
+ if (state.longBuf) Object.assign(store.longBuf, state.longBuf);
202
+ if (state.graphModes) Object.assign(graphModes, state.graphModes);
203
+
204
+ // Ripristino SOG/VMG
205
+ if (state.displayModeSog) {
206
+ displayModeSog = state.displayModeSog;
207
+ const labelEl = document.getElementById('sog-vmg-label');
208
+ if (labelEl) labelEl.textContent = displayModeSog;
209
+ }
210
+
211
+ // Ripristino Tema Notte
212
+ if (state.isNightMode) document.body.classList.add('night-mode');
213
+
214
+ // Ripristino Focus (Dual Screen)
215
+ if (state.isFocusActive && state.focusedBoxType) {
216
+ setTimeout(() => {
217
+ const el = document.querySelector(`.box-${state.focusedBoxType}`);
218
+ if (el) { isFocusActive = false; toggleFocusMode(state.focusedBoxType, el); }
219
+ }, 200);
220
+ }
221
+ console.log("Stato ripristinato con successo dalla cache.");
222
+ }
223
+ } catch (e) { localStorage.removeItem('rotevista_dash_state'); }
101
224
  }
102
225
 
226
+ // ==========================================================================
227
+ // 4. AUDIO E ALLARMI
228
+ // ==========================================================================
103
229
  function playBingBing() {
104
230
  if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
105
231
  const n = Date.now(); if (n - lastAlarmTime < 3000) return; lastAlarmTime = n;
106
- 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); }
232
+ function b(f, s) {
233
+ const o = audioCtx.createOscillator(); const g = audioCtx.createGain();
234
+ o.connect(g); g.connect(audioCtx.destination); o.frequency.value = f;
235
+ g.gain.setValueAtTime(0.1, s); g.gain.exponentialRampToValueAtTime(0.01, s + 0.4);
236
+ o.start(s); o.stop(s + 0.5);
237
+ }
107
238
  b(880, audioCtx.currentTime); b(880, audioCtx.currentTime + 0.6);
108
239
  }
109
240
 
110
241
  function playGybeAlarm() {
111
242
  if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
112
243
  const n = Date.now(); if (n - lastAlarmTime < 2000) return; lastAlarmTime = n;
113
- 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); }
114
- 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); }
244
+ function note(f, s, d) {
245
+ const o = audioCtx.createOscillator(); const g = audioCtx.createGain();
246
+ o.connect(g); g.connect(audioCtx.destination); o.type = 'square';
247
+ o.frequency.value = f; g.gain.setValueAtTime(0.05, s);
248
+ g.gain.exponentialRampToValueAtTime(0.001, s + d); o.start(s); o.stop(s + d);
249
+ }
250
+ for (let i = 0; i < 4; i++) {
251
+ note(1800, audioCtx.currentTime + (i * 0.15), 0.1);
252
+ note(1200, audioCtx.currentTime + (i * 0.15) + 0.07, 0.1);
253
+ }
115
254
  }
116
255
 
117
256
  function checkDepthAlarm(m) {
@@ -127,270 +266,337 @@ function updateLeewayDisplay(deg) {
127
266
  }
128
267
 
129
268
  // ==========================================================================
130
- // 4. MOTORE DI CALCOLO VENTO E DATA ROUTING
269
+ // 5. MOTORE DI CALCOLO VENTO E DATA ROUTING
131
270
  // ==========================================================================
132
271
  function computeTrueWind() {
133
- const aws = store.raw["environment.wind.speedApparent"];
134
- let awa = store.raw["environment.wind.angleApparent"];
272
+ const aws = store.raw["environment.wind.speedApparent"], awa = store.raw["environment.wind.angleApparent"];
135
273
  const stw = store.raw["navigation.speedThroughWater"] || 0, sog = store.raw["navigation.speedOverGround"] || 0;
136
274
  const hdg = store.raw["navigation.headingTrue"] || 0, cog = store.raw["navigation.courseOverGroundTrue"] || 0;
137
-
138
275
  if (aws === undefined || awa === undefined) return;
139
- if (awa > Math.PI) awa -= 2 * Math.PI;
140
276
 
277
+ // Vento Reale Rispetto all'acqua (TWA/TWS Water)
141
278
  const tw_water_x = aws * Math.cos(awa) - stw, tw_water_y = aws * Math.sin(awa);
142
279
  const tws_water = Math.sqrt(tw_water_x * tw_water_x + tw_water_y * tw_water_y);
143
280
 
144
- const drift_angle = (cog - hdg + Math.PI * 3) % (2 * Math.PI) - Math.PI;
145
- const sog_vec_x = sog * Math.cos(drift_angle), sog_vec_y = sog * Math.sin(drift_angle);
146
- const tw_ground_x = aws * Math.cos(awa) - sog_vec_x, tw_ground_y = aws * Math.sin(awa) - sog_vec_y;
281
+ // Vento Reale Rispetto al fondo (TWD Ground)
282
+ const drift = (cog - hdg + Math.PI * 3) % (2 * Math.PI) - Math.PI;
283
+ const tw_ground_x = aws * Math.cos(awa) - sog * Math.cos(drift), tw_ground_y = aws * Math.sin(awa) - sog * Math.sin(drift);
147
284
  const tws_ground = Math.sqrt(tw_ground_x * tw_ground_x + tw_ground_y * tw_ground_y);
148
285
 
149
286
  const now = Date.now();
150
287
  store.raw["environment.wind.speedTrue"] = tws_water;
151
-
152
288
  if (tws_water > 0.05) {
153
- const twa_water = Math.atan2(tw_water_y, tw_water_x);
154
- store.raw["environment.wind.angleTrueWater"] = twa_water;
155
- safePush(store.smoothBuf.twa, twa_water, now); safePush(store.longBuf.twa, twa_water, now);
289
+ const twa = Math.atan2(tw_water_y, tw_water_x);
290
+ store.raw["environment.wind.angleTrueWater"] = twa;
291
+ safePush(store.smoothBuf.twa, twa, now); safePush(store.longBuf.twa, twa, now);
156
292
  }
157
293
  if (tws_ground > 0.05) {
158
- let twd_ground = (hdg + Math.atan2(tw_ground_y, tw_ground_x) + 2 * Math.PI) % (2 * Math.PI);
159
- store.raw["environment.wind.directionTrue"] = twd_ground;
160
- safePush(store.smoothBuf.twd, twd_ground, now); safePush(store.longBuf.twd, twd_ground, now);
294
+ let twd = (hdg + Math.atan2(tw_ground_y, tw_ground_x) + 2 * Math.PI) % (2 * Math.PI);
295
+ store.raw["environment.wind.directionTrue"] = twd;
296
+ safePush(store.smoothBuf.twd, twd, now); safePush(store.longBuf.twd, twd, now);
161
297
  }
162
298
  }
163
299
 
164
300
  function processIncomingData(path, val) {
165
301
  const now = Date.now(); store.timestamps[path] = now; store.raw[path] = val;
166
- if (path === "navigation.position") store.raw["navigation.position"] = val;
167
302
  if (path === "environment.wind.angleApparent") { safePush(store.smoothBuf.awa, val, now); safePush(store.longBuf.awa, val, now); }
168
-
169
303
  const twPaths = ["environment.wind.speedApparent", "environment.wind.angleApparent", "navigation.speedThroughWater", "navigation.speedOverGround", "navigation.headingTrue", "navigation.courseOverGroundTrue"];
170
304
  if (twPaths.includes(path)) twDirty = true;
171
305
  if (twDirty && (now - lastTWCompute > 100)) { computeTrueWind(); lastTWCompute = now; twDirty = false; }
172
-
173
306
  if (path === "navigation.headingTrue") { safePush(store.smoothBuf.hdg, val, now); safePush(store.longBuf.hdg, val, now); }
174
307
  if (path === "navigation.courseOverGroundTrue") { safePush(store.smoothBuf.cog, val, now); safePush(store.longBuf.cog, val, now); }
175
308
  }
176
309
 
177
310
  // ==========================================================================
178
- // 5. TREND VENTO E SICUREZZA
311
+ // 6. TREND VENTO (Tattico 2s vs 10s | Strategico 1m vs 10m)
179
312
  // ==========================================================================
180
- /**
181
- * Analizza i trend di rotazione del vento su due scale temporali:
182
- * 1. Tattica (veloce): per la regolazione delle vele (sulla lancetta TWA)
183
- * 2. Strategica (lenta): per le previsioni meteo a lungo termine (bussola TWD)
184
- */
185
313
  function updateWindTrend() {
186
314
  const now = Date.now();
187
- const twaAvgObj = getCircularAverageFromBuffer(store.longBuf.twa, 30000, true);
188
- const shortAvg = getCircularAverageFromBuffer(store.longBuf.twd, 5000, false);
189
- const instantTwaRad = store.raw["environment.wind.angleTrueWater"];
190
-
191
- if (!shortAvg || !twaAvgObj || instantTwaRad === undefined) return;
192
- const instantTwaDeg = radToDeg(instantTwaRad), shortAvgDeg = radToDeg(shortAvg.val), twaAvgDeg = radToDeg(twaAvgObj.val);
193
-
194
- if (lastShortAvgVal === null) { lastShortAvgVal = shortAvgDeg; lastInstantTwa = instantTwaDeg; return; }
195
- const dt = (now - lastTrendTime) / 1000; lastTrendTime = now;
196
-
197
- // ALLARME STRAMBATA
198
- const gybeDetected = (Math.abs(instantTwaDeg) > 155 && Math.sign(instantTwaDeg) !== Math.sign(lastInstantTwa));
199
- lastInstantTwa = instantTwaDeg;
200
-
315
+ // TATTICA (Lancetta): 2s vs 10s (reazione rapida per trim)
316
+ const twaNow = getCircularAverageFromBuffer(store.longBuf.twa, 2000, true);
317
+ const twaRef = getCircularAverageFromBuffer(store.longBuf.twa, 10000, true);
318
+
319
+ // STRATEGIA (Bussola TWD): 1 min vs 30 minuti (tendenza meteo profonda)
320
+ const twdNow = getCircularAverageFromBuffer(store.longBuf.twd, 60000, false);
321
+ const twdRef = getCircularAverageFromBuffer(store.longBuf.twd, 1800000, false); // 1.800.000 ms = 30 min
322
+
323
+ if (!twaNow || !twaRef || !twdNow || !twdRef) return;
201
324
  const compassDots = { cw: document.getElementById('trend-dot-cw'), ccw: document.getElementById('trend-dot-ccw') };
202
325
  const gaugeDots = { cw: document.getElementById('trend-gauge-cw'), ccw: document.getElementById('trend-gauge-ccw') };
203
326
 
204
- if (gybeDetected && isNavigating && (now - lastGybeAlarmTime > 5000)) { lastGybeAlarmTime = now; playGybeAlarm(); }
205
- if (now - lastGybeAlarmTime < 4000 && isNavigating) {
206
- [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'); }});
207
- return;
208
- }
209
-
210
- // CALCOLO TREND
211
- let diff = (shortAvgDeg - lastShortAvgVal + 540) % 360 - 180; lastShortAvgVal = shortAvgDeg;
212
- if (dt > 0) {
213
- let rate = Math.max(-10, Math.min(10, diff / dt));
214
- // Tattico (veloce ~15s)
215
- const alphaT = Math.min(1, dt / 15);
216
- rotationTrend = rotationTrend * (1 - alphaT) + rate * alphaT;
217
- // Strategico (molto lento ~8-10min)
218
- const alphaM = Math.min(1, dt / 500);
219
- meteoTrend = meteoTrend * (1 - alphaM) + rate * alphaM;
220
- }
221
-
222
- // VISUALIZZAZIONE METEO (Bussola Centrale)
223
- if (Math.abs(meteoTrend) > 0.2) {
327
+ // TREND METEO (Bussola Centrale TWD)
328
+ let deltaMeteo = radToDeg((twdNow.val - twdRef.val + Math.PI * 3) % (Math.PI * 2) - Math.PI);
329
+ if (Math.abs(deltaMeteo) > 6.0) {
224
330
  const isSouth = store.raw["navigation.position"]?.latitude < 0;
225
- let meteoColor = (!isSouth) ? (meteoTrend < 0 ? "#27ae60" : "#c0392b") : (meteoTrend > 0 ? "#27ae60" : "#c0392b");
226
- if (meteoTrend > 0) {
227
- if (compassDots.cw) { compassDots.cw.classList.add('is-trending'); compassDots.cw.setAttribute('fill', meteoColor); }
228
- if (compassDots.ccw) { compassDots.ccw.classList.remove('is-trending'); compassDots.ccw.setAttribute('fill', '#bbb'); }
331
+ let meteoColor = (!isSouth) ? (deltaMeteo < 0 ? "#27ae60" : "#c0392b") : (deltaMeteo > 0 ? "#27ae60" : "#c0392b");
332
+ if (deltaMeteo > 0) {
333
+ compassDots.cw.classList.add('is-trending'); compassDots.cw.setAttribute('fill', meteoColor);
334
+ compassDots.ccw.classList.remove('is-trending');
229
335
  } else {
230
- if (compassDots.ccw) { compassDots.ccw.classList.add('is-trending'); compassDots.ccw.setAttribute('fill', meteoColor); }
231
- if (compassDots.cw) { compassDots.cw.classList.remove('is-trending'); compassDots.cw.setAttribute('fill', '#bbb'); }
336
+ compassDots.ccw.classList.add('is-trending'); compassDots.ccw.setAttribute('fill', meteoColor);
337
+ compassDots.cw.classList.remove('is-trending');
232
338
  }
233
- } else {
234
- [compassDots.cw, compassDots.ccw].forEach(el => { if (el) { el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }});
235
- }
236
-
237
- // VISUALIZZAZIONE TATTICA (Lancetta Vento)
238
- if (Math.abs(rotationTrend) > 3.0) {
239
- let isLift = (twaAvgDeg > 0) ? (rotationTrend > 0) : (rotationTrend < 0);
240
- if (Math.abs(twaAvgDeg) >= 90) isLift = !isLift;
241
- const tacticColor = isLift ? "#27ae60" : "#c0392b";
242
- if (rotationTrend > 0) {
243
- if (gaugeDots.cw) { gaugeDots.cw.classList.add('is-trending'); gaugeDots.cw.setAttribute('fill', tacticColor); }
244
- if (gaugeDots.ccw) { gaugeDots.ccw.classList.remove('is-trending'); gaugeDots.ccw.setAttribute('fill', '#bbb'); }
339
+ } else { [compassDots.cw, compassDots.ccw].forEach(el => { if(el){ el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }}); }
340
+
341
+ // TREND TATTICO (Lancetta Arancione)
342
+ let deltaTac = radToDeg((twaNow.val - twaRef.val + Math.PI * 3) % (2 * Math.PI) - Math.PI);
343
+ const curTwaDeg = radToDeg(twaNow.val);
344
+ if (Math.abs(deltaTac) > 3.0) {
345
+ // Calcolo logica tattica: LIFT (Verde), HEADER (Rosso) o NEUTRO (Grigio)
346
+ let absTwa = Math.abs(curTwaDeg);
347
+ let tacticColor;
348
+
349
+ // Se siamo tra 75° e 105° (Traverso), il cambio è considerato neutro
350
+ if (absTwa > 75 && absTwa < 105) {
351
+ tacticColor = "#bbb"; // Grigio/Bianco sporco neutro
245
352
  } else {
246
- if (gaugeDots.ccw) { gaugeDots.ccw.classList.add('is-trending'); gaugeDots.ccw.setAttribute('fill', tacticColor); }
247
- if (gaugeDots.cw) { gaugeDots.cw.classList.remove('is-trending'); gaugeDots.cw.setAttribute('fill', '#bbb'); }
353
+ let isLift = (curTwaDeg > 0) ? (deltaTac > 0) : (deltaTac < 0);
354
+ if (absTwa >= 90) isLift = !isLift; // Inversione logica per andature portanti
355
+ tacticColor = isLift ? "#27ae60" : "#c0392b";
248
356
  }
249
- } else {
250
- [gaugeDots.cw, gaugeDots.ccw].forEach(el => { if (el) { el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }});
357
+ if (deltaTac > 0) {
358
+ gaugeDots.cw.classList.add('is-trending'); gaugeDots.cw.setAttribute('fill', tacticColor);
359
+ gaugeDots.ccw.classList.remove('is-trending');
360
+ } else {
361
+ gaugeDots.ccw.classList.add('is-trending'); gaugeDots.ccw.setAttribute('fill', tacticColor);
362
+ gaugeDots.cw.classList.remove('is-trending');
363
+ }
364
+ } else { [gaugeDots.cw, gaugeDots.ccw].forEach(el => { if(el){ el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }}); }
365
+
366
+ // ALLARME STRAMBATA (GYBE)
367
+ const instTwa = radToDeg(store.raw["environment.wind.angleTrueWater"] || 0);
368
+ if (Math.abs(instTwa) > 155 && Math.sign(instTwa) !== Math.sign(lastInstantTwa)) {
369
+ if (isNavigating && (now - lastGybeAlarmTime > 5000)) { lastGybeAlarmTime = now; playGybeAlarm(); }
251
370
  }
371
+ lastInstantTwa = instTwa;
252
372
  }
253
373
 
254
374
  // ==========================================================================
255
- // 6. RENDERING ENGINE (TIERED)
375
+ // 7. RENDERING ENGINE E AGGIORNAMENTO UI
256
376
  // ==========================================================================
257
377
  function refreshGraph(t) {
258
378
  const type = (t === 'vmg') ? 'sog' : t;
259
379
  const data = store.histories[t]; if (!data || data.length < 2) return;
260
380
  const mode = graphModes[type], cfg = calculateScale(type, data, mode);
261
- const graphEl = document.getElementById(type + '-graph');
262
- if (graphEl) { const box = graphEl.closest('.data-box'); if (box) box.classList.toggle('box-hercules', mode === 'hercules'); }
263
- updateScaleLabels(type, cfg.min, cfg.max); drawGraph(data, type + '-graph', cfg.min, cfg.max, t === 'tws', mode === 'hercules');
381
+
382
+ // Gestione visualizzazione Hercules Zoom (sfondo rosso)
383
+ const box = document.querySelector(`.box-${type}`);
384
+ if (box) box.classList.toggle('box-hercules', mode === 'hercules');
385
+
386
+ updateScaleLabels(type, cfg.min, cfg.max);
387
+ drawGraph(data, type + '-graph', cfg.min, cfg.max, t === 'tws', mode === 'hercules');
264
388
  }
265
389
 
390
+ /**
391
+ * upUI: Aggiornamento valori digitali con logica anti-ritardo (Istantaneo vs Media)
392
+ */
393
+ const upUI = (el, obj, instantRaw, isCompass = false) => {
394
+ if (!obj || obj.val === null || instantRaw === undefined) {
395
+ el.innerHTML = "---&deg;"; el.classList.remove('unstable-data');
396
+ } else {
397
+ let valDeg = Math.round(radToDeg(obj.val));
398
+ let mainVal = (isCompass ? ((valDeg + 360) % 360).toString().padStart(3, '0') : valDeg) + "&deg;";
399
+ let dev = (obj.dev > 1 && obj.dev < 90) ? `<span style="font-size: 0.35em; opacity: 0.5; margin-left: 4px; vertical-align: middle;">&plusmn;${obj.dev}</span>` : "";
400
+ el.innerHTML = mainVal + dev;
401
+
402
+ let diff = Math.abs((radToDeg(instantRaw) - radToDeg(obj.val) + 540) % 360 - 180);
403
+ // Allarme lampeggio solo se in navigazione E (R bassa O deviazione alta O salto istantaneo brusco)
404
+ if (isNavigating && (!obj.stable || obj.dev > CONFIG.averages.stabilityBreakout || diff > CONFIG.averages.stabilityBreakout)) el.classList.add('unstable-data');
405
+ else el.classList.remove('unstable-data');
406
+ }
407
+ };
408
+
409
+ /**
410
+ * Loop principale di aggiornamento interfaccia (1Hz)
411
+ */
412
+ /**
413
+ * Loop principale di aggiornamento interfaccia (1Hz)
414
+ * Gestisce la gerarchia di aggiornamento Live (1s), Heavy (2s) e Slow (3s).
415
+ */
266
416
  function startDisplayLoop() {
267
- let tick = 0;
268
417
  renderInterval = setInterval(() => {
269
- const now = Date.now(); tick++;
270
-
271
- const stwKts = msToKts(store.raw["navigation.speedThroughWater"] || 0), sogKts = msToKts(store.raw["navigation.speedOverGround"] || 0);
418
+ const now = Date.now();
419
+ const stwKts = msToKts(store.raw["navigation.speedThroughWater"] || 0);
420
+ const sogKts = msToKts(store.raw["navigation.speedOverGround"] || 0);
421
+
422
+ // Verifica stato navigazione basato su soglia impostata
272
423
  isNavigating = stwKts > CONFIG.averages.minSpeed || sogKts > CONFIG.averages.minSpeed;
273
424
 
274
- // LIVE TIER (1s)
275
- 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 };
276
- 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]; } }
425
+ // --- TIER LIVE (1s): CONTROLLO TIMEOUT DATI ---
426
+ const watch = { "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 };
427
+ for (let p in watch) {
428
+ if (!store.timestamps[p] || (now - store.timestamps[p] > TIMEOUT_MS)) {
429
+ watch[p].innerText = "---"; delete store.raw[p];
430
+ }
431
+ }
277
432
 
278
- if (store.raw["navigation.speedThroughWater"] !== undefined) { ui.stw.innerText = stwKts.toFixed(1); manageHistory('stw', stwKts); }
433
+ // --- AGGIORNAMENTO DATI ISTANTANEI ---
434
+ if (store.raw["navigation.speedThroughWater"] !== undefined) {
435
+ ui.stw.innerText = stwKts.toFixed(1); manageHistory('stw', stwKts);
436
+ }
437
+
438
+ // --- LOGICA SOG / VMG E COLORI DINAMICI ---
279
439
  if (store.raw["navigation.speedOverGround"] !== undefined) {
280
- const twaRad = store.raw["environment.wind.angleTrueWater"], vmgKts = (twaRad !== undefined) ? Math.abs(stwKts * Math.cos(twaRad)) : 0;
281
- manageHistory('vmg', vmgKts); manageHistory('sog', sogKts);
440
+ const vmg = Math.abs(stwKts * Math.cos(store.raw["environment.wind.angleTrueWater"] || 0));
441
+ manageHistory('vmg', vmg); manageHistory('sog', sogKts);
442
+
443
+ const labelEl = document.getElementById('sog-vmg-label');
282
444
  if (displayModeSog === 'VMG') {
283
- ui.sog.innerText = vmgKts.toFixed(1); ui.sog.style.color = "#16a085"; document.getElementById('sog-vmg-label').textContent = 'VMG';
445
+ ui.sog.innerText = vmg.toFixed(1);
446
+ ui.sog.style.setProperty('color', '#16a085', 'important'); // Verde Petrolio
447
+ if (labelEl) labelEl.textContent = 'VMG';
284
448
  } else {
285
449
  ui.sog.innerText = sogKts.toFixed(1);
286
- ui.sog.style.color = (sogKts - stwKts > 0.3) ? "#27ae60" : (sogKts - stwKts < -0.3 ? "#c0392b" : "#000");
287
- document.getElementById('sog-vmg-label').textContent = 'SOG';
450
+ if (labelEl) labelEl.textContent = 'SOG';
451
+
452
+ // Colore Corrente: se neutro usiamo "", così il CSS Night Mode può agire
453
+ if (sogKts - stwKts > 0.3) ui.sog.style.setProperty('color', '#27ae60', 'important');
454
+ else if (sogKts - stwKts < -0.3) ui.sog.style.setProperty('color', '#c0392b', 'important');
455
+ else ui.sog.style.color = "";
288
456
  }
289
457
  }
290
- 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); }
291
- if (store.raw["environment.wind.speedTrue"] !== undefined) { ui.tws.innerText = msToKts(store.raw["environment.wind.speedTrue"]).toFixed(1); ui.tws.style.color = (msToKts(store.raw["environment.wind.speedTrue"]) >= CONFIG.graphs.reef2) ? "#e74c3c" : (msToKts(store.raw["environment.wind.speedTrue"]) >= CONFIG.graphs.reef1 ? "#e67e22" : "#000"); manageHistory('tws', msToKts(store.raw["environment.wind.speedTrue"])); }
292
- if (store.raw["environment.wind.speedApparent"] !== undefined) ui.awsSvg.textContent = msToKts(store.raw["environment.wind.speedApparent"]).toFixed(1);
458
+
459
+ if (store.raw["environment.depth.belowTransducer"] !== undefined) {
460
+ ui.depth.innerText = store.raw["environment.depth.belowTransducer"].toFixed(1);
461
+ checkDepthAlarm(store.raw["environment.depth.belowTransducer"]);
462
+ manageHistory('depth', store.raw["environment.depth.belowTransducer"]);
463
+ }
464
+
465
+ if (store.raw["environment.wind.speedTrue"] !== undefined) {
466
+ const twsKts = msToKts(store.raw["environment.wind.speedTrue"]);
467
+ ui.tws.innerText = twsKts.toFixed(1);
468
+
469
+ // Colore Reef: se normale usiamo "", il CSS metterà Nero (giorno) o Rosso (notte)
470
+ if (twsKts >= CONFIG.graphs.reef2) ui.tws.style.setProperty('color', '#e74c3c', 'important');
471
+ else if (twsKts >= CONFIG.graphs.reef1) ui.tws.style.setProperty('color', '#e67e22', 'important');
472
+ else ui.tws.style.color = "";
473
+
474
+ manageHistory('tws', twsKts);
475
+ }
476
+
477
+ if (store.raw["environment.wind.speedApparent"] !== undefined) {
478
+ ui.awsSvg.textContent = msToKts(store.raw["environment.wind.speedApparent"]).toFixed(1);
479
+ }
293
480
 
481
+ // --- PUNTATORI ANALOGICI (Smoothing 2s) ---
294
482
  const smAwa = getCircularAverageFromBuffer(store.smoothBuf.awa, 2000, true);
295
- if (smAwa) { curAwaRot = getShortestRotation(curAwaRot, radToDeg(smAwa.val)); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
296
483
  const smTwa = getCircularAverageFromBuffer(store.smoothBuf.twa, 2000, true);
484
+ if (smAwa) { curAwaRot = getShortestRotation(curAwaRot, radToDeg(smAwa.val)); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
297
485
  if (smTwa) { curTwaRot = getShortestRotation(curTwaRot, radToDeg(smTwa.val)); ui.twa.setAttribute('transform', `rotate(${curTwaRot}, 200, 200)`); }
298
-
486
+
487
+ // --- CALCOLO LEEWAY E TRACK POINTER ---
299
488
  if (store.raw["navigation.courseOverGroundTrue"] !== undefined && store.raw["navigation.headingTrue"] !== undefined) {
300
489
  let driftDeg = radToDeg((store.raw["navigation.courseOverGroundTrue"] - store.raw["navigation.headingTrue"] + Math.PI * 3) % (Math.PI * 2) - Math.PI);
301
- if (sogKts < CONFIG.averages.minSpeed) smoothedLeeway = 0; else smoothedLeeway = (smoothedLeeway * 0.9) + (driftDeg * 0.1);
490
+ // Azzeramento sotto soglia minima impostata
491
+ smoothedLeeway = (sogKts < CONFIG.averages.minSpeed) ? 0 : (smoothedLeeway * 0.9) + (driftDeg * 0.1);
302
492
  curTrackRot = getShortestRotation(curTrackRot, smoothedLeeway); ui.track.setAttribute('transform', `rotate(${curTrackRot}, 200, 200)`);
303
- ui.leewayVal.style.color = (Math.abs(sogKts - stwKts) > 0.5 && Math.abs(smoothedLeeway) > 7) ? "#e67e22" : "#000";
493
+ ui.leewayVal.style.color = (Math.abs(sogKts - stwKts) > 0.5 && Math.abs(smoothedLeeway) > 7) ? "#e67e22" : "";
304
494
  updateLeewayDisplay(Math.max(-20, Math.min(20, smoothedLeeway)));
305
495
  }
306
-
496
+
307
497
  updateWindTrend();
308
- if (tick % 2 === 0) { refreshGraph('stw'); refreshGraph(displayModeSog === 'VMG' ? 'vmg' : 'sog'); refreshGraph('depth'); refreshGraph('tws'); }
309
-
310
- // SLOW TIER (3s)
311
- if (tick % 3 === 0) {
312
- // Utilizziamo CONFIG.averages.longWindow (che ora è 30000ms)
313
- // In questo modo, se cambi il tempo su Signal K, la dashboard si aggiorna da sola.
314
- let hObj = getCircularAverageFromBuffer(store.longBuf.hdg, CONFIG.averages.longWindow, false),
315
- cObj = getCircularAverageFromBuffer(store.longBuf.cog, CONFIG.averages.longWindow, false),
316
- awObj = getCircularAverageFromBuffer(store.longBuf.awa, CONFIG.averages.longWindow, true),
317
- twObj = getCircularAverageFromBuffer(store.longBuf.twa, CONFIG.averages.longWindow, true),
318
- twdObj = getCircularAverageFromBuffer(store.longBuf.twd, CONFIG.averages.longWindow, false);
319
-
320
- const upUI = (el, obj, isCompass = false) => {
321
- if (!obj || obj.val === null) { el.innerHTML = "---&deg;"; el.classList.remove('unstable-data'); }
322
- else {
323
- let valDeg = Math.round(radToDeg(obj.val)); el.innerHTML = (isCompass ? ((valDeg + 360) % 360).toString().padStart(3, '0') : valDeg) + "&deg;";
324
- if (obj.stable || !isNavigating) el.classList.remove('unstable-data'); else el.classList.add('unstable-data');
498
+
499
+ // TIER HEAVY (2s) - Grafici e Persistenza Browser
500
+ if (lastAvgUIUpdate++ % 2 === 0) {
501
+ ['stw', 'sog', 'depth', 'tws'].forEach(refreshGraph);
502
+ saveDashboardState();
503
+ }
504
+
505
+ // TIER SLOW (3s) - Medie Lunghe e Calcolo TACK
506
+ if (lastAvgUIUpdate % 3 === 0) {
507
+ let hObj = getCircularAverageFromBuffer(store.longBuf.hdg, CONFIG.averages.longWindow * 2, false);
508
+ let cObj = getCircularAverageFromBuffer(store.longBuf.cog, CONFIG.averages.longWindow, false);
509
+ let awObj = getCircularAverageFromBuffer(store.longBuf.awa, CONFIG.averages.longWindow, true);
510
+ let twObj = getCircularAverageFromBuffer(store.longBuf.twa, CONFIG.averages.longWindow, true);
511
+ let twdObj = getCircularAverageFromBuffer(store.longBuf.twd, CONFIG.averages.longWindow, false);
512
+
513
+ upUI(ui.hdg, hObj, store.raw["navigation.headingTrue"], true);
514
+ upUI(ui.cog, cObj, store.raw["navigation.courseOverGroundTrue"], true);
515
+ upUI(ui.awaAvg, awObj, store.raw["environment.wind.angleApparent"], false);
516
+ upUI(ui.twaAvg, twObj, store.raw["environment.wind.angleTrueWater"], false);
517
+ upUI(ui.twdAvg, twdObj, store.raw["environment.wind.directionTrue"], true);
518
+
519
+ // --- LOGICA TACK STRATEGICA (Riflessione geometrica su TWD) ---
520
+ if (hObj && twdObj) {
521
+ const tH = radToDeg((2 * twdObj.val - hObj.val + Math.PI * 2) % (Math.PI * 2));
522
+ const unstableH = !hObj.stable || !twdObj.stable || hObj.dev > CONFIG.averages.stabilityBreakout || twdObj.dev > CONFIG.averages.stabilityBreakout;
523
+
524
+ if (!isNavigating) {
525
+ ui.tackHdg.innerHTML = "---&deg;"; ui.tackHdg.classList.remove('unstable-data');
526
+ } else if (unstableH) {
527
+ ui.tackHdg.innerHTML = "---&deg;"; ui.tackHdg.classList.add('unstable-data');
528
+ } else {
529
+ ui.tackHdg.innerHTML = `${Math.round((tH + 360) % 360).toString().padStart(3, '0')}&deg;`;
530
+ ui.tackHdg.classList.remove('unstable-data');
325
531
  }
326
- };
327
- 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);
328
-
329
- if (hObj && twObj) {
330
- const tackHdgDeg = radToDeg((hObj.val + twObj.val * 2 + Math.PI * 2) % (Math.PI * 2));
331
- ui.tackHdg.innerHTML = `${Math.round((tackHdgDeg + 360) % 360).toString().padStart(3, '0')}&deg;`;
532
+
332
533
  if (cObj) {
333
- const tackCogDeg = radToDeg((cObj.val + twObj.val * 2 + Math.PI * 2) % (Math.PI * 2));
334
- ui.tackCog.innerHTML = `${Math.round((tackCogDeg + 360) % 360).toString().padStart(3, '0')}&deg;`;
534
+ const tC = radToDeg((2 * twdObj.val - cObj.val + Math.PI * 2) % (Math.PI * 2));
535
+ const unstableC = !cObj.stable || !twdObj.stable || cObj.dev > CONFIG.averages.stabilityBreakout || twdObj.dev > CONFIG.averages.stabilityBreakout;
536
+
537
+ if (!isNavigating) {
538
+ ui.tackCog.innerHTML = "---&deg;"; ui.tackCog.classList.remove('unstable-data');
539
+ } else if (unstableC) {
540
+ ui.tackCog.innerHTML = "---&deg;"; ui.tackCog.classList.add('unstable-data');
541
+ } else {
542
+ ui.tackCog.innerHTML = `${Math.round((tC + 360) % 360).toString().padStart(3, '0')}&deg;`;
543
+ ui.tackCog.classList.remove('unstable-data');
544
+ }
335
545
  }
336
546
  }
337
- const smHdg = getCircularAverageFromBuffer(store.smoothBuf.hdg, 2000, false), smTwd = getCircularAverageFromBuffer(store.smoothBuf.twd, 2000, false);
338
- if (smHdg && smTwd) {
339
- curWindCompassRot = getShortestRotation(curWindCompassRot, radToDeg(smTwd.val)); ui.twdArrow.setAttribute('transform', `rotate(${curWindCompassRot}, 20, 20)`);
340
- curBoatCompassRot = getShortestRotation(curBoatCompassRot, radToDeg(smHdg.val)); ui.twdBoat.setAttribute('transform', `rotate(${curBoatCompassRot}, 20, 20)`);
547
+
548
+ // Rotazione Mini-Bussole
549
+ const smHdgIcons = getCircularAverageFromBuffer(store.smoothBuf.hdg, 2000, false);
550
+ const smTwdIcons = getCircularAverageFromBuffer(store.smoothBuf.twd, 2000, false);
551
+ if (smHdgIcons && smTwdIcons) {
552
+ curWindCompassRot = getShortestRotation(curWindCompassRot, radToDeg(smTwdIcons.val)); ui.twdArrow.setAttribute('transform', `rotate(${curWindCompassRot}, 20, 20)`);
553
+ curBoatCompassRot = getShortestRotation(curBoatCompassRot, radToDeg(smHdgIcons.val)); ui.twdBoat.setAttribute('transform', `rotate(${curBoatCompassRot}, 20, 20)`);
341
554
  }
342
- lastAvgUIUpdate = now;
343
555
  }
344
- if (tick % 60 === 0) tick = 0;
345
556
  }, RENDER_INTERVAL_MS);
346
557
  }
347
558
 
348
559
  // ==========================================================================
349
- // 7. CONFIGURAZIONE E GRAFICI UTILS
560
+ // 8. CONFIGURAZIONE E GRAFICI UTILS
350
561
  // ==========================================================================
351
562
  async function fetchServerConfig() {
352
563
  if (!window.location.protocol.includes("http")) return;
353
- const pluginID = 'rotevista-dash';
354
- const possibleUrls = [`/skServer/plugins/${pluginID}/config`, `/plugins/${pluginID}/config` ];
355
- for (let url of possibleUrls) {
356
- try {
357
- const response = await fetch(url);
358
- if (response.ok) {
359
- const data = await response.json();
360
- const actual = data.configuration || data;
361
- if (actual) {
362
- 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]); } };
363
- parseNumbers(actual);
364
- if (actual.alarms) CONFIG.alarms = { ...CONFIG.alarms, ...actual.alarms };
365
- if (actual.graphs) CONFIG.graphs = { ...CONFIG.graphs, ...actual.graphs };
366
- if (actual.averaging) CONFIG.averages = { ...CONFIG.averages, ...actual.averaging };
367
- if (actual.scales) { for (let key in actual.scales) { CONFIG.scales[key] = { ...CONFIG.scales[key], ...actual.scales[key] }; } }
368
- }
564
+ try {
565
+ const response = await fetch(`/plugins/rotevista-dash/config`);
566
+ if (response.ok) {
567
+ const data = await response.json();
568
+ const actual = data.configuration || data;
569
+ if (actual) {
570
+ const pN = (obj) => { for (let k in obj) { if (typeof obj[k] === 'object') pN(obj[k]); else if (!isNaN(obj[k]) && obj[k] !== "") obj[k] = parseFloat(obj[k]); } };
571
+ pN(actual);
572
+ if (actual.alarms) CONFIG.alarms = { ...CONFIG.alarms, ...actual.alarms };
573
+ if (actual.averaging) CONFIG.averages = { ...CONFIG.averages, ...actual.averaging };
574
+ if (actual.scales) { for (let k in actual.scales) CONFIG.scales[k] = { ...CONFIG.scales[k], ...actual.scales[k] }; }
369
575
  }
370
- } catch (e) { }
371
- }
576
+ }
577
+ } catch (e) { }
372
578
  }
373
579
 
374
580
  function manageHistory(t, v) {
375
- const n = Date.now();
376
- const interval = simulationMode ? SIM_SAMPLE_INTERVAL : (CONFIG.graphs.historyMinutes * 60000) / CONFIG.graphs.samples;
581
+ const n = Date.now(), interval = (CONFIG.graphs.historyMinutes * 60000) / CONFIG.graphs.samples;
377
582
  if (!store.graphTempBuf[t]) store.graphTempBuf[t] = [];
378
583
  store.graphTempBuf[t].push(v);
379
-
380
584
  if (n - store.lastUpdates[t] > interval || store.histories[t].length === 0) {
381
- const sum = store.graphTempBuf[t].reduce((a, b) => a + b, 0);
382
- const avg = sum / store.graphTempBuf[t].length;
585
+ const avg = store.graphTempBuf[t].reduce((a, b) => a + b, 0) / store.graphTempBuf[t].length;
383
586
  store.histories[t].push(avg);
384
587
  if (store.histories[t].length > CONFIG.graphs.samples) store.histories[t].shift();
385
- store.graphTempBuf[t] = [];
386
- store.lastUpdates[t] = n;
588
+ store.graphTempBuf[t] = []; store.lastUpdates[t] = n;
387
589
  }
388
590
  }
389
591
 
390
592
  function calculateScale(type, data, mode) {
391
- const s = CONFIG.scales[type] || { stdMax: 12, hercSpan: 4, step: 2 }; let aMin = Math.min(...data), aMax = Math.max(...data);
392
- 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 }; }
393
- else return { min: 0, max: Math.max(s.stdMax, Math.ceil(aMax / s.step) * s.step) };
593
+ const s = CONFIG.scales[type]; let aMin = Math.min(...data), aMax = Math.max(...data);
594
+ if (mode === 'hercules') {
595
+ let avg = (aMin + aMax) / 2; let span = Math.max(s.hercSpan, Math.ceil(aMax - aMin));
596
+ if (span % 2 !== 0) span += 1; let min = Math.max(0, Math.floor(avg - (span / 2)));
597
+ return { min, max: min + span };
598
+ }
599
+ return { min: 0, max: Math.max(s.stdMax, Math.ceil(aMax / s.step) * s.step) };
394
600
  }
395
601
 
396
602
  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>`; }
@@ -398,9 +604,15 @@ function updateScaleLabels(t, min, max) { const el = document.getElementById(t +
398
604
  function drawGraph(d, id, min, max, isTws, isHercules) {
399
605
  const svg = document.getElementById(id); if (!svg || d.length < 2) return;
400
606
  const w = 200, h = 40, range = max - min || 1;
401
- let grids = "";
402
- [0.25, 0.5, 0.75].forEach(p => { grids += `<line x1="0" y1="${h-(p*h)}" x2="${w}" y2="${h-(p*h)}" stroke="rgba(0,0,0,0.12)" stroke-width="0.5" />`; });
403
- for (let m = 1; m < CONFIG.graphs.historyMinutes; m++) {
607
+ 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(0,0,0,0.12)" stroke-width="0.5" />`; });
608
+ /**
609
+ * Griglia Temporale Intelligente:
610
+ * - Storia <= 15 min: linee ogni 1 minuto.
611
+ * - Storia > 15 min: linee ogni 5 minuti.
612
+ */
613
+ const gridInterval = (CONFIG.graphs.historyMinutes <= 15) ? 1 : 5;
614
+
615
+ for (let m = gridInterval; m < CONFIG.graphs.historyMinutes; m += gridInterval) {
404
616
  const x = w - (m / CONFIG.graphs.historyMinutes) * w;
405
617
  grids += `<line x1="${x}" y1="0" x2="${x}" y2="${h}" stroke="rgba(0,0,0,0.08)" stroke-width="0.5" />`;
406
618
  }
@@ -410,7 +622,7 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
410
622
  if (isTws && i > 0) {
411
623
  const px = ((i-1)/(CONFIG.graphs.samples-1))*w, py = h-(Math.max(0,Math.min(1,(d[i-1]-min)/range))*h);
412
624
  let c = (v >= CONFIG.graphs.reef2) ? "#e74c3c" : (v >= CONFIG.graphs.reef1 ? "#e67e22" : "#000");
413
- cS += `<line x1="${px}" y1="${py}" x2="${x}" y2="${y}" stroke="${c}" class="${isHercules?'line-hercules':''}" />`;
625
+ cS += `<line x1="${px}" y1="${py}" x2="${x}" y2="${y}" stroke="${c}" class="tws-reef-line ${isHercules?'line-hercules':''}" />`;
414
626
  }
415
627
  });
416
628
  const clrs = { 'stw-graph': '#2ecc71', 'sog-graph': '#f39c12', 'depth-graph': '#3498db', 'tws-graph': '#000' };
@@ -419,101 +631,41 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
419
631
  }
420
632
 
421
633
  // ==========================================================================
422
- // 8. INTERAZIONI E RETE
634
+ // 9. INTERAZIONI E GESTI
423
635
  // ==========================================================================
424
636
  function toggleFocusMode(type, element) {
425
637
  const container = document.querySelector('.main-container');
426
-
427
- // Con la nuova struttura Flat HTML, non abbiamo più '.left-panel'.
428
- // Dobbiamo determinare il lato guardando il tipo di box.
429
- const leftBoxes = ['stw', 'sog', 'hdg', 'cog', 'tack'];
430
- const isLeft = leftBoxes.includes(type);
431
-
638
+ const isLeft = ['stw', 'sog', 'hdg', 'cog', 'tack'].includes(type);
432
639
  isFocusActive = !isFocusActive;
433
-
434
- if (isFocusActive) {
435
- container.classList.add('focus-active', isLeft ? 'focus-side-left' : 'focus-side-right');
436
- element.classList.add('is-focused');
437
- } else {
438
- container.classList.remove('focus-active', 'focus-side-left', 'focus-side-right');
439
- document.querySelectorAll('.data-box').forEach(b => b.classList.remove('is-focused'));
440
- }
640
+ if (isFocusActive) { container.classList.add('focus-active', isLeft ? 'focus-side-left' : 'focus-side-right'); element.classList.add('is-focused'); }
641
+ else { container.classList.remove('focus-active', 'focus-side-left', 'focus-side-right'); document.querySelectorAll('.data-box').forEach(b => b.classList.remove('is-focused')); }
441
642
  }
442
643
 
443
644
  ['stw', 'sog', 'tws', 'depth'].forEach(type => {
444
- // Risale dal grafico al contenitore principale (funziona con la nuova struttura)
445
645
  const el = document.getElementById(type + '-graph').closest('.data-box');
446
646
  let lastTapTime = 0, tapTimeout, isLongPressActive = false;
447
-
448
- el.addEventListener('pointerdown', (e) => {
449
- isLongPressActive = false;
450
- pressTimer = setTimeout(() => {
451
- if (!isFocusActive) {
452
- isLongPressActive = true;
453
- toggleFocusMode(type, el);
454
- lastTapTime = 0;
455
- }
456
- }, 1000);
457
- });
458
-
647
+ el.addEventListener('pointerdown', (e) => { isLongPressActive = false; pressTimer = setTimeout(() => { if (!isFocusActive) { isLongPressActive = true; toggleFocusMode(type, el); lastTapTime = 0; } }, 1000); });
459
648
  el.addEventListener('pointerup', (e) => {
460
- clearTimeout(pressTimer);
461
- if (isLongPressActive) return; // Se era focus, ignora l'up
462
-
649
+ clearTimeout(pressTimer); if (isLongPressActive) return;
463
650
  const currentTime = new Date().getTime(), tapDelay = currentTime - lastTapTime;
464
-
465
- // DOPPIO TAP: Toggle Hercules Mode (Funziona anche in Focus)
466
- if (tapDelay < 300 && tapDelay > 0) {
467
- clearTimeout(tapTimeout); // Cancella l'uscita dal Focus
468
- graphModes[type] = graphModes[type] === 'standard' ? 'hercules' : 'standard';
469
- localStorage.setItem('mode_' + type, graphModes[type]);
470
- refreshGraph(type); // Mostra istantaneamente
471
- lastTapTime = 0;
472
- }
473
- // TAP SINGOLO (con ritardo di 250ms per aspettare l'eventuale doppio tap)
474
- else {
475
- lastTapTime = currentTime;
476
- tapTimeout = setTimeout(() => {
477
- // Uscita Focus
478
- if (isFocusActive && el.classList.contains('is-focused')) {
479
- toggleFocusMode(type, el);
480
- }
481
- // Toggle SOG/VMG (solo se non in focus)
482
- else if (!isFocusActive && type === 'sog') {
483
- displayModeSog = (displayModeSog === 'SOG') ? 'VMG' : 'SOG';
484
- el.style.backgroundColor = "rgba(0, 0, 0, 0.05)";
485
- setTimeout(() => el.style.backgroundColor = "", 150);
486
- }
487
- }, 250); // Attesa critica per il doppio tap
488
- }
651
+ if (tapDelay < 300 && tapDelay > 0) { clearTimeout(tapTimeout); graphModes[type] = (graphModes[type] === 'standard') ? 'hercules' : 'standard'; localStorage.setItem('mode_' + type, graphModes[type]); refreshGraph(type); lastTapTime = 0; }
652
+ 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(0, 0, 0, 0.05)"; setTimeout(() => el.style.backgroundColor = "", 150); } }, 250); }
489
653
  });
490
-
491
654
  el.addEventListener('pointerleave', () => clearTimeout(pressTimer));
492
655
  });
493
656
 
494
657
  if (ui.hotspot) {
495
658
  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); });
496
- 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(); } } });
659
+ ui.hotspot.addEventListener('pointerup', (e) => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; const doc = document.documentElement; if (document.fullscreenElement) document.exitFullscreen(); else doc.requestFullscreen(); } });
497
660
  ui.hotspot.addEventListener('pointerleave', () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } });
498
661
  }
499
662
 
500
- function connect() {
501
- if (simulationMode) return;
502
- let addr = (window.location.protocol.includes("http")) ? window.location.host : CONFIG.server.fallbackIp;
503
- const protocol = (window.location.protocol === 'https:') ? 'wss' : 'ws';
504
- try {
505
- socket = new WebSocket(`${protocol}://${addr}/signalk/v1/stream?subscribe=self`);
506
- socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; reconnectDelay = 1000; };
507
- 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))); };
508
- socket.onclose = () => { if (!simulationMode) { ui.status.className = "offline"; ui.status.innerText = "RECONNECTING..."; setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 1.5, 10000); } };
509
- } catch (e) { setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 1.5, 10000); }
510
- }
511
-
512
- 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; } }; })());
513
-
663
+ // ==========================================================================
664
+ // 10. SIMULAZIONE E RETE
665
+ // ==========================================================================
514
666
  function startDynamicSimulation() {
515
667
  ui.status.innerText = "SIM ATTIVO";
516
- let sim = { hdg: 45, tws: 12, twd: 90, depth: 12, stw: 5, leeway: 0, currentSpeed: 1.5, currentDir: 90, startTime: Date.now() };
668
+ let sim = { hdg: 45, tws: 12, twd: 90, depth: 12, stw: 5, startTime: Date.now() };
517
669
  simInterval = setInterval(() => {
518
670
  const elapsed = (Date.now() - sim.startTime) / 1000;
519
671
  sim.twd = (sim.twd + (Math.sin(elapsed / 20) * 0.5) + 360) % 360;
@@ -521,21 +673,50 @@ function startDynamicSimulation() {
521
673
  sim.tws += (targetTws - sim.tws) * 0.05; sim.hdg = (sim.hdg + (Math.random() - 0.5) * 1 + 360) % 360;
522
674
  let twaRel = (sim.twd - sim.hdg + 360) % 360; if (twaRel > 180) twaRel -= 360;
523
675
  let targetStw = 5; sim.stw += (targetStw - sim.stw) * 0.05;
524
- const bX = sim.stw * Math.sin(degToRad(sim.hdg)), bY = sim.stw * Math.cos(degToRad(sim.hdg));
525
- const sog = sim.stw, cog = sim.hdg;
526
- 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));
527
- const awa = Math.atan2(sim.tws * Math.sin(twaRad), sim.stw + sim.tws * Math.cos(twaRad));
528
- processIncomingData("environment.wind.speedApparent", ktsToMs(aws)); processIncomingData("environment.wind.angleApparent", awa);
529
- processIncomingData("environment.depth.belowTransducer", sim.depth); processIncomingData("navigation.headingTrue", degToRad(sim.hdg));
530
- processIncomingData("navigation.speedThroughWater", ktsToMs(sim.stw)); processIncomingData("navigation.speedOverGround", ktsToMs(sog));
531
- processIncomingData("navigation.courseOverGroundTrue", degToRad(cog));
676
+ processIncomingData("navigation.headingTrue", degToRad(sim.hdg));
677
+ processIncomingData("environment.wind.speedApparent", ktsToMs(sim.stw + 2));
678
+ processIncomingData("environment.wind.angleApparent", degToRad(twaRel + 5));
679
+ processIncomingData("environment.depth.belowTransducer", sim.depth);
680
+ processIncomingData("navigation.speedThroughWater", ktsToMs(sim.stw));
681
+ processIncomingData("navigation.speedOverGround", ktsToMs(sim.stw));
682
+ processIncomingData("navigation.courseOverGroundTrue", degToRad(sim.hdg));
532
683
  }, 1000);
533
684
  }
534
685
 
686
+ function connect() {
687
+ if (simulationMode) return;
688
+ let addr = window.location.host || CONFIG.server.fallbackIp;
689
+ try {
690
+ socket = new WebSocket(`ws://${addr}/signalk/v1/stream?subscribe=self`);
691
+ socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; reconnectDelay = 1000; };
692
+ socket.onmessage = (e) => { const d = JSON.parse(e.data); if (d.updates) d.updates.forEach(u => u.values && u.values.forEach(v => processIncomingData(v.path, v.value))); };
693
+ socket.onclose = () => { if (!simulationMode) { ui.status.className = "offline"; ui.status.innerText = "RECONNECTING..."; setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 1.5, 10000); } };
694
+ } catch (e) { setTimeout(connect, reconnectDelay); }
695
+ }
696
+
535
697
  // ==========================================================================
536
- // 10. INIT
698
+ // 11. INIT E CICLO DI VITA
537
699
  // ==========================================================================
538
700
  window.addEventListener('contextmenu', e => e.preventDefault(), true);
539
- (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 ? "#000" : "#bbb"); l.setAttribute("stroke-width", m ? "2" : "1"); l.setAttribute("transform", `rotate(${i}, 200, 200)`); c.appendChild(l); } } })();
540
- async function init() { await fetchServerConfig(); startDisplayLoop(); connect(); }
701
+ (function genTicks() {
702
+ const c = document.getElementById('ticks');
703
+ if (c) {
704
+ for (let i = 0; i < 360; i += 10) {
705
+ const l = document.createElementNS("http://www.w3.org/2000/svg", "line");
706
+ const m = i % 30 === 0;
707
+ l.setAttribute("x1", "200"); l.setAttribute("y1", "40"); l.setAttribute("x2", "200"); l.setAttribute("y2", (m ? 60 : 50));
708
+ l.setAttribute("stroke", m ? "#000" : "#bbb"); l.setAttribute("stroke-width", m ? "2" : "1");
709
+ l.setAttribute("transform", `rotate(${i}, 200, 200)`); c.appendChild(l);
710
+ }
711
+ }
712
+ })();
713
+
714
+ async function init() {
715
+ loadDashboardState();
716
+ await fetchServerConfig();
717
+ startDisplayLoop();
718
+ connect();
719
+ }
720
+
541
721
  window.addEventListener('load', init);
722
+ window.addEventListener('pagehide', saveDashboardState);