@sailingrotevista/rotevista-dash 3.0.6 → 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 +449 -340
  2. package/index.js +71 -59
  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.93,
12
- minSpeed: 2
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,35 +103,54 @@ 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
+ });
97
144
 
98
145
  let R = Math.sqrt(sSin * sSin + sCos * sCos) / validData.length;
99
- 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);
100
150
  let avgRad = Math.atan2(sSin, sCos);
101
151
 
102
- // Calcolo deviazione standard (scarto) in gradi
103
- let deviation = 0;
104
- if (R < 1 && R > 0) {
105
- deviation = Math.round(Math.sqrt(-2 * Math.log(R)) * (180 / Math.PI));
106
- }
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;
107
154
 
108
155
  return {
109
156
  val: signed ? avgRad : (avgRad + 2 * Math.PI) % (2 * Math.PI),
@@ -112,18 +159,98 @@ function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false) {
112
159
  };
113
160
  }
114
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'); }
224
+ }
225
+
226
+ // ==========================================================================
227
+ // 4. AUDIO E ALLARMI
228
+ // ==========================================================================
115
229
  function playBingBing() {
116
230
  if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
117
231
  const n = Date.now(); if (n - lastAlarmTime < 3000) return; lastAlarmTime = n;
118
- 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
+ }
119
238
  b(880, audioCtx.currentTime); b(880, audioCtx.currentTime + 0.6);
120
239
  }
121
240
 
122
241
  function playGybeAlarm() {
123
242
  if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
124
243
  const n = Date.now(); if (n - lastAlarmTime < 2000) return; lastAlarmTime = n;
125
- 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); }
126
- 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
+ }
127
254
  }
128
255
 
129
256
  function checkDepthAlarm(m) {
@@ -139,330 +266,337 @@ function updateLeewayDisplay(deg) {
139
266
  }
140
267
 
141
268
  // ==========================================================================
142
- // 4. MOTORE DI CALCOLO VENTO E DATA ROUTING
269
+ // 5. MOTORE DI CALCOLO VENTO E DATA ROUTING
143
270
  // ==========================================================================
144
271
  function computeTrueWind() {
145
- const aws = store.raw["environment.wind.speedApparent"];
146
- let awa = store.raw["environment.wind.angleApparent"];
272
+ const aws = store.raw["environment.wind.speedApparent"], awa = store.raw["environment.wind.angleApparent"];
147
273
  const stw = store.raw["navigation.speedThroughWater"] || 0, sog = store.raw["navigation.speedOverGround"] || 0;
148
274
  const hdg = store.raw["navigation.headingTrue"] || 0, cog = store.raw["navigation.courseOverGroundTrue"] || 0;
149
-
150
275
  if (aws === undefined || awa === undefined) return;
151
- if (awa > Math.PI) awa -= 2 * Math.PI;
152
276
 
277
+ // Vento Reale Rispetto all'acqua (TWA/TWS Water)
153
278
  const tw_water_x = aws * Math.cos(awa) - stw, tw_water_y = aws * Math.sin(awa);
154
279
  const tws_water = Math.sqrt(tw_water_x * tw_water_x + tw_water_y * tw_water_y);
155
280
 
156
- const drift_angle = (cog - hdg + Math.PI * 3) % (2 * Math.PI) - Math.PI;
157
- const sog_vec_x = sog * Math.cos(drift_angle), sog_vec_y = sog * Math.sin(drift_angle);
158
- 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);
159
284
  const tws_ground = Math.sqrt(tw_ground_x * tw_ground_x + tw_ground_y * tw_ground_y);
160
285
 
161
286
  const now = Date.now();
162
287
  store.raw["environment.wind.speedTrue"] = tws_water;
163
-
164
288
  if (tws_water > 0.05) {
165
- const twa_water = Math.atan2(tw_water_y, tw_water_x);
166
- store.raw["environment.wind.angleTrueWater"] = twa_water;
167
- 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);
168
292
  }
169
293
  if (tws_ground > 0.05) {
170
- let twd_ground = (hdg + Math.atan2(tw_ground_y, tw_ground_x) + 2 * Math.PI) % (2 * Math.PI);
171
- store.raw["environment.wind.directionTrue"] = twd_ground;
172
- 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);
173
297
  }
174
298
  }
175
299
 
176
300
  function processIncomingData(path, val) {
177
301
  const now = Date.now(); store.timestamps[path] = now; store.raw[path] = val;
178
- if (path === "navigation.position") store.raw["navigation.position"] = val;
179
302
  if (path === "environment.wind.angleApparent") { safePush(store.smoothBuf.awa, val, now); safePush(store.longBuf.awa, val, now); }
180
-
181
303
  const twPaths = ["environment.wind.speedApparent", "environment.wind.angleApparent", "navigation.speedThroughWater", "navigation.speedOverGround", "navigation.headingTrue", "navigation.courseOverGroundTrue"];
182
304
  if (twPaths.includes(path)) twDirty = true;
183
305
  if (twDirty && (now - lastTWCompute > 100)) { computeTrueWind(); lastTWCompute = now; twDirty = false; }
184
-
185
306
  if (path === "navigation.headingTrue") { safePush(store.smoothBuf.hdg, val, now); safePush(store.longBuf.hdg, val, now); }
186
307
  if (path === "navigation.courseOverGroundTrue") { safePush(store.smoothBuf.cog, val, now); safePush(store.longBuf.cog, val, now); }
187
308
  }
188
309
 
189
310
  // ==========================================================================
190
- // 5. TREND VENTO E SICUREZZA
311
+ // 6. TREND VENTO (Tattico 2s vs 10s | Strategico 1m vs 10m)
191
312
  // ==========================================================================
192
- /**
193
- * Analizza i trend di rotazione del vento su due scale temporali:
194
- * 1. Tattica (veloce): per la regolazione delle vele (sulla lancetta TWA)
195
- * 2. Strategica (lenta): per le previsioni meteo a lungo termine (bussola TWD)
196
- */
197
313
  function updateWindTrend() {
198
314
  const now = Date.now();
199
- const twaAvgObj = getCircularAverageFromBuffer(store.longBuf.twa, 30000, true);
200
- const shortAvg = getCircularAverageFromBuffer(store.longBuf.twd, 5000, false);
201
- const instantTwaRad = store.raw["environment.wind.angleTrueWater"];
202
-
203
- if (!shortAvg || !twaAvgObj || instantTwaRad === undefined) return;
204
- const instantTwaDeg = radToDeg(instantTwaRad), shortAvgDeg = radToDeg(shortAvg.val), twaAvgDeg = radToDeg(twaAvgObj.val);
205
-
206
- if (lastShortAvgVal === null) { lastShortAvgVal = shortAvgDeg; lastInstantTwa = instantTwaDeg; return; }
207
- const dt = (now - lastTrendTime) / 1000; lastTrendTime = now;
208
-
209
- // ALLARME STRAMBATA
210
- const gybeDetected = (Math.abs(instantTwaDeg) > 155 && Math.sign(instantTwaDeg) !== Math.sign(lastInstantTwa));
211
- lastInstantTwa = instantTwaDeg;
212
-
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;
213
324
  const compassDots = { cw: document.getElementById('trend-dot-cw'), ccw: document.getElementById('trend-dot-ccw') };
214
325
  const gaugeDots = { cw: document.getElementById('trend-gauge-cw'), ccw: document.getElementById('trend-gauge-ccw') };
215
326
 
216
- if (gybeDetected && isNavigating && (now - lastGybeAlarmTime > 5000)) { lastGybeAlarmTime = now; playGybeAlarm(); }
217
- if (now - lastGybeAlarmTime < 4000 && isNavigating) {
218
- [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'); }});
219
- return;
220
- }
221
-
222
- // CALCOLO TREND
223
- let diff = (shortAvgDeg - lastShortAvgVal + 540) % 360 - 180; lastShortAvgVal = shortAvgDeg;
224
- if (dt > 0) {
225
- let rate = Math.max(-10, Math.min(10, diff / dt));
226
- // Tattico (veloce ~15s)
227
- const alphaT = Math.min(1, dt / 15);
228
- rotationTrend = rotationTrend * (1 - alphaT) + rate * alphaT;
229
- // Strategico (molto lento ~8-10min)
230
- const alphaM = Math.min(1, dt / 500);
231
- meteoTrend = meteoTrend * (1 - alphaM) + rate * alphaM;
232
- }
233
-
234
- // VISUALIZZAZIONE METEO (Bussola Centrale)
235
- 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) {
236
330
  const isSouth = store.raw["navigation.position"]?.latitude < 0;
237
- let meteoColor = (!isSouth) ? (meteoTrend < 0 ? "#27ae60" : "#c0392b") : (meteoTrend > 0 ? "#27ae60" : "#c0392b");
238
- if (meteoTrend > 0) {
239
- if (compassDots.cw) { compassDots.cw.classList.add('is-trending'); compassDots.cw.setAttribute('fill', meteoColor); }
240
- 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');
241
335
  } else {
242
- if (compassDots.ccw) { compassDots.ccw.classList.add('is-trending'); compassDots.ccw.setAttribute('fill', meteoColor); }
243
- 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');
244
338
  }
245
- } else {
246
- [compassDots.cw, compassDots.ccw].forEach(el => { if (el) { el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }});
247
- }
248
-
249
- // VISUALIZZAZIONE TATTICA (Lancetta Vento)
250
- if (Math.abs(rotationTrend) > 3.0) {
251
- let isLift = (twaAvgDeg > 0) ? (rotationTrend > 0) : (rotationTrend < 0);
252
- if (Math.abs(twaAvgDeg) >= 90) isLift = !isLift;
253
- const tacticColor = isLift ? "#27ae60" : "#c0392b";
254
- if (rotationTrend > 0) {
255
- if (gaugeDots.cw) { gaugeDots.cw.classList.add('is-trending'); gaugeDots.cw.setAttribute('fill', tacticColor); }
256
- 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
257
352
  } else {
258
- if (gaugeDots.ccw) { gaugeDots.ccw.classList.add('is-trending'); gaugeDots.ccw.setAttribute('fill', tacticColor); }
259
- 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";
260
356
  }
261
- } else {
262
- [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(); }
263
370
  }
371
+ lastInstantTwa = instTwa;
264
372
  }
265
373
 
266
374
  // ==========================================================================
267
- // 6. RENDERING ENGINE (TIERED)
375
+ // 7. RENDERING ENGINE E AGGIORNAMENTO UI
268
376
  // ==========================================================================
269
377
  function refreshGraph(t) {
270
378
  const type = (t === 'vmg') ? 'sog' : t;
271
379
  const data = store.histories[t]; if (!data || data.length < 2) return;
272
380
  const mode = graphModes[type], cfg = calculateScale(type, data, mode);
273
- const graphEl = document.getElementById(type + '-graph');
274
- if (graphEl) { const box = graphEl.closest('.data-box'); if (box) box.classList.toggle('box-hercules', mode === 'hercules'); }
275
- 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');
276
388
  }
277
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
+ */
278
416
  function startDisplayLoop() {
279
- let tick = 0;
280
417
  renderInterval = setInterval(() => {
281
- const now = Date.now(); tick++;
282
-
283
- 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
284
423
  isNavigating = stwKts > CONFIG.averages.minSpeed || sogKts > CONFIG.averages.minSpeed;
285
424
 
286
- // LIVE TIER (1s)
287
- 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 };
288
- 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
+ }
289
432
 
290
- 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 ---
291
439
  if (store.raw["navigation.speedOverGround"] !== undefined) {
292
- const twaRad = store.raw["environment.wind.angleTrueWater"], vmgKts = (twaRad !== undefined) ? Math.abs(stwKts * Math.cos(twaRad)) : 0;
293
- 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');
294
444
  if (displayModeSog === 'VMG') {
295
- 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';
296
448
  } else {
297
449
  ui.sog.innerText = sogKts.toFixed(1);
298
- ui.sog.style.color = (sogKts - stwKts > 0.3) ? "#27ae60" : (sogKts - stwKts < -0.3 ? "#c0392b" : "#000");
299
- 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 = "";
300
456
  }
301
457
  }
302
- 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); }
303
- 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"])); }
304
- 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
+ }
305
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
+ }
480
+
481
+ // --- PUNTATORI ANALOGICI (Smoothing 2s) ---
306
482
  const smAwa = getCircularAverageFromBuffer(store.smoothBuf.awa, 2000, true);
307
- if (smAwa) { curAwaRot = getShortestRotation(curAwaRot, radToDeg(smAwa.val)); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
308
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)`); }
309
485
  if (smTwa) { curTwaRot = getShortestRotation(curTwaRot, radToDeg(smTwa.val)); ui.twa.setAttribute('transform', `rotate(${curTwaRot}, 200, 200)`); }
310
-
486
+
487
+ // --- CALCOLO LEEWAY E TRACK POINTER ---
311
488
  if (store.raw["navigation.courseOverGroundTrue"] !== undefined && store.raw["navigation.headingTrue"] !== undefined) {
312
489
  let driftDeg = radToDeg((store.raw["navigation.courseOverGroundTrue"] - store.raw["navigation.headingTrue"] + Math.PI * 3) % (Math.PI * 2) - Math.PI);
313
- 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);
314
492
  curTrackRot = getShortestRotation(curTrackRot, smoothedLeeway); ui.track.setAttribute('transform', `rotate(${curTrackRot}, 200, 200)`);
315
- 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" : "";
316
494
  updateLeewayDisplay(Math.max(-20, Math.min(20, smoothedLeeway)));
317
495
  }
318
-
496
+
319
497
  updateWindTrend();
320
- if (tick % 2 === 0) { refreshGraph('stw'); refreshGraph(displayModeSog === 'VMG' ? 'vmg' : 'sog'); refreshGraph('depth'); refreshGraph('tws'); }
321
-
322
- // SLOW TIER (3s)
323
- if (tick % 3 === 0) {
324
- // Utilizziamo CONFIG.averages.longWindow (che ora è 30000ms)
325
- // In questo modo, se cambi il tempo su Signal K, la dashboard si aggiorna da sola.
326
- let hObj = getCircularAverageFromBuffer(store.longBuf.hdg, CONFIG.averages.longWindow * 2, false)
327
- cObj = getCircularAverageFromBuffer(store.longBuf.cog, CONFIG.averages.longWindow, false),
328
- awObj = getCircularAverageFromBuffer(store.longBuf.awa, CONFIG.averages.longWindow, true),
329
- twObj = getCircularAverageFromBuffer(store.longBuf.twa, CONFIG.averages.longWindow, true),
330
- twdObj = getCircularAverageFromBuffer(store.longBuf.twd, CONFIG.averages.longWindow, false);
331
-
332
- const upUI = (el, obj, instantRaw, isCompass = false) => {
333
- if (!obj || obj.val === null || instantRaw === undefined) {
334
- el.innerHTML = "---&deg;";
335
- el.classList.remove('unstable-data');
336
- } else {
337
- let valDeg = Math.round(radToDeg(obj.val));
338
- let mainVal = (isCompass ? ((valDeg + 360) % 360).toString().padStart(3, '0') : valDeg) + "&deg;";
339
-
340
- // Mostriamo lo scarto medio (±)
341
- let devDisplay = (obj.dev > 1 && obj.dev < 90) ?
342
- `<span style="font-size: 0.35em; opacity: 0.5; margin-left: 4px; vertical-align: middle;">&plusmn;${obj.dev}</span>` : "";
343
-
344
- el.innerHTML = mainVal + devDisplay;
345
-
346
- // --- LOGICA ALLARME ISTANTANEA (ANTI-RITARDO) ---
347
- // Calcoliamo la differenza tra istantaneo e media (con gestione giro bussola)
348
- let instantDeg = radToDeg(instantRaw);
349
- let diff = Math.abs((instantDeg - radToDeg(obj.val) + 540) % 360 - 180);
350
-
351
- // Lampeggia se:
352
- // 1. La statistica R è bassa (obj.stable è false)
353
- // 2. Lo scarto medio è alto (> 15°)
354
- // 3. C'è un salto improvviso tra istantaneo e media (> 15°)
355
- if (isNavigating && (!obj.stable || obj.dev > 15 || diff > 15)) {
356
- el.classList.add('unstable-data');
357
- } else {
358
- el.classList.remove('unstable-data');
359
- }
360
- }
361
- };
362
- // Passiamo: (elemento UI, oggetto media, valore istantaneo dal sensore, è una bussola?)
363
- upUI(ui.hdg, hObj, store.raw["navigation.headingTrue"], true);
364
- upUI(ui.cog, cObj, store.raw["navigation.courseOverGroundTrue"], true);
365
- upUI(ui.awaAvg, awObj, store.raw["environment.wind.angleApparent"], false);
366
- upUI(ui.twaAvg, twObj, store.raw["environment.wind.angleTrueWater"], false);
367
- upUI(ui.twdAvg, twdObj, store.raw["environment.wind.directionTrue"], true);
368
-
369
- // --- CALCOLO E VALIDAZIONE TACK ---
370
- if (hObj && twObj) {
371
- // Calcoliamo i valori teorici
372
- const tackHdgDeg = radToDeg((hObj.val + twObj.val * 2 + Math.PI * 2) % (Math.PI * 2));
373
- const tackCogDeg = cObj ? radToDeg((cObj.val + twObj.val * 2 + Math.PI * 2) % (Math.PI * 2)) : null;
374
498
 
375
- // Condizione di instabilità tecnica (durante la manovra)
376
- const isTackUnstable = !hObj.stable || !twObj.stable || hObj.dev > 15 || twObj.dev > 15;
499
+ // TIER HEAVY (2s) - Grafici e Persistenza Browser
500
+ if (lastAvgUIUpdate++ % 2 === 0) {
501
+ ['stw', 'sog', 'depth', 'tws'].forEach(refreshGraph);
502
+ saveDashboardState();
503
+ }
377
504
 
378
- // --- GESTIONE INTERFACCIA TACK HDG ---
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');
531
+ }
532
+
533
+ if (cObj) {
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
+
379
537
  if (!isNavigating) {
380
- // Caso 1: Barca ferma -> Trattini fissi
381
- ui.tackHdg.innerHTML = "---&deg;";
382
- ui.tackHdg.classList.remove('unstable-data');
383
- } else if (isTackUnstable) {
384
- // Caso 2: Manovra in corso -> Trattini lampeggianti
385
- ui.tackHdg.innerHTML = "---&deg;";
386
- ui.tackHdg.classList.add('unstable-data');
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');
387
541
  } else {
388
- // Caso 3: Navigazione stabile -> Mostra valore
389
- ui.tackHdg.innerHTML = `${Math.round((tackHdgDeg + 360) % 360).toString().padStart(3, '0')}&deg;`;
390
- ui.tackHdg.classList.remove('unstable-data');
391
- }
392
-
393
- // --- GESTIONE INTERFACCIA TACK COG ---
394
- if (cObj) {
395
- const isCogTackUnstable = !cObj.stable || !twObj.stable || cObj.dev > 15 || twObj.dev > 15;
396
-
397
- if (!isNavigating) {
398
- ui.tackCog.innerHTML = "---&deg;";
399
- ui.tackCog.classList.remove('unstable-data');
400
- } else if (isCogTackUnstable) {
401
- ui.tackCog.innerHTML = "---&deg;";
402
- ui.tackCog.classList.add('unstable-data');
403
- } else {
404
- ui.tackCog.innerHTML = `${Math.round((tackCogDeg + 360) % 360).toString().padStart(3, '0')}&deg;`;
405
- ui.tackCog.classList.remove('unstable-data');
406
- }
542
+ ui.tackCog.innerHTML = `${Math.round((tC + 360) % 360).toString().padStart(3, '0')}&deg;`;
543
+ ui.tackCog.classList.remove('unstable-data');
407
544
  }
408
545
  }
409
- const smHdg = getCircularAverageFromBuffer(store.smoothBuf.hdg, 2000, false), smTwd = getCircularAverageFromBuffer(store.smoothBuf.twd, 2000, false);
410
- if (smHdg && smTwd) {
411
- curWindCompassRot = getShortestRotation(curWindCompassRot, radToDeg(smTwd.val)); ui.twdArrow.setAttribute('transform', `rotate(${curWindCompassRot}, 20, 20)`);
412
- curBoatCompassRot = getShortestRotation(curBoatCompassRot, radToDeg(smHdg.val)); ui.twdBoat.setAttribute('transform', `rotate(${curBoatCompassRot}, 20, 20)`);
413
546
  }
414
- lastAvgUIUpdate = now;
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)`);
554
+ }
415
555
  }
416
- if (tick % 60 === 0) tick = 0;
417
556
  }, RENDER_INTERVAL_MS);
418
557
  }
419
558
 
420
559
  // ==========================================================================
421
- // 7. CONFIGURAZIONE E GRAFICI UTILS
560
+ // 8. CONFIGURAZIONE E GRAFICI UTILS
422
561
  // ==========================================================================
423
562
  async function fetchServerConfig() {
424
563
  if (!window.location.protocol.includes("http")) return;
425
- const pluginID = 'rotevista-dash';
426
- const possibleUrls = [`/skServer/plugins/${pluginID}/config`, `/plugins/${pluginID}/config` ];
427
- for (let url of possibleUrls) {
428
- try {
429
- const response = await fetch(url);
430
- if (response.ok) {
431
- const data = await response.json();
432
- const actual = data.configuration || data;
433
- if (actual) {
434
- 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]); } };
435
- parseNumbers(actual);
436
- if (actual.alarms) CONFIG.alarms = { ...CONFIG.alarms, ...actual.alarms };
437
- if (actual.graphs) CONFIG.graphs = { ...CONFIG.graphs, ...actual.graphs };
438
- if (actual.averaging) CONFIG.averages = { ...CONFIG.averages, ...actual.averaging };
439
- if (actual.scales) { for (let key in actual.scales) { CONFIG.scales[key] = { ...CONFIG.scales[key], ...actual.scales[key] }; } }
440
- }
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] }; }
441
575
  }
442
- } catch (e) { }
443
- }
576
+ }
577
+ } catch (e) { }
444
578
  }
445
579
 
446
580
  function manageHistory(t, v) {
447
- const n = Date.now();
448
- 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;
449
582
  if (!store.graphTempBuf[t]) store.graphTempBuf[t] = [];
450
583
  store.graphTempBuf[t].push(v);
451
-
452
584
  if (n - store.lastUpdates[t] > interval || store.histories[t].length === 0) {
453
- const sum = store.graphTempBuf[t].reduce((a, b) => a + b, 0);
454
- const avg = sum / store.graphTempBuf[t].length;
585
+ const avg = store.graphTempBuf[t].reduce((a, b) => a + b, 0) / store.graphTempBuf[t].length;
455
586
  store.histories[t].push(avg);
456
587
  if (store.histories[t].length > CONFIG.graphs.samples) store.histories[t].shift();
457
- store.graphTempBuf[t] = [];
458
- store.lastUpdates[t] = n;
588
+ store.graphTempBuf[t] = []; store.lastUpdates[t] = n;
459
589
  }
460
590
  }
461
591
 
462
592
  function calculateScale(type, data, mode) {
463
- const s = CONFIG.scales[type] || { stdMax: 12, hercSpan: 4, step: 2 }; let aMin = Math.min(...data), aMax = Math.max(...data);
464
- 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 }; }
465
- 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) };
466
600
  }
467
601
 
468
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>`; }
@@ -470,9 +604,15 @@ function updateScaleLabels(t, min, max) { const el = document.getElementById(t +
470
604
  function drawGraph(d, id, min, max, isTws, isHercules) {
471
605
  const svg = document.getElementById(id); if (!svg || d.length < 2) return;
472
606
  const w = 200, h = 40, range = max - min || 1;
473
- let grids = "";
474
- [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" />`; });
475
- 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) {
476
616
  const x = w - (m / CONFIG.graphs.historyMinutes) * w;
477
617
  grids += `<line x1="${x}" y1="0" x2="${x}" y2="${h}" stroke="rgba(0,0,0,0.08)" stroke-width="0.5" />`;
478
618
  }
@@ -482,7 +622,7 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
482
622
  if (isTws && i > 0) {
483
623
  const px = ((i-1)/(CONFIG.graphs.samples-1))*w, py = h-(Math.max(0,Math.min(1,(d[i-1]-min)/range))*h);
484
624
  let c = (v >= CONFIG.graphs.reef2) ? "#e74c3c" : (v >= CONFIG.graphs.reef1 ? "#e67e22" : "#000");
485
- 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':''}" />`;
486
626
  }
487
627
  });
488
628
  const clrs = { 'stw-graph': '#2ecc71', 'sog-graph': '#f39c12', 'depth-graph': '#3498db', 'tws-graph': '#000' };
@@ -491,101 +631,41 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
491
631
  }
492
632
 
493
633
  // ==========================================================================
494
- // 8. INTERAZIONI E RETE
634
+ // 9. INTERAZIONI E GESTI
495
635
  // ==========================================================================
496
636
  function toggleFocusMode(type, element) {
497
637
  const container = document.querySelector('.main-container');
498
-
499
- // Con la nuova struttura Flat HTML, non abbiamo più '.left-panel'.
500
- // Dobbiamo determinare il lato guardando il tipo di box.
501
- const leftBoxes = ['stw', 'sog', 'hdg', 'cog', 'tack'];
502
- const isLeft = leftBoxes.includes(type);
503
-
638
+ const isLeft = ['stw', 'sog', 'hdg', 'cog', 'tack'].includes(type);
504
639
  isFocusActive = !isFocusActive;
505
-
506
- if (isFocusActive) {
507
- container.classList.add('focus-active', isLeft ? 'focus-side-left' : 'focus-side-right');
508
- element.classList.add('is-focused');
509
- } else {
510
- container.classList.remove('focus-active', 'focus-side-left', 'focus-side-right');
511
- document.querySelectorAll('.data-box').forEach(b => b.classList.remove('is-focused'));
512
- }
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')); }
513
642
  }
514
643
 
515
644
  ['stw', 'sog', 'tws', 'depth'].forEach(type => {
516
- // Risale dal grafico al contenitore principale (funziona con la nuova struttura)
517
645
  const el = document.getElementById(type + '-graph').closest('.data-box');
518
646
  let lastTapTime = 0, tapTimeout, isLongPressActive = false;
519
-
520
- el.addEventListener('pointerdown', (e) => {
521
- isLongPressActive = false;
522
- pressTimer = setTimeout(() => {
523
- if (!isFocusActive) {
524
- isLongPressActive = true;
525
- toggleFocusMode(type, el);
526
- lastTapTime = 0;
527
- }
528
- }, 1000);
529
- });
530
-
647
+ el.addEventListener('pointerdown', (e) => { isLongPressActive = false; pressTimer = setTimeout(() => { if (!isFocusActive) { isLongPressActive = true; toggleFocusMode(type, el); lastTapTime = 0; } }, 1000); });
531
648
  el.addEventListener('pointerup', (e) => {
532
- clearTimeout(pressTimer);
533
- if (isLongPressActive) return; // Se era focus, ignora l'up
534
-
649
+ clearTimeout(pressTimer); if (isLongPressActive) return;
535
650
  const currentTime = new Date().getTime(), tapDelay = currentTime - lastTapTime;
536
-
537
- // DOPPIO TAP: Toggle Hercules Mode (Funziona anche in Focus)
538
- if (tapDelay < 300 && tapDelay > 0) {
539
- clearTimeout(tapTimeout); // Cancella l'uscita dal Focus
540
- graphModes[type] = graphModes[type] === 'standard' ? 'hercules' : 'standard';
541
- localStorage.setItem('mode_' + type, graphModes[type]);
542
- refreshGraph(type); // Mostra istantaneamente
543
- lastTapTime = 0;
544
- }
545
- // TAP SINGOLO (con ritardo di 250ms per aspettare l'eventuale doppio tap)
546
- else {
547
- lastTapTime = currentTime;
548
- tapTimeout = setTimeout(() => {
549
- // Uscita Focus
550
- if (isFocusActive && el.classList.contains('is-focused')) {
551
- toggleFocusMode(type, el);
552
- }
553
- // Toggle SOG/VMG (solo se non in focus)
554
- else if (!isFocusActive && type === 'sog') {
555
- displayModeSog = (displayModeSog === 'SOG') ? 'VMG' : 'SOG';
556
- el.style.backgroundColor = "rgba(0, 0, 0, 0.05)";
557
- setTimeout(() => el.style.backgroundColor = "", 150);
558
- }
559
- }, 250); // Attesa critica per il doppio tap
560
- }
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); }
561
653
  });
562
-
563
654
  el.addEventListener('pointerleave', () => clearTimeout(pressTimer));
564
655
  });
565
656
 
566
657
  if (ui.hotspot) {
567
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); });
568
- 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(); } });
569
660
  ui.hotspot.addEventListener('pointerleave', () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } });
570
661
  }
571
662
 
572
- function connect() {
573
- if (simulationMode) return;
574
- let addr = (window.location.protocol.includes("http")) ? window.location.host : CONFIG.server.fallbackIp;
575
- const protocol = (window.location.protocol === 'https:') ? 'wss' : 'ws';
576
- try {
577
- socket = new WebSocket(`${protocol}://${addr}/signalk/v1/stream?subscribe=self`);
578
- socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; reconnectDelay = 1000; };
579
- 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))); };
580
- socket.onclose = () => { if (!simulationMode) { ui.status.className = "offline"; ui.status.innerText = "RECONNECTING..."; setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 1.5, 10000); } };
581
- } catch (e) { setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 1.5, 10000); }
582
- }
583
-
584
- 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; } }; })());
585
-
663
+ // ==========================================================================
664
+ // 10. SIMULAZIONE E RETE
665
+ // ==========================================================================
586
666
  function startDynamicSimulation() {
587
667
  ui.status.innerText = "SIM ATTIVO";
588
- 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() };
589
669
  simInterval = setInterval(() => {
590
670
  const elapsed = (Date.now() - sim.startTime) / 1000;
591
671
  sim.twd = (sim.twd + (Math.sin(elapsed / 20) * 0.5) + 360) % 360;
@@ -593,21 +673,50 @@ function startDynamicSimulation() {
593
673
  sim.tws += (targetTws - sim.tws) * 0.05; sim.hdg = (sim.hdg + (Math.random() - 0.5) * 1 + 360) % 360;
594
674
  let twaRel = (sim.twd - sim.hdg + 360) % 360; if (twaRel > 180) twaRel -= 360;
595
675
  let targetStw = 5; sim.stw += (targetStw - sim.stw) * 0.05;
596
- const bX = sim.stw * Math.sin(degToRad(sim.hdg)), bY = sim.stw * Math.cos(degToRad(sim.hdg));
597
- const sog = sim.stw, cog = sim.hdg;
598
- 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));
599
- const awa = Math.atan2(sim.tws * Math.sin(twaRad), sim.stw + sim.tws * Math.cos(twaRad));
600
- processIncomingData("environment.wind.speedApparent", ktsToMs(aws)); processIncomingData("environment.wind.angleApparent", awa);
601
- processIncomingData("environment.depth.belowTransducer", sim.depth); processIncomingData("navigation.headingTrue", degToRad(sim.hdg));
602
- processIncomingData("navigation.speedThroughWater", ktsToMs(sim.stw)); processIncomingData("navigation.speedOverGround", ktsToMs(sog));
603
- 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));
604
683
  }, 1000);
605
684
  }
606
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
+
607
697
  // ==========================================================================
608
- // 10. INIT
698
+ // 11. INIT E CICLO DI VITA
609
699
  // ==========================================================================
610
700
  window.addEventListener('contextmenu', e => e.preventDefault(), true);
611
- (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); } } })();
612
- 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
+
613
721
  window.addEventListener('load', init);
722
+ window.addEventListener('pagehide', saveDashboardState);