@sailingrotevista/rotevista-dash 3.0.6 → 4.0.3

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 +468 -329
  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,367 @@ 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
+ }
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
+ }
305
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
+ }
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');
531
+ }
377
532
 
378
- // --- GESTIONE INTERFACCIA TACK HDG ---
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
  // ==========================================================================
562
+ /**
563
+ * Recupera la configurazione dal server Signal K e sovrascrive i default.
564
+ * Include una funzione di parsing per garantire che i valori siano numeri.
565
+ */
423
566
  async function fetchServerConfig() {
424
567
  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) {
568
+
569
+ // Proviamo i due percorsi standard di Signal K per i settings dei plugin
570
+ const urls = [
571
+ '/plugins/rotevista-dash/settings',
572
+ '/plugins/@sailingrotevista%2frotevista-dash/settings'
573
+ ];
574
+
575
+ for (let url of urls) {
428
576
  try {
429
577
  const response = await fetch(url);
430
578
  if (response.ok) {
431
579
  const data = await response.json();
580
+ // Signal K mette i dati reali dentro l'oggetto configuration o direttamente nel body
432
581
  const actual = data.configuration || data;
582
+
433
583
  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);
584
+ // FUNZIONE DI PULIZIA: Trasforma le stringhe "123" in numeri 123 reali
585
+ const parseObj = (obj) => {
586
+ for (let k in obj) {
587
+ if (typeof obj[k] === 'object') parseObj(obj[k]);
588
+ else if (!isNaN(obj[k]) && obj[k] !== "") obj[k] = parseFloat(obj[k]);
589
+ }
590
+ };
591
+ parseObj(actual);
592
+
593
+ // MAPPATURA INTELLIGENTE: Sovrascrive CONFIG solo con i dati ricevuti
436
594
  if (actual.alarms) CONFIG.alarms = { ...CONFIG.alarms, ...actual.alarms };
437
595
  if (actual.graphs) CONFIG.graphs = { ...CONFIG.graphs, ...actual.graphs };
438
596
  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] }; } }
597
+ if (actual.scales) {
598
+ for (let k in actual.scales) {
599
+ CONFIG.scales[k] = { ...CONFIG.scales[k], ...actual.scales[k] };
600
+ }
601
+ }
602
+ console.log("Configurazione caricata correttamente da:", url);
603
+ return; // Esci dal loop se il caricamento ha avuto successo
440
604
  }
441
605
  }
442
- } catch (e) { }
606
+ } catch (e) { console.warn(`Tentativo su ${url} fallito.`); }
443
607
  }
444
608
  }
445
609
 
446
610
  function manageHistory(t, v) {
447
- const n = Date.now();
448
- const interval = simulationMode ? SIM_SAMPLE_INTERVAL : (CONFIG.graphs.historyMinutes * 60000) / CONFIG.graphs.samples;
611
+ const n = Date.now(), interval = (CONFIG.graphs.historyMinutes * 60000) / CONFIG.graphs.samples;
449
612
  if (!store.graphTempBuf[t]) store.graphTempBuf[t] = [];
450
613
  store.graphTempBuf[t].push(v);
451
-
452
614
  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;
615
+ const avg = store.graphTempBuf[t].reduce((a, b) => a + b, 0) / store.graphTempBuf[t].length;
455
616
  store.histories[t].push(avg);
456
617
  if (store.histories[t].length > CONFIG.graphs.samples) store.histories[t].shift();
457
- store.graphTempBuf[t] = [];
458
- store.lastUpdates[t] = n;
618
+ store.graphTempBuf[t] = []; store.lastUpdates[t] = n;
459
619
  }
460
620
  }
461
621
 
462
622
  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) };
623
+ const s = CONFIG.scales[type]; let aMin = Math.min(...data), aMax = Math.max(...data);
624
+ if (mode === 'hercules') {
625
+ let avg = (aMin + aMax) / 2; let span = Math.max(s.hercSpan, Math.ceil(aMax - aMin));
626
+ if (span % 2 !== 0) span += 1; let min = Math.max(0, Math.floor(avg - (span / 2)));
627
+ return { min, max: min + span };
628
+ }
629
+ return { min: 0, max: Math.max(s.stdMax, Math.ceil(aMax / s.step) * s.step) };
466
630
  }
467
631
 
468
632
  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 +634,15 @@ function updateScaleLabels(t, min, max) { const el = document.getElementById(t +
470
634
  function drawGraph(d, id, min, max, isTws, isHercules) {
471
635
  const svg = document.getElementById(id); if (!svg || d.length < 2) return;
472
636
  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++) {
637
+ 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" />`; });
638
+ /**
639
+ * Griglia Temporale Intelligente:
640
+ * - Storia <= 15 min: linee ogni 1 minuto.
641
+ * - Storia > 15 min: linee ogni 5 minuti.
642
+ */
643
+ const gridInterval = (CONFIG.graphs.historyMinutes <= 15) ? 1 : 5;
644
+
645
+ for (let m = gridInterval; m < CONFIG.graphs.historyMinutes; m += gridInterval) {
476
646
  const x = w - (m / CONFIG.graphs.historyMinutes) * w;
477
647
  grids += `<line x1="${x}" y1="0" x2="${x}" y2="${h}" stroke="rgba(0,0,0,0.08)" stroke-width="0.5" />`;
478
648
  }
@@ -482,7 +652,7 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
482
652
  if (isTws && i > 0) {
483
653
  const px = ((i-1)/(CONFIG.graphs.samples-1))*w, py = h-(Math.max(0,Math.min(1,(d[i-1]-min)/range))*h);
484
654
  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':''}" />`;
655
+ cS += `<line x1="${px}" y1="${py}" x2="${x}" y2="${y}" stroke="${c}" class="tws-reef-line ${isHercules?'line-hercules':''}" />`;
486
656
  }
487
657
  });
488
658
  const clrs = { 'stw-graph': '#2ecc71', 'sog-graph': '#f39c12', 'depth-graph': '#3498db', 'tws-graph': '#000' };
@@ -491,101 +661,41 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
491
661
  }
492
662
 
493
663
  // ==========================================================================
494
- // 8. INTERAZIONI E RETE
664
+ // 9. INTERAZIONI E GESTI
495
665
  // ==========================================================================
496
666
  function toggleFocusMode(type, element) {
497
667
  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
-
668
+ const isLeft = ['stw', 'sog', 'hdg', 'cog', 'tack'].includes(type);
504
669
  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
- }
670
+ if (isFocusActive) { container.classList.add('focus-active', isLeft ? 'focus-side-left' : 'focus-side-right'); element.classList.add('is-focused'); }
671
+ else { container.classList.remove('focus-active', 'focus-side-left', 'focus-side-right'); document.querySelectorAll('.data-box').forEach(b => b.classList.remove('is-focused')); }
513
672
  }
514
673
 
515
674
  ['stw', 'sog', 'tws', 'depth'].forEach(type => {
516
- // Risale dal grafico al contenitore principale (funziona con la nuova struttura)
517
675
  const el = document.getElementById(type + '-graph').closest('.data-box');
518
676
  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
-
677
+ el.addEventListener('pointerdown', (e) => { isLongPressActive = false; pressTimer = setTimeout(() => { if (!isFocusActive) { isLongPressActive = true; toggleFocusMode(type, el); lastTapTime = 0; } }, 1000); });
531
678
  el.addEventListener('pointerup', (e) => {
532
- clearTimeout(pressTimer);
533
- if (isLongPressActive) return; // Se era focus, ignora l'up
534
-
679
+ clearTimeout(pressTimer); if (isLongPressActive) return;
535
680
  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
- }
681
+ if (tapDelay < 300 && tapDelay > 0) { clearTimeout(tapTimeout); graphModes[type] = (graphModes[type] === 'standard') ? 'hercules' : 'standard'; localStorage.setItem('mode_' + type, graphModes[type]); refreshGraph(type); lastTapTime = 0; }
682
+ 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
683
  });
562
-
563
684
  el.addEventListener('pointerleave', () => clearTimeout(pressTimer));
564
685
  });
565
686
 
566
687
  if (ui.hotspot) {
567
688
  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(); } } });
689
+ ui.hotspot.addEventListener('pointerup', (e) => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; const doc = document.documentElement; if (document.fullscreenElement) document.exitFullscreen(); else doc.requestFullscreen(); } });
569
690
  ui.hotspot.addEventListener('pointerleave', () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } });
570
691
  }
571
692
 
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
-
693
+ // ==========================================================================
694
+ // 10. SIMULAZIONE E RETE
695
+ // ==========================================================================
586
696
  function startDynamicSimulation() {
587
697
  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() };
698
+ let sim = { hdg: 45, tws: 12, twd: 90, depth: 12, stw: 5, startTime: Date.now() };
589
699
  simInterval = setInterval(() => {
590
700
  const elapsed = (Date.now() - sim.startTime) / 1000;
591
701
  sim.twd = (sim.twd + (Math.sin(elapsed / 20) * 0.5) + 360) % 360;
@@ -593,21 +703,50 @@ function startDynamicSimulation() {
593
703
  sim.tws += (targetTws - sim.tws) * 0.05; sim.hdg = (sim.hdg + (Math.random() - 0.5) * 1 + 360) % 360;
594
704
  let twaRel = (sim.twd - sim.hdg + 360) % 360; if (twaRel > 180) twaRel -= 360;
595
705
  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));
706
+ processIncomingData("navigation.headingTrue", degToRad(sim.hdg));
707
+ processIncomingData("environment.wind.speedApparent", ktsToMs(sim.stw + 2));
708
+ processIncomingData("environment.wind.angleApparent", degToRad(twaRel + 5));
709
+ processIncomingData("environment.depth.belowTransducer", sim.depth);
710
+ processIncomingData("navigation.speedThroughWater", ktsToMs(sim.stw));
711
+ processIncomingData("navigation.speedOverGround", ktsToMs(sim.stw));
712
+ processIncomingData("navigation.courseOverGroundTrue", degToRad(sim.hdg));
604
713
  }, 1000);
605
714
  }
606
715
 
716
+ function connect() {
717
+ if (simulationMode) return;
718
+ let addr = window.location.host || CONFIG.server.fallbackIp;
719
+ try {
720
+ socket = new WebSocket(`ws://${addr}/signalk/v1/stream?subscribe=self`);
721
+ socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; reconnectDelay = 1000; };
722
+ 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))); };
723
+ socket.onclose = () => { if (!simulationMode) { ui.status.className = "offline"; ui.status.innerText = "RECONNECTING..."; setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 1.5, 10000); } };
724
+ } catch (e) { setTimeout(connect, reconnectDelay); }
725
+ }
726
+
607
727
  // ==========================================================================
608
- // 10. INIT
728
+ // 11. INIT E CICLO DI VITA
609
729
  // ==========================================================================
610
730
  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(); }
731
+ (function genTicks() {
732
+ const c = document.getElementById('ticks');
733
+ if (c) {
734
+ for (let i = 0; i < 360; i += 10) {
735
+ const l = document.createElementNS("http://www.w3.org/2000/svg", "line");
736
+ const m = i % 30 === 0;
737
+ l.setAttribute("x1", "200"); l.setAttribute("y1", "40"); l.setAttribute("x2", "200"); l.setAttribute("y2", (m ? 60 : 50));
738
+ l.setAttribute("stroke", m ? "#000" : "#bbb"); l.setAttribute("stroke-width", m ? "2" : "1");
739
+ l.setAttribute("transform", `rotate(${i}, 200, 200)`); c.appendChild(l);
740
+ }
741
+ }
742
+ })();
743
+
744
+ async function init() {
745
+ loadDashboardState();
746
+ await fetchServerConfig();
747
+ startDisplayLoop();
748
+ connect();
749
+ }
750
+
613
751
  window.addEventListener('load', init);
752
+ window.addEventListener('pagehide', saveDashboardState);