@sailingrotevista/rotevista-dash 7.0.45 → 8.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.

Potentially problematic release.


This version of @sailingrotevista/rotevista-dash might be problematic. Click here for more details.

package/app.js DELETED
@@ -1,1505 +0,0 @@
1
- /**
2
- * ==========================================================================
3
- * Signal K Wind Dashboard - Pro Version 6.0 (Dynamic Envelope Architecture)
4
- * ==========================================================================
5
- * Autore: Sailing Rotevista
6
- * Motore di calcolo tattico per navigazione e crociera.
7
- * Gestisce: Medie Vettoriali, Deviazione Standard, Trend Strategico dinamico,
8
- * Memoria UI persistente, Modalità Hercules, Focus Split Screen e
9
- * Rendering Grafico basato sul Tempo Reale (Timeline e Gap Handling).
10
- * file app.js
11
- */
12
-
13
- // ==========================================================================
14
- // 1. CONFIGURAZIONE E DEFAULT
15
- // ==========================================================================
16
- let CONFIG = {
17
- alarms: { depthDanger: 2.5, depthWarning: 3.5 },
18
- averaging: {
19
- smoothWindow: 2000,
20
- longWindow: 30000,
21
- stabilityTolerance: 2000,
22
- stabilityThreshold: 0.99,
23
- minSpeed: 0.5,
24
- stabilityBreakout: 15
25
- },
26
- graphs: { reef1: 10, reef2: 15, historyMinutes: 5, samples: 60 },
27
- scales: {
28
- stw: { stdMax: 4, hercSpan: 2, step: 1 },
29
- sog: { stdMax: 4, hercSpan: 2, step: 1 },
30
- tws: { stdMax: 15, hercSpan: 2, step: 1 },
31
- depth: { stdMax: 5, hercSpan: 2, step: 1 }
32
- },
33
- server: { fallbackIp: "venus.local:3000" }
34
- };
35
-
36
- const RENDER_INTERVAL_MS = 1000;
37
- const TIMEOUT_MS = 15000;
38
- const SIM_SAMPLE_INTERVAL = 1000;
39
- const DASH_VERSION = "6.0"; // Major Update: Server-Side History RAM Logging (Pro v6.0)
40
-
41
- // ==========================================================================
42
- // 2. STATO GLOBALE E RIFERIMENTI UI
43
- // ==========================================================================
44
- let simulationMode = false;
45
- let displayModeSog = 'SOG';
46
- let displayModeTws = 'TWS';
47
- let activeInstrument = 'gauge'; // Modalità di default all'avvio: 'gauge' (analogico) o 'radar' (storico)
48
- let socket, renderInterval, simInterval;
49
- let lastAvgUIUpdate = 0; // Chirurgico: Rimosse audioCtx e lastAlarmTime poiché sono già dichiarate in utils.js
50
- const lastPathProcessTimes = {}; // Registro dei timestamp per limitazione di frequenza client-side a 1Hz
51
-
52
- let rotationTrend = 0, meteoTrend = 0;
53
- let lastShortAvgVal = null, lastInstantTwa = null;
54
- let lastTrendTime = Date.now(), lastGybeAlarmTime = 0, lastTWCompute = 0;
55
- let twDirty = false, isNavigating = false, reconnectDelay = 1000;
56
- let lastMessageReceivedTime = Date.now(); // Watchdog anti-zombie per monitorare il flusso dati reale
57
-
58
- let pressTimer, isFocusActive = false;
59
-
60
- let emaTwaSin = 0;
61
- let emaTwaCos = 0;
62
- let firstEmaRun = true;
63
- // MACCHINA A STATI ALLARME STRAMBATA (Filtro Isteresi Anti-Brandeggio)
64
- let lastGybeSide = null;
65
-
66
- const graphModes = {
67
- stw: 'standard',
68
- sog: 'standard',
69
- tws: 'standard',
70
- depth: 'standard'
71
- };
72
-
73
- // GESTIONE SMART LOCK DELLE SORGENTI (Zero-Config)
74
- const sourceLocks = {};
75
-
76
- // Database centrale dello store dati
77
- const store = {
78
- raw: {},
79
- timestamps: {},
80
- isNavigating: false, // Stato unificato di navigazione barca in movimento
81
- depthProtectedActive: false, // Memoria per lo stato della protezione profondità
82
- herculesScales: {}, // Memoria per i limiti attivi della modalità Hercules
83
- smoothBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
84
- longBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
85
- histories: { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] },
86
- graphTempBuf: { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [] },
87
- lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0, aws: 0 }
88
- };
89
-
90
- // Riferimenti agli elementi DOM mappati all'avvio
91
- const ui = {
92
- stw: document.getElementById('stw'), sog: document.getElementById('sog'),
93
- hdg: document.getElementById('hdg'), cog: document.getElementById('cog'),
94
- awsSvg: document.getElementById('aws-val-svg'), awa: document.getElementById('awa-pointer'),
95
- twa: document.getElementById('twa-pointer'), track: document.getElementById('track-pointer'),
96
- tws: document.getElementById('tws'), depth: document.getElementById('depth'),
97
- twaAvg: document.getElementById('twa-avg'), awaAvg: document.getElementById('awa-avg'),
98
- twdAvg: document.getElementById('twd-avg'), twdArrow: document.getElementById('twd-arrow'),
99
- twdBoat: document.getElementById('twd-boat-wrap'),
100
- twdChevron: document.getElementById('twd-wind-chevron'),
101
- leewayMask: document.getElementById('leeway-mask-rect'), leewayVal: document.getElementById('leeway-val'),
102
- tackHdg: document.getElementById('tack-hdg'), tackCog: document.getElementById('tack-cog'),
103
- status: document.getElementById('status'), hotspot: document.getElementById('fullscreen-hotspot'),
104
- tackLabel: document.getElementById('tack-label')
105
- };
106
-
107
- let currentTackLabelMode = 'TACK'; // Stato dell'etichetta tattica ('TACK' o 'GYBE') con isteresi
108
-
109
- // ==========================================================================
110
- // 3. UTILITIES (MATEMATICA, BUFFER E MEMORIA) - [Esportate in utils.js]
111
- // ==========================================================================
112
-
113
- /**
114
- * Inserimento sicuro nel buffer con trigonometria precaricata e pruning automatico
115
- * Mantiene sempre almeno 60 minuti di memoria locale per il calcolo della bussola meteo.
116
- */
117
- function safePush(buffer, val, time) {
118
- if (val === null || val === undefined || isNaN(val)) return;
119
-
120
- buffer.push({
121
- val: val,
122
- time: time,
123
- sin: Math.sin(val),
124
- cos: Math.cos(val)
125
- });
126
-
127
- // Se stiamo spingendo nel buffer TWD, conserviamo sempre almeno 60 minuti in memoria locale
128
- const isTwdBuffer = (buffer === store.longBuf.twd);
129
- const limitMinutes = isTwdBuffer ? 60 : CONFIG.graphs.historyMinutes;
130
- const maxHistoryMs = (limitMinutes * 60000 * 2) + 60000;
131
-
132
- while (buffer.length > 0 && (time - buffer[0].time) > maxHistoryMs) {
133
- buffer.shift();
134
- }
135
- if (buffer.length > 36000) buffer.shift();
136
- }
137
-
138
- function saveDashboardState() {
139
- try {
140
- const focusedBox = document.querySelector('.data-box.is-focused');
141
- let focusedType = null;
142
- if (focusedBox) {
143
- const match = focusedBox.className.match(/box-([a-z]+)/);
144
- if (match) focusedType = match[1];
145
- }
146
-
147
- const state = {
148
- version: DASH_VERSION,
149
- histories: store.histories,
150
- longBuf: store.longBuf,
151
- displayModeSog: displayModeSog,
152
- displayModeTws: displayModeTws,
153
- graphModes: graphModes,
154
- isNightMode: document.body.classList.contains('night-mode'),
155
- isFocusActive: isFocusActive,
156
- focusedBoxType: focusedType,
157
- timestamp: Date.now()
158
- };
159
- localStorage.setItem('rotevista_dash_state', JSON.stringify(state));
160
- } catch (e) { console.error("Save error:", e); }
161
- }
162
-
163
- function loadDashboardState() {
164
- const saved = localStorage.getItem('rotevista_dash_state');
165
- if (!saved) return;
166
- try {
167
- const state = JSON.parse(saved);
168
- if (state.version !== DASH_VERSION) { localStorage.removeItem('rotevista_dash_state'); return; }
169
-
170
- if ((Date.now() - state.timestamp) / 60000 < 20) {
171
- if (state.histories) Object.assign(store.histories, state.histories);
172
- if (state.longBuf) Object.assign(store.longBuf, state.longBuf);
173
- if (state.graphModes) Object.assign(graphModes, state.graphModes);
174
-
175
- if (state.displayModeSog) {
176
- displayModeSog = state.displayModeSog;
177
- const labelEl = document.getElementById('sog-vmg-label');
178
- if (labelEl) labelEl.textContent = displayModeSog;
179
- }
180
- if (state.displayModeTws) {
181
- displayModeTws = state.displayModeTws;
182
- const labelEl = document.getElementById('tws-aws-label');
183
- if (labelEl) labelEl.textContent = displayModeTws;
184
- }
185
- if (state.isNightMode) document.body.classList.add('night-mode');
186
-
187
- if (state.isFocusActive && state.focusedBoxType) {
188
- setTimeout(() => {
189
- const el = document.querySelector(`.box-${state.focusedBoxType}`);
190
- if (el) { isFocusActive = false; toggleFocusMode(state.focusedBoxType, el); }
191
- }, 200);
192
- }
193
- }
194
- } catch (e) { localStorage.removeItem('rotevista_dash_state'); }
195
- }
196
-
197
- // ==========================================================================
198
- // 4. AUDIO E ALLARMI - [Esportati in utils.js]
199
- // ==========================================================================
200
-
201
- function checkDepthAlarm(m) {
202
- ui.depth.classList.remove('alarm-warning', 'alarm-danger', 'blink-alarm');
203
- if (m < CONFIG.alarms.depthDanger) {
204
- ui.depth.classList.add('alarm-danger', 'blink-alarm');
205
- // Suona solo se siamo attivamente in navigazione (Harbor Silence acustico quando fermi)
206
- if (isNavigating) {
207
- playBingBing();
208
- }
209
- } else if (m < CONFIG.alarms.depthWarning) {
210
- ui.depth.classList.add('alarm-warning');
211
- }
212
- }
213
-
214
- /**
215
- * computeTrueWind: Calcola TWS, TWA e TWD strategico.
216
- * Gestisce il fallback separato (Split-Fallback) in caso di dati parzialmente nativi di bordo.
217
- */
218
- function computeTrueWind() {
219
- const aws = store.raw["environment.wind.speedApparent"], awa = store.raw["environment.wind.angleApparent"];
220
- const stw = store.raw["navigation.speedThroughWater"], sog = store.raw["navigation.speedOverGround"] || 0;
221
- const hdg = store.raw["navigation.headingTrue"]; // Rimosso il default a 0 fittizio all'avvio
222
- if (aws === undefined || awa === undefined) return;
223
-
224
- const now = store.timestamps["environment.wind.speedApparent"] || Date.now();
225
-
226
- // RILEVAMENTO "LOG BLOCCATO" (Fouled Paddlewheel)
227
- const hasFreshStw = store.timestamps["navigation.speedThroughWater"] && (now - store.timestamps["navigation.speedThroughWater"] < 15000);
228
- let speedRef = 0;
229
-
230
- if (hasFreshStw && stw !== undefined) {
231
- // Se la barca naviga a GPS (> 1.5 nodi) ma l'elichetta legge quasi zero (< 0.5 nodi), il log è sporco/bloccato.
232
- if (sog > 0.77 && stw < 0.25) {
233
- speedRef = sog; // Forza il backup su SOG ignorando lo "0" fittizio del log
234
- } else {
235
- speedRef = stw;
236
- }
237
- } else {
238
- speedRef = sog;
239
- }
240
-
241
- // ==========================================================================
242
- // 1. GESTIONE TWS (NATIVO vs FALLBACK)
243
- // ==========================================================================
244
- const hasNativeTws = store.timestamps["environment.wind.speedTrue"] && (Date.now() - store.timestamps["environment.wind.speedTrue"] < 5000);
245
- let tws_water = 0;
246
-
247
- if (hasNativeTws) {
248
- tws_water = store.raw["environment.wind.speedTrue"] ? msToKts(store.raw["environment.wind.speedTrue"]) : 0;
249
- } else {
250
- tws_water = Math.sqrt(aws * aws + speedRef * speedRef - 2 * aws * speedRef * Math.cos(awa));
251
- store.raw["environment.wind.speedTrue"] = tws_water;
252
- }
253
-
254
- let twa = 0;
255
- // Verifica se riceviamo un TWA nativo valido ed efficiente negli ultimi 5 secondi
256
- const hasNativeTwa = store.timestamps["environment.wind.angleTrueWater"] && (Date.now() - store.timestamps["environment.wind.angleTrueWater"] < 5000);
257
-
258
- if (hasNativeTwa && store.raw["environment.wind.angleTrueWater"] !== undefined) {
259
- // "Native First": diamo la precedenza assoluta al valore calcolato dalla centralina
260
- twa = store.raw["environment.wind.angleTrueWater"];
261
- } else {
262
- // "Fallback Second": se manca il dato nativo, eseguiamo il calcolo vettoriale a mano
263
- if (tws_water > 0.05) {
264
- twa = Math.atan2(aws * Math.sin(awa), aws * Math.cos(awa) - speedRef);
265
- store.raw["environment.wind.angleTrueWater"] = twa;
266
- }
267
- }
268
-
269
- // Salviamo e allineiamo i buffer delle medie se abbiamo un angolo valido (nativo o calcolato)
270
- if (tws_water > 0.05 || hasNativeTwa) {
271
- safePush(store.smoothBuf.twa, twa, now);
272
- safePush(store.longBuf.twa, twa, now);
273
-
274
- // Salviamo l'angolo apparente solo se il relativo sensore fisico è effettivamente attivo
275
- if (awa !== undefined) {
276
- safePush(store.smoothBuf.awa, awa, now);
277
- safePush(store.longBuf.awa, awa, now);
278
- }
279
- }
280
-
281
- // ==========================================================================
282
- // 2. GESTIONE TWD (NATIVO vs FALLBACK MATEMATICO STABILE)
283
- // ==========================================================================
284
- const hasNativeTwd = store.timestamps["environment.wind.directionTrue"] && (Date.now() - store.timestamps["environment.wind.directionTrue"] < 5000);
285
-
286
- // Eseguiamo il calcolo del TWD di fallback solo se la Prua (hdg) è realmente disponibile in memoria,
287
- // evitando di inquinare i buffer all'avvio con lo zero fittizio
288
- if (!hasNativeTwd && tws_water > 0.05 && hdg !== undefined) {
289
- // Calcolo TWD stabile e immune dal rollio: Prua + TWA
290
- let twd = (hdg + twa + 2 * Math.PI) % (2 * Math.PI);
291
- store.raw["environment.wind.directionTrue"] = twd;
292
- safePush(store.smoothBuf.twd, twd, now);
293
- safePush(store.longBuf.twd, twd, now);
294
- }
295
- }
296
-
297
- // ==========================================================================
298
- // GESTIONE SMART LOCK DELLE SORGENTI (Zero-Config)
299
- // ==========================================================================
300
-
301
- /**
302
- * Assegna un punteggio di qualità statico alla sorgente basato sull'hardware.
303
- * Più alto è il punteggio, maggiore è la priorità del sensore.
304
- */
305
- function getSourcePriorityScore(sourceName) {
306
- if (!sourceName) return 0;
307
- const name = sourceName.toLowerCase();
308
-
309
- // TIER 3: AIS / Trasmissioni lente (es. yacht_device.AI, VDO)
310
- if (name.includes('.ai') || name.includes('ais') || name.includes('vdo')) {
311
- return 10;
312
- }
313
-
314
- // TIER 2: GPS USB del Cerbo GX / Victron / Sistemi locali di servizio
315
- if (name.includes('venus') || name.includes('victron') || name.includes('ttyacm') || name.includes('ttyusb') || name.includes('system')) {
316
- return 50;
317
- }
318
-
319
- // TIER 1: Strumentazione ufficiale di navigazione (NMEA 2000, Yacht Devices GP/YD/AP, Gateway, ecc.)
320
- if (name.includes('yacht_device') || name.includes('n2k') || name.includes('actisense') || name.includes('can') || name.includes('nmea') || name.includes('raymarine') || name.includes('garmin') || name.includes('simrad') || name.includes('b&g')) {
321
- return 100;
322
- }
323
-
324
- return 30; // Punteggio standard per sorgenti sconosciute
325
- }
326
-
327
- function processIncomingData(path, val, source, timeMs) {
328
- // 0. Filtro di validità assoluta: ignora all'istante pacchetti vuoti, null o non numerici
329
- if (val === null || val === undefined || (typeof val === 'number' && isNaN(val))) {
330
- return;
331
- }
332
-
333
- // 1. Filtro anti-spike vento (> 100 nodi / 51.44 m/s)
334
- if ((path === "environment.wind.speedApparent" || path === "environment.wind.speedTrue") && val > 51.44) {
335
- return;
336
- }
337
-
338
- // 2. Filtro anti-spike velocità barca STW/SOG (> 50 nodi / 25.72 m/s)
339
- if ((path === "navigation.speedThroughWater" || path === "navigation.speedOverGround") && val > 25.72) {
340
- return;
341
- }
342
-
343
- // 3. Filtro validità profondità (ignora errori negativi e letture > 500m per lost-echo)
344
- if (path === "environment.depth.belowTransducer" && (val < -2.0 || val > 500)) {
345
- return;
346
- }
347
-
348
- const now = timeMs || Date.now();
349
- const score = getSourcePriorityScore(source);
350
-
351
- // Gestione dello Smart Lock
352
- if (!sourceLocks[path]) {
353
- // Nessun blocco attivo: aggancia la sorgente corrente
354
- sourceLocks[path] = { label: source, score: score, lastSeen: now };
355
- } else {
356
- const currentLock = sourceLocks[path];
357
- const isSameSource = (currentLock.label === source);
358
- // Tolleranza 5s: ignora il GPS Victron durante le micro-pause di Yacht Device per evitare imprecisioni
359
- const isLockExpired = (now - currentLock.lastSeen > 5000);
360
- const hasHigherPriority = (score > currentLock.score);
361
- // Switch immediato tra apparati principali di pari priorità (es. YD e AP) se inattivi da > 1.5s
362
- const isSamePriorityStale = (score === currentLock.score && (now - currentLock.lastSeen > 1500));
363
-
364
- if (isSameSource) {
365
- // Stessa sorgente: aggiorna il timestamp e mantiene il blocco
366
- currentLock.lastSeen = now;
367
- currentLock.score = score;
368
- } else if (hasHigherPriority || isLockExpired || isSamePriorityStale) {
369
- // Passa a sorgenti secondarie (Victron) solo se Yacht Device è spento da > 5s
370
- sourceLocks[path] = { label: source, score: score, lastSeen: now };
371
- console.log(`🔌 [Smart Lock] Path "${path}" switched to: ${source} (Score: ${score})`);
372
- } else {
373
- // Scarta sorgenti imprecise di servizio mentre Yacht Device è la sorgente principale attiva
374
- return;
375
- }
376
- }
377
-
378
- // Aggiorna lo stato raw e sincronizza il timestamp del watchdog sull'orologio locale (Date.now()) per evitare falsi timeout
379
- const localNow = Date.now();
380
- store.timestamps[path] = localNow;
381
- store.raw[path] = val;
382
-
383
- // Converte e aggiorna istantaneamente store.raw["navigation.headingTrue"] per la UI prima del rate limiter
384
- if (path === "navigation.headingMagnetic") {
385
- const hasNativeTrueHdg = store.timestamps["navigation.headingTrueNative"] && (localNow - store.timestamps["navigation.headingTrueNative"] < 5000);
386
- if (!hasNativeTrueHdg) {
387
- const variation = store.raw["navigation.magneticVariation"] || 0;
388
- store.raw["navigation.headingTrue"] = (val + variation + 2 * Math.PI) % (2 * Math.PI);
389
- store.timestamps["navigation.headingTrue"] = localNow;
390
- }
391
- }
392
-
393
- // Le coordinate GPS e la posizione non sono soggette alla limitazione a 1Hz
394
- if (path === "navigation.position") {
395
- return; // Esce subito
396
- }
397
-
398
- // LIMITATORE DI FREQUENZA (RATE LIMITER) CLIENT-SIDE A 1HZ PER PERCORSO ATTIVO:
399
- if (!lastPathProcessTimes[path]) lastPathProcessTimes[path] = 0;
400
- if (now - lastPathProcessTimes[path] < 800) {
401
- return; // Esce subito risparmiando cicli di calcolo
402
- }
403
- lastPathProcessTimes[path] = now;
404
-
405
- // Da qui in poi, l'inserimento nei buffer fisici avviene rigorosamente a 1Hz:
406
-
407
- // Intercetta il TWD nativo e lo spinge nei buffer della bussola radar
408
- if (path === "environment.wind.directionTrue") {
409
- let directionVal = (val && typeof val === 'object' && val.val !== undefined) ? val.val : val;
410
- safePush(store.smoothBuf.twd, directionVal, now);
411
- safePush(store.longBuf.twd, directionVal, now);
412
- }
413
-
414
- // Intercetta il TWS nativo e lo memorizza in tempo reale
415
- if (path === "environment.wind.speedTrue") {
416
- let speedVal = (val && typeof val === 'object' && val.val !== undefined) ? val.val : val;
417
- store.raw["environment.wind.speedTrue"] = speedVal;
418
- }
419
-
420
- // Intercetta TWA nativo (Angolo Vento Reale)
421
- if (path === "environment.wind.angleTrueWater") {
422
- let angleVal = (val && typeof val === 'object' && val.val !== undefined) ? val.val : val;
423
- store.raw["environment.wind.angleTrueWater"] = angleVal;
424
- }
425
-
426
- // --- GESTIONE PRUA VERA / MAGNETICA CON AUTODIVIAZIONE ---
427
- if (path === "navigation.headingTrue") {
428
- store.timestamps["navigation.headingTrueNative"] = now; // Marca la presenza di un sensore nativo di prua vera
429
- store.timestamps["navigation.headingTrue"] = localNow;
430
- safePush(store.smoothBuf.hdg, val, now);
431
- safePush(store.longBuf.hdg, val, now);
432
- }
433
- else if (path === "navigation.headingMagnetic") {
434
- // Se non c'è una prua vera NATIVA negli ultimi 5 secondi, converte e registra ad ogni secondo la prua magnetica
435
- const hasNativeTrueHdg = store.timestamps["navigation.headingTrueNative"] && (now - store.timestamps["navigation.headingTrueNative"] < 5000);
436
- if (!hasNativeTrueHdg) {
437
- const variation = store.raw["navigation.magneticVariation"] || 0; // Legge la declinazione magnetica del GPS
438
- const calculatedTrueHdg = (val + variation + 2 * Math.PI) % (2 * Math.PI);
439
-
440
- // Registra il valore calcolato mantenendo attivo il watchdog senza bloccare i successivi pacchetti magnetici
441
- store.raw["navigation.headingTrue"] = calculatedTrueHdg;
442
- store.timestamps["navigation.headingTrue"] = localNow;
443
- safePush(store.smoothBuf.hdg, calculatedTrueHdg, now);
444
- safePush(store.longBuf.hdg, calculatedTrueHdg, now);
445
- }
446
- }
447
-
448
- if (path === "navigation.courseOverGroundTrue") {
449
- safePush(store.smoothBuf.cog, val, now);
450
- safePush(store.longBuf.cog, val, now);
451
-
452
- // Se non è installata alcuna bussola fisica sulla rete e la barca è in movimento stabile (> 1.5 nodi),
453
- // emuliamo la Prua usando il COG per attivare il TWD, il mini-compass e la bussola radar.
454
- const hasCompass = store.raw["navigation.headingTrue"] !== undefined || store.raw["navigation.headingMagnetic"] !== undefined;
455
- const sog = store.raw["navigation.speedOverGround"] || 0;
456
- if (!hasCompass && sog > 0.77) { // 0.77 m/s = 1.5 nodi
457
- store.raw["navigation.headingTrue"] = val;
458
- store.timestamps["navigation.headingTrue"] = localNow;
459
- safePush(store.smoothBuf.hdg, val, now);
460
- safePush(store.longBuf.hdg, val, now);
461
- }
462
- }
463
-
464
- const twPaths = [
465
- "environment.wind.speedApparent", "environment.wind.angleApparent",
466
- "navigation.speedThroughWater", "navigation.speedOverGround",
467
- "navigation.headingTrue", "navigation.courseOverGroundTrue"
468
- ];
469
-
470
- if (twPaths.includes(path)) {
471
- twDirty = true;
472
- if (twDirty && (now - lastTWCompute > 100)) {
473
- computeTrueWind();
474
- lastTWCompute = now;
475
- twDirty = false;
476
- }
477
- }
478
- }
479
-
480
- // ==========================================================================
481
- // 6. TREND VENTO (Tattico 2s vs 10s | Strategico 1m vs 10m)
482
- // Sincronizzato sul tempo reale dei sensori per eliminare i conflitti di orologio.
483
- // ==========================================================================
484
- function updateWindTrend() {
485
- // --- RISOLUZIONE CLOCK DRIFT SUI PALLINI METEO ---
486
- // Usiamo il tempo del sensore (l'ultimo dato del TWD in memoria) invece dell'orologio del tablet
487
- const latestTwdPoint = store.longBuf.twd.length > 0 ? store.longBuf.twd[store.longBuf.twd.length - 1] : null;
488
- const now = latestTwdPoint ? latestTwdPoint.time : Date.now();
489
-
490
- // --- 6.1 TREND TATTICO (AWA/TWA Sintonizzato) ---
491
- const twaNow = getCircularAverageFromBuffer(store.longBuf.twa, 2000, true, now);
492
- const twaRef = getCircularAverageFromBuffer(store.longBuf.twa, 10000, true, now);
493
- const gaugeDots = { cw: document.getElementById('trend-gauge-cw'), ccw: document.getElementById('trend-gauge-ccw') };
494
-
495
- // --- 6.2 ALLARME STRAMBATA CON ISTERESI (MACCHINA A STATI ANTI-BRANDEGGIO Sintonizzato) ---
496
- const instTwaRad = store.raw["environment.wind.angleTrueWater"];
497
- let smoothedTwaDeg = null;
498
-
499
- if (instTwaRad !== undefined) {
500
- const dynamicAlpha = Math.max(0.05, 1.1 - CONFIG.averaging.stabilityThreshold);
501
- const currentSin = Math.sin(instTwaRad);
502
- const currentCos = Math.cos(instTwaRad);
503
-
504
- if (firstEmaRun) {
505
- emaTwaSin = currentSin; emaTwaCos = currentCos; firstEmaRun = false;
506
- } else {
507
- emaTwaSin = (currentSin * dynamicAlpha) + (emaTwaSin * (1 - dynamicAlpha));
508
- emaTwaCos = (currentCos * dynamicAlpha) + (emaTwaCos * (1 - dynamicAlpha));
509
- }
510
-
511
- smoothedTwaDeg = radToDeg(Math.atan2(emaTwaSin, emaTwaCos));
512
- const absTwaDeg = Math.abs(smoothedTwaDeg);
513
-
514
- // Allarme VISIVO: Se siamo in zona di pericolo poppa profonda (> 155°), accendi entrambi i LED di rosso pulsante
515
- if (absTwaDeg > 155) {
516
- if (gaugeDots.cw) { gaugeDots.cw.classList.add('is-gybing'); gaugeDots.cw.setAttribute('fill', '#ff3b30'); }
517
- if (gaugeDots.ccw) { gaugeDots.ccw.classList.remove('is-gybing'); }
518
-
519
- // LOGICA MACCHINA A STATI CON ISTERESI:
520
- // Rileviamo le mure solo se siamo fuori dalla zona cieca di poppa secca (> 170°).
521
- // Se oscilliamo tra -175° e +178° (brandeggio), lastGybeSide NON cambia e l'allarme acustico tace.
522
- let currentTack = null;
523
- if (smoothedTwaDeg > 155 && smoothedTwaDeg < 170) {
524
- currentTack = 'starboard';
525
- } else if (smoothedTwaDeg < -155 && smoothedTwaDeg > -170) {
526
- currentTack = 'port';
527
- }
528
-
529
- if (currentTack !== null) {
530
- if (lastGybeSide === null) {
531
- // Inizializzazione al primo ingresso nella zona di controllo
532
- lastGybeSide = currentTack;
533
- } else if (lastGybeSide !== currentTack) {
534
- // Abbiamo eseguito una vera strambata stabile e siamo usciti dalla zona di poppa secca!
535
- lastGybeSide = currentTack;
536
-
537
- // Estraggo i dati correnti di vento reale (TWS), velocità (SOG) e l'angolo TWA smorzato
538
- const twsKts = store.raw["environment.wind.speedTrue"] ? msToKts(store.raw["environment.wind.speedTrue"]) : 0;
539
- const sogKts = msToKts(store.raw["navigation.speedOverGround"] || 0);
540
- const twaDeg = Math.abs(smoothedTwaDeg);
541
-
542
- // RILEVAMENTO AERODINAMICO MOTORE IN POPPA:
543
- // Se siamo di poppa (TWA > 135°) e la velocità della barca supera il 75% della velocità del vento reale,
544
- // è fisicamente impossibile essere a vela pura. Siamo a motore o in motorsailing.
545
- const isMotoringDownwind = (twaDeg > 135) && (sogKts > (twsKts * 0.75));
546
-
547
- // L'allarme strambata acustico è reale e attivo solo se:
548
- // 1. C'è vento significativo (> 7.0 nodi)
549
- // 2. La barca è in movimento (> 1.0 nodi)
550
- // 3. NON siamo a motore/motorsailing (isMotoringDownwind è falso)
551
- const isGybeDangerous = twsKts > 7.0 && sogKts > 1.0 && !isMotoringDownwind;
552
-
553
- // Attivazione allarme acustico con blocco temporale di sicurezza (60 secondi)
554
- if (isGybeDangerous && (now - lastGybeAlarmTime > 60000)) {
555
- lastGybeAlarmTime = now;
556
- playGybeAlarm();
557
- console.log(`⚠️ GYBE ALARM TRIGGERED: Tack switched to ${currentTack} (TWA: ${twaDeg.toFixed(1)}°, TWS: ${twsKts.toFixed(1)}kts, SOG: ${sogKts.toFixed(1)}kts)`);
558
- }
559
- }
560
- }
561
- } else {
562
- // Se usciamo dalla poppa profonda (< 155°), disattiva l'allarme visivo e resetta lo stato delle mure
563
- if (gaugeDots.cw) gaugeDots.cw.classList.remove('is-gybing');
564
- if (gaugeDots.ccw) gaugeDots.ccw.classList.remove('is-gybing');
565
- lastGybeSide = null; // Reset per la prossima poppa
566
- }
567
- lastInstantTwa = smoothedTwaDeg;
568
- }
569
-
570
- // Gestione normale dei Trend Tattici (Lifts/Headers) se NON siamo in allarme strambata
571
- if (twaNow && twaRef && (smoothedTwaDeg === null || Math.abs(smoothedTwaDeg) <= 155)) {
572
- let deltaTac = radToDeg((twaNow.val - twaRef.val + Math.PI * 3) % (2 * Math.PI) - Math.PI);
573
- const curTwaDeg = radToDeg(twaNow.val);
574
- if (Math.abs(deltaTac) > 3.0) {
575
- let absTwa = Math.abs(curTwaDeg);
576
- let tacticColor;
577
- if (absTwa > 75 && absTwa < 105) {
578
- tacticColor = "#bbb";
579
- } else {
580
- let isLift = (curTwaDeg > 0) ? (deltaTac > 0) : (deltaTac < 0);
581
- if (absTwa >= 90) isLift = !isLift;
582
- tacticColor = isLift ? "#27ae60" : "#c0392b";
583
- }
584
- if (deltaTac > 0) {
585
- if (gaugeDots.cw) { gaugeDots.cw.classList.add('is-trending'); gaugeDots.cw.setAttribute('fill', tacticColor); }
586
- if (gaugeDots.ccw) { gaugeDots.ccw.classList.remove('is-trending'); gaugeDots.ccw.setAttribute('fill', '#bbb'); } // Resetta l'attributo di riempimento del pallino inattivo
587
- } else {
588
- if (gaugeDots.ccw) { gaugeDots.ccw.classList.add('is-trending'); gaugeDots.ccw.setAttribute('fill', tacticColor); }
589
- if (gaugeDots.cw) { gaugeDots.cw.classList.remove('is-trending'); gaugeDots.cw.setAttribute('fill', '#bbb'); } // Resetta l'attributo di riempimento del pallino inattivo
590
- }
591
- } else {
592
- [gaugeDots.cw, gaugeDots.ccw].forEach(el => { if(el){ el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }});
593
- }
594
- }
595
-
596
- // --- 6.3 TREND METEO STRATEGICO (TWD PARAMETRIZZATO E SINTONIZZATO) ---
597
- const twdNow = getCircularAverageFromBuffer(store.longBuf.twd, 60000, false, now); // 1 minuto fisso
598
-
599
- // Parametrizzazione rigida nel codice: 15 minuti in navigazione, 60 minuti all'ancora
600
- const fixedTwdMinutes = isNavigating ? 15 : 60;
601
- const strategicWindowMs = fixedTwdMinutes * 60000;
602
-
603
- const twdRef = getCircularAverageFromBuffer(store.longBuf.twd, strategicWindowMs, false, now);
604
-
605
- const compassDots = { cw: document.getElementById('trend-dot-cw'), ccw: document.getElementById('trend-dot-ccw') };
606
-
607
- if (twdNow && twdRef) {
608
- let deltaMeteo = radToDeg((twdNow.val - twdRef.val + Math.PI * 3) % (2 * Math.PI) - Math.PI);
609
- if (Math.abs(deltaMeteo) > 6.0) {
610
- const isSouth = store.raw["navigation.position"]?.latitude < 0;
611
- let meteoColor = (!isSouth) ? (deltaMeteo < 0 ? "#27ae60" : "#c0392b") : (deltaMeteo > 0 ? "#27ae60" : "#c0392b");
612
- if (deltaMeteo > 0) {
613
- if (compassDots.cw) { compassDots.cw.classList.add('is-trending'); compassDots.cw.setAttribute('fill', meteoColor); }
614
- if (compassDots.ccw) { compassDots.ccw.classList.remove('is-trending'); }
615
- } else {
616
- if (compassDots.ccw) { compassDots.ccw.classList.add('is-trending'); compassDots.ccw.setAttribute('fill', meteoColor); }
617
- if (compassDots.cw) { compassDots.cw.classList.remove('is-trending'); }
618
- }
619
- } else {
620
- [compassDots.cw, compassDots.ccw].forEach(el => { if(el){ el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }});
621
- }
622
- }
623
- }
624
-
625
- // ==========================================================================
626
- // 7. RENDERING ENGINE E AGGIORNAMENTO UI
627
- // ==========================================================================
628
-
629
- /**
630
- * upUI: Aggiornamento valori digitali con tolleranza d'emergenza sul dato istantaneo
631
- */
632
- const upUI = (el, obj, instantRaw, isCompass = false) => {
633
- // Se la media obj non è momentaneamente disponibile, usa il dato istantaneo raw come paracadute
634
- const displayRad = (obj && obj.val !== null && !isNaN(obj.val)) ? obj.val : instantRaw;
635
-
636
- if (displayRad === undefined || displayRad === null || isNaN(displayRad)) {
637
- el.innerHTML = "---&deg;";
638
- el.classList.remove('unstable-data');
639
- } else {
640
- let valDeg = Math.round(radToDeg(displayRad));
641
- let mainVal = (isCompass ? ((valDeg + 360) % 360).toString().padStart(3, '0') : valDeg) + "&deg;";
642
- let dev = (obj && obj.dev > 1 && obj.dev < 90) ? `<span style="font-size: 0.8em; opacity: 0.4; margin-left: 6px;">&plusmn;${obj.dev}</span>` : "";
643
- el.innerHTML = mainVal + dev;
644
-
645
- let diff = (obj && instantRaw !== undefined) ? Math.abs((radToDeg(instantRaw) - radToDeg(obj.val) + 540) % 360 - 180) : 0;
646
- if (isNavigating && (obj && (!obj.stable || obj.dev > CONFIG.averaging.stabilityBreakout || diff > CONFIG.averaging.stabilityBreakout))) {
647
- el.classList.add('unstable-data');
648
- } else {
649
- el.classList.remove('unstable-data');
650
- }
651
- }
652
- };
653
-
654
- /**
655
- * Loop principale di aggiornamento interfaccia (1Hz)
656
- */
657
- function startDisplayLoop() {
658
- renderInterval = setInterval(() => {
659
- const now = Date.now();
660
- const isNight = document.body.classList.contains('night-mode');
661
-
662
- const stwKts = msToKts(store.raw["navigation.speedThroughWater"] || 0);
663
- const sogKts = msToKts(store.raw["navigation.speedOverGround"] || 0);
664
-
665
- isNavigating = stwKts > CONFIG.averaging.minSpeed || sogKts > CONFIG.averaging.minSpeed;
666
- store.isNavigating = isNavigating; // Sincronizzazione stato nello store condiviso
667
-
668
- // --- CALCOLO TREND VENTO & ALLARME STRAMBATA ---
669
- updateWindTrend();
670
-
671
- // --- AGGIORNAMENTO STATUS CON WATCHDOG ANTI-ZOMBIE ---
672
- const isSocketOpen = socket && socket.readyState === WebSocket.OPEN;
673
- const isDataFlowing = (now - lastMessageReceivedTime) < 6000; // Flusso dati attivo negli ultimi 6s
674
-
675
- // Se il socket risulta aperto ma non riceve pacchetti da > 6s (connessione zombie congelata da Android)
676
- if (isSocketOpen && !isDataFlowing && !document.hidden && !simulationMode) {
677
- console.warn("🔌 [Watchdog] Rilevato socket zombie (0 byte ricevuti da 6s). Forzo riconnessione...");
678
- connect();
679
- }
680
-
681
- if (isSocketOpen && isDataFlowing) {
682
- ui.status.className = "online"; // Colore Verde
683
- const viewportMinutes = CONFIG.graphs.historyMinutes * (isNavigating ? 1 : 2);
684
- const requiredMs = viewportMinutes * 60000;
685
- const oldestStw = store.histories.stw ? store.histories.stw[0] : null;
686
-
687
- if (oldestStw) {
688
- const availableMs = now - oldestStw.time;
689
- if (availableMs >= requiredMs) {
690
- ui.status.innerText = `ONLINE ${viewportMinutes}min`;
691
- } else {
692
- const availableMin = Math.max(1, Math.floor(availableMs / 60000));
693
- ui.status.innerText = `ONLINE ${availableMin}/${viewportMinutes}min`;
694
- }
695
- } else {
696
- ui.status.innerText = `ONLINE`;
697
- }
698
- } else {
699
- ui.status.className = "offline"; // Colore Rosso
700
- ui.status.innerText = isSocketOpen ? "STALL (RECONNECTING...)" : "OFFLINE";
701
- }
702
-
703
- // --- WATCHDOG: CONTROLLO TIMEOUT ---
704
- const watch = {
705
- "navigation.speedThroughWater": ui.stw, "navigation.speedOverGround": ui.sog,
706
- "navigation.headingTrue": ui.hdg, "navigation.courseOverGroundTrue": ui.cog,
707
- "environment.wind.speedApparent": ui.awsSvg, "environment.depth.belowTransducer": ui.depth,
708
- "environment.wind.speedTrue": ui.tws,
709
- "environment.wind.angleApparent": ui.awaAvg,
710
- "environment.wind.angleTrueWater": ui.twaAvg,
711
- "environment.wind.directionTrue": ui.twdAvg
712
- };
713
- for (let p in watch) {
714
- if (!store.timestamps[p] || (now - store.timestamps[p] > TIMEOUT_MS)) {
715
- if (watch[p]) safeSetText(watch[p], "---");
716
- delete store.raw[p];
717
-
718
- // Forza lo scorrimento dei dati fuori dallo schermo per i sensori in timeout
719
- if (p === "navigation.speedThroughWater") {
720
- refreshGraph('stw');
721
- } else if (p === "navigation.speedOverGround") {
722
- refreshGraph('sog');
723
- } else if (p === "environment.depth.belowTransducer") {
724
- refreshGraph('depth');
725
- } else if (p === "environment.wind.speedApparent" || p === "environment.wind.speedTrue") {
726
- refreshGraph('tws');
727
- }
728
- }
729
- }
730
-
731
- // --- AGGIORNAMENTO VELOCITÀ SULL'ACQUA (STW) ---
732
- if (store.raw["navigation.speedThroughWater"] !== undefined) {
733
- safeSetText(ui.stw, stwKts.toFixed(1));
734
- ui.stw.style.color = ""; // Neutro
735
- manageHistory('stw', stwKts);
736
- }
737
-
738
- // --- LOGICA SOG / VMG ---
739
- if (store.raw["navigation.speedOverGround"] !== undefined) {
740
- // RILEVAMENTO "LOG BLOCCATO" PER IL CALCOLO VMG
741
- const hasFreshStw = store.timestamps["navigation.speedThroughWater"] && (now - store.timestamps["navigation.speedThroughWater"] < 15000);
742
- let speedRefKts = sogKts; // Fallback predefinito su GPS
743
-
744
- if (hasFreshStw) {
745
- // Se viaggiamo a più di 1.5 nodi (GPS) ma il log segna meno di 0.5 nodi, usa il SOG come backup
746
- speedRefKts = (sogKts > 1.5 && stwKts < 0.5) ? sogKts : stwKts;
747
- }
748
-
749
- const vmgVal = Math.abs(speedRefKts * Math.cos(store.raw["environment.wind.angleTrueWater"] || 0));
750
- manageHistory('vmg', vmgVal);
751
- manageHistory('sog', sogKts);
752
-
753
- const labelSogVmg = document.getElementById('sog-vmg-label');
754
- if (displayModeSog === 'VMG') {
755
- safeSetText(ui.sog, vmgVal.toFixed(1));
756
- ui.sog.style.setProperty('color', '#00b8d4', 'important'); // Cyan
757
- if (labelSogVmg) labelSogVmg.textContent = 'VMG';
758
- } else {
759
- safeSetText(ui.sog, sogKts.toFixed(1));
760
- if (labelSogVmg) labelSogVmg.textContent = 'SOG';
761
-
762
- if (isNavigating) {
763
- const hasFreshStw = store.timestamps["navigation.speedThroughWater"] && (now - store.timestamps["navigation.speedThroughWater"] < 15000);
764
- // Rileviamo se il log è plausibilmente sporco o bloccato (SOG in movimento, STW a zero)
765
- const isFouledLog = (sogKts > 1.5 && stwKts < 0.5);
766
-
767
- // Se non abbiamo un log affidabile, è impossibile calcolare la corrente. Evitiamo falsi colori tattici.
768
- if (!hasFreshStw || isFouledLog) {
769
- ui.sog.style.removeProperty('color');
770
- } else {
771
- const lastSog = store.histories.sog.length > 0 ? store.histories.sog[store.histories.sog.length - 1].val : sogKts;
772
- const lastStw = store.histories.stw.length > 0 ? store.histories.stw[store.histories.stw.length - 1].val : stwKts;
773
- const drift = lastSog - lastStw;
774
-
775
- if (drift < -0.3) ui.sog.style.setProperty('color', '#ff3b30', 'important'); // Contro
776
- else if (drift > 0.3) ui.sog.style.setProperty('color', '#00C851', 'important'); // Favore
777
- else ui.sog.style.setProperty('color', '#ffbb33', 'important'); // Neutro SOG
778
- }
779
- } else {
780
- ui.sog.style.removeProperty('color');
781
- }
782
- }
783
- }
784
-
785
- // --- AGGIORNAMENTO PROFONDITÀ (DEPTH) ---
786
- if (store.raw["environment.depth.belowTransducer"] !== undefined) {
787
- safeSetText(ui.depth, store.raw["environment.depth.belowTransducer"].toFixed(1));
788
- checkDepthAlarm(store.raw["environment.depth.belowTransducer"]);
789
- manageHistory('depth', store.raw["environment.depth.belowTransducer"]);
790
- }
791
-
792
- // --- GESTIONE VENTO (TWS / AWS SWITCH & BUSSOLA) ---
793
-
794
- // Estrazione dati sicura: controlliamo esplicitamente se il dato esiste, altrimenti 0
795
- const rawTws = store.raw["environment.wind.speedTrue"];
796
- const rawAws = store.raw["environment.wind.speedApparent"];
797
-
798
- const twsVal = (rawTws !== undefined && rawTws !== null) ? msToKts(rawTws) : 0;
799
- const awsVal = (rawAws !== undefined && rawAws !== null) ? msToKts(rawAws) : 0;
800
-
801
- if (rawTws !== undefined && rawTws !== null) manageHistory('tws', twsVal);
802
- if (rawAws !== undefined && rawAws !== null) manageHistory('aws', awsVal);
803
-
804
- // Disegno testo casella destra (TWS o AWS)
805
- if (rawTws !== undefined || rawAws !== undefined) {
806
- const labelWind = document.getElementById('tws-aws-label');
807
- const currentWind = (displayModeTws === 'AWS') ? awsVal : twsVal;
808
-
809
- safeSetText(ui.tws, currentWind.toFixed(1));
810
- if (labelWind) labelWind.textContent = displayModeTws;
811
-
812
- if (currentWind >= CONFIG.graphs.reef2) {
813
- ui.tws.style.setProperty('color', '#ff3b30', 'important');
814
- } else if (currentWind >= CONFIG.graphs.reef1) {
815
- ui.tws.style.setProperty('color', '#ff9800', 'important');
816
- } else {
817
- if (displayModeTws === 'AWS') {
818
- ui.tws.style.setProperty('color', '#5c6bc0', 'important');
819
- } else {
820
- const navyNight = isNight ? '#6c8ea0' : '#2c3e50';
821
- ui.tws.style.setProperty('color', navyNight, 'important');
822
- }
823
- }
824
- }
825
-
826
- // --- AGGIORNAMENTO DELLA BUSSOLA CENTRALE (BATTERY SAVER A 1Hz) ---
827
- if (activeInstrument === 'gauge') {
828
- updateCentralGauge(store, ui, now, isNavigating, sogKts, stwKts, rawAws, awsVal);
829
- }
830
-
831
- // --- SLOW TIER (Salvataggio stato ogni 10 secondi) ---
832
- if (lastAvgUIUpdate++ % 10 === 0) {
833
- saveDashboardState();
834
- }
835
-
836
- // AGGIORNAMENTO FLUIDO MEDIE UI (Esecuzione a 1Hz costante ad ogni secondo)
837
- const baseThreshold = CONFIG.averaging.stabilityThreshold || 0.95;
838
- const breakout = CONFIG.averaging.stabilityBreakout || 15;
839
-
840
- // CALCOLO DIFFERENZIALE: Tolleranza del vento proporzionale alla sensibilità di base
841
- const windThreshold = Math.max(0.60, baseThreshold - 0.13);
842
-
843
- let hObj = getCircularAverageFromBuffer(store.longBuf.hdg, CONFIG.averaging.longWindow * 2, false, now, baseThreshold, breakout);
844
- let cObj = getCircularAverageFromBuffer(store.longBuf.cog, CONFIG.averaging.longWindow, false, now, baseThreshold, breakout);
845
-
846
- // Applichiamo la soglia differenziale ottimizzata per i tre dati legati al vento
847
- let awObj = getCircularAverageFromBuffer(store.longBuf.awa, CONFIG.averaging.longWindow, true, now, windThreshold, breakout);
848
- let twObj = getCircularAverageFromBuffer(store.longBuf.twa, CONFIG.averaging.longWindow, true, now, windThreshold, breakout);
849
- let twdObj = getCircularAverageFromBuffer(store.longBuf.twd, CONFIG.averaging.longWindow, false, now, windThreshold, breakout);
850
-
851
- // --- GESTIONE DINAMICA ETICHETTA TACK/GYBE CON ISTERESI DI 10 GRADI ---
852
- if (ui.tackLabel) {
853
- const absTwa = Math.abs(radToDeg(store.raw["environment.wind.angleTrueWater"] || 0));
854
- if (currentTackLabelMode === 'TACK' && absTwa > 95) {
855
- currentTackLabelMode = 'GYBE';
856
- } else if (currentTackLabelMode === 'GYBE' && absTwa < 85) {
857
- currentTackLabelMode = 'TACK';
858
- }
859
- safeSetText(ui.tackLabel, currentTackLabelMode);
860
- }
861
-
862
- upUI(ui.hdg, hObj, store.raw["navigation.headingTrue"], true);
863
- upUI(ui.cog, cObj, store.raw["navigation.courseOverGroundTrue"], true);
864
- upUI(ui.awaAvg, awObj, store.raw["environment.wind.angleApparent"], false);
865
- upUI(ui.twaAvg, twObj, store.raw["environment.wind.angleTrueWater"], false);
866
- upUI(ui.twdAvg, twdObj, store.raw["environment.wind.directionTrue"], true);
867
-
868
- if (hObj && twdObj) {
869
- const reflectAngle = (targetRad, axisRad) => {
870
- const dS = Math.sin(axisRad - targetRad);
871
- const dC = Math.cos(axisRad - targetRad);
872
- return Math.atan2(Math.sin(axisRad) * dC + Math.cos(axisRad) * dS, Math.cos(axisRad) * dC - Math.sin(axisRad) * dS);
873
- };
874
- const unstableH = !hObj.stable || !twdObj.stable || hObj.dev > CONFIG.averaging.stabilityBreakout;
875
- if (!isNavigating) ui.tackHdg.innerHTML = "---&deg;";
876
- else if (unstableH) { ui.tackHdg.innerHTML = "---&deg;"; ui.tackHdg.classList.add('unstable-data'); }
877
- else {
878
- const rH = (radToDeg(reflectAngle(hObj.val, twdObj.val)) + 360) % 360;
879
- ui.tackHdg.innerHTML = `${Math.round(rH).toString().padStart(3, '0')}&deg;`;
880
- ui.tackHdg.classList.remove('unstable-data');
881
- }
882
- if (cObj) {
883
- const unstableC = !cObj.stable || !twdObj.stable || cObj.dev > CONFIG.averaging.stabilityBreakout;
884
- if (!isNavigating) ui.tackCog.innerHTML = "---&deg;";
885
- else if (unstableC) { ui.tackCog.innerHTML = "---&deg;"; ui.tackCog.classList.add('unstable-data'); }
886
- else {
887
- const rC = (radToDeg(reflectAngle(cObj.val, twdObj.val)) + 360) % 360;
888
- ui.tackCog.innerHTML = `${Math.round(rC).toString().padStart(3, '0')}&deg;`;
889
- ui.tackCog.classList.remove('unstable-data');
890
- }
891
- }
892
- }
893
- }, RENDER_INTERVAL_MS);
894
- }
895
-
896
- // ==========================================================================
897
- // 8. CONFIGURAZIONE, AGGIORNAMENTO LIVE E GRAFICI UTILS
898
- // ==========================================================================
899
-
900
- let currentConfigString = ""; // Memoria per rilevare cambiamenti nei settings
901
-
902
- /**
903
- * Risolve dinamicamente l'URL dell'API del Cerbo GX se siamo in locale su Mac/PC
904
- */
905
- function getApiUrl(path) {
906
- if (window.location.protocol === 'file:' || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
907
- return `http://${CONFIG.server.fallbackIp}${path}`;
908
- }
909
- return path;
910
- }
911
-
912
- /**
913
- * Funzione Helper: Applica fisicamente i dati JSON all'oggetto CONFIG globale
914
- */
915
- function applyConfigData(data) {
916
- Object.assign(CONFIG.alarms, data.alarms || {});
917
- Object.assign(CONFIG.graphs, data.graphs || {});
918
- Object.assign(CONFIG.averaging, data.averaging || {});
919
-
920
- // Migrazione Silenziosa: Rilevato vecchio parametro stabilità (<= 0.85).
921
- // Aggiornato a 0.95 per ottimizzazione filtri.
922
- if (CONFIG.averaging.stabilityThreshold <= 0.85) {
923
- CONFIG.averaging.stabilityThreshold = 0.95;
924
- }
925
-
926
- if (data.scales) {
927
- for (let key in data.scales) {
928
- if (CONFIG.scales[key]) Object.assign(CONFIG.scales[key], data.scales[key]);
929
- }
930
- }
931
- }
932
-
933
- /**
934
- * Recupera la configurazione iniziale al caricamento della pagina
935
- */
936
- async function fetchServerConfig() {
937
- try {
938
- const response = await fetch(getApiUrl('/rotevista-config'));
939
- if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
940
- const data = await response.json();
941
-
942
- // Salviamo l'impronta digitale della configurazione per i confronti futuri
943
- currentConfigString = JSON.stringify(data);
944
-
945
- applyConfigData(data);
946
- console.log("✅ Configurazione iniziale applicata con successo.");
947
- } catch (err) {
948
- console.warn("⚠️ Impossibile raggiungere le configurazioni dal server. Utilizzo default.");
949
- }
950
- }
951
-
952
- /**
953
- * Watchdog: Controlla in background se le impostazioni sul server sono cambiate
954
- */
955
- async function watchConfigChanges() {
956
- try {
957
- const response = await fetch(getApiUrl('/rotevista-config'));
958
- if (!response.ok) return;
959
- const data = await response.json();
960
-
961
- const newConfigString = JSON.stringify(data);
962
-
963
- // Se l'impronta digitale è diversa, l'utente ha salvato nuovi settings!
964
- if (newConfigString !== currentConfigString) {
965
- console.log("🔄 Rilevato cambio impostazioni su Signal K! Aggiornamento in tempo reale...");
966
-
967
- // Applichiamo i nuovi settings al volo
968
- applyConfigData(data);
969
-
970
- // Aggiorniamo l'impronta
971
- currentConfigString = newConfigString;
972
-
973
- // FEEDBACK VISIVO: Avvisiamo l'utente dell'aggiornamento
974
- ui.status.innerText = "CONFIG UPDATED!";
975
- ui.status.style.color = "#00C851"; // Verde Neon
976
- setTimeout(() => { ui.status.style.color = ""; }, 4000);
977
- }
978
- } catch (err) {
979
- // Ignoriamo gli errori di rete nel watchdog per non intasare la console in caso di disconnessione
980
- }
981
- }
982
-
983
- /**
984
- * Recupera lo storico dei grafici e dei radar pre-popolato dal server Signal K (Pro v6.0)
985
- */
986
- async function fetchServerHistory() {
987
- try {
988
- const response = await fetch(getApiUrl('/rotevista-history'));
989
- if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
990
- const data = await response.json();
991
-
992
- if (data && typeof data === 'object') {
993
- // 1. RILEVAMENTO DEL DELTA DI COERENZA TEMPORALE
994
- // Troviamo il timestamp più recente in assoluto presente nei dati storici del server
995
- let maxServerTime = 0;
996
- for (let key in data) {
997
- if (store.histories[key] !== undefined && Array.isArray(data[key]) && data[key].length > 0) {
998
- const lastPoint = data[key][data[key].length - 1];
999
- if (lastPoint && lastPoint.time > maxServerTime) {
1000
- maxServerTime = lastPoint.time;
1001
- }
1002
- }
1003
- }
1004
-
1005
- // Calcoliamo lo sfasamento in millisecondi rispetto all'orologio del client (MacBook/Tablet)
1006
- const nowMac = Date.now();
1007
- const timeDelta = (maxServerTime > 0) ? (nowMac - maxServerTime) : 0;
1008
-
1009
- if (Math.abs(timeDelta) > 500) {
1010
- console.log(`⏱️ [Clock Sync] Rilevato scostamento di ${(timeDelta/1000).toFixed(1)}s rispetto a einstein. Calibrazione attiva.`);
1011
- }
1012
-
1013
- // 2. TRASLAZIONE DEI DATI STORICI
1014
- // Spostiamo nel tempo tutti i punti passati per incollarli millimetricamente al "now" del client
1015
- for (let key in data) {
1016
- if (store.histories[key] !== undefined) {
1017
- if (Array.isArray(data[key])) {
1018
- store.histories[key] = data[key].map(p => ({
1019
- val: p.val,
1020
- time: p.time + timeDelta, // Sposta il punto nel tempo per allinearlo al Mac
1021
- min: p.min !== undefined ? p.min : undefined,
1022
- max: p.max !== undefined ? p.max : undefined
1023
- }));
1024
- } else {
1025
- store.histories[key] = data[key];
1026
- }
1027
- }
1028
- }
1029
-
1030
- // Sincronizza i dati specifici del radar storici mantendo i timestamp calendarizzati assoluti (0% sfasamento dei perni)
1031
- if (data.windRadarSlots) {
1032
- store.windRadarSlots = data.windRadarSlots; // Mantiene gli orari spaccati al minuto per l'uguaglianza del radar
1033
- }
1034
- if (data.futureForecast) {
1035
- store.futureForecast = {
1036
- ...data.futureForecast,
1037
- timestamp: data.futureForecast.timestamp + timeDelta
1038
- };
1039
- }
1040
- // data.twd e data.tws sono già mappati direttamente in store.histories.twd e store.histories.tws
1041
-
1042
- // Sincronizziamo lo storico dei minuti per il radar, ma lasciamo che il buffer rapido 'store.longBuf.twd'
1043
- // parta pulito all'avvio. Si popolerà istantaneamente a 3Hz con i soli dati in tempo reale,
1044
- // garantendo una reattività della direzione del vento (TWD) immediata e priva di sbalzi all'avvio.
1045
- console.log("📈 Storico dei grafici pre-popolato caricato dal server.");
1046
- }
1047
- } catch (err) {
1048
- console.warn("⚠️ Impossibile caricare lo storico dal server. Utilizzo dati vuoti/simulati.");
1049
- throw err;
1050
- }
1051
- }
1052
-
1053
- /**
1054
- * manageHistory v3.7 - Aggregazione semantica "Pro-Grade"
1055
- * Integrazioni:
1056
- * 1. Strict undefined check per lastUpdates.
1057
- * 2. Anti-dropout dinamico tarato sul 50% del Reef 1.
1058
- * 3. Clamping di sicurezza (no negativi, no Infinity).
1059
- */
1060
- function manageHistory(type, value) {
1061
- // --- 1. VALIDAZIONE INPUT RIGOROSA ---
1062
- if (value === undefined || value === null || !isFinite(value)) return;
1063
-
1064
- const now = Date.now();
1065
- const historyMinutes = Math.max(1, CONFIG.graphs.historyMinutes || 10);
1066
- const samples = Math.max(2, CONFIG.graphs.samples || 60);
1067
- const bucketIntervalMs = (historyMinutes * 60000) / samples;
1068
-
1069
- // --- 2. INIT SICURO (Sintonizzato all'epoca assoluta UTC) ---
1070
- if (!store.graphTempBuf[type]) store.graphTempBuf[type] = [];
1071
- if (!store.histories[type]) store.histories[type] = [];
1072
-
1073
- // SINTONIZZAZIONE DI FASE: Agganciamo il timer iniziale al multiplo UTC più vicino
1074
- if (store.lastUpdates[type] === undefined || store.lastUpdates[type] === 0) {
1075
- store.lastUpdates[type] = Math.floor(now / bucketIntervalMs) * bucketIntervalMs;
1076
- }
1077
-
1078
- const tempBuf = store.graphTempBuf[type];
1079
-
1080
- // --- 3. ANTI-DROPOUT DINAMICO (Auto-scaling) ---
1081
- if ((type === 'tws' || type === 'aws') && value < 0.05 && tempBuf.length > 0) {
1082
- const lastPoint = tempBuf[tempBuf.length - 1];
1083
- const glitchThreshold = (CONFIG.graphs.reef1 || 15) * 0.5;
1084
- if (lastPoint && lastPoint.val > glitchThreshold) return;
1085
- }
1086
-
1087
- // --- 4. STORAGE TEMPORANEO ---
1088
- tempBuf.push({ val: value, time: now });
1089
-
1090
- // Controllo finestra temporale
1091
- const bucketReady = (now - store.lastUpdates[type] > bucketIntervalMs) || store.histories[type].length === 0;
1092
- if (!bucketReady) return;
1093
-
1094
- // --- 5. AGGREGAZIONE SEMANTICA (Ottimizzata a zero-allocazioni di array intermedi) ---
1095
- let finalValue = value;
1096
-
1097
- if (tempBuf.length > 0) {
1098
- // A. VENTO -> SUSTAINED PEAK (EMA Time-Aware) (0 allocazioni)
1099
- if (type === 'tws' || type === 'aws') {
1100
- const tauMs = 2500;
1101
- let ema = tempBuf[0].val;
1102
- let maxSustained = ema;
1103
-
1104
- for (let i = 1; i < tempBuf.length; i++) {
1105
- const dt = Math.max(1, tempBuf[i].time - tempBuf[i - 1].time);
1106
- const alpha = 1 - Math.exp(-dt / tauMs);
1107
- ema = (tempBuf[i].val * alpha) + (ema * (1 - alpha));
1108
- if (isFinite(ema) && ema > maxSustained) maxSustained = ema;
1109
- }
1110
- finalValue = maxSustained;
1111
- }
1112
- // B. PROFONDITÀ -> MINIMO (In-place loop, 0 allocazioni)
1113
- else if (type === 'depth') {
1114
- let minVal = Infinity;
1115
- for (let i = 0; i < tempBuf.length; i++) {
1116
- const v = tempBuf[i].val;
1117
- if (isFinite(v) && v < minVal) {
1118
- minVal = v;
1119
- }
1120
- }
1121
- if (minVal !== Infinity) {
1122
- finalValue = minVal;
1123
- }
1124
- }
1125
- // C. VELOCITÀ -> MEDIA (In-place loop, 0 allocazioni)
1126
- else {
1127
- let sum = 0;
1128
- let count = 0;
1129
- for (let i = 0; i < tempBuf.length; i++) {
1130
- const v = tempBuf[i].val;
1131
- if (isFinite(v)) {
1132
- sum += v;
1133
- count++;
1134
- }
1135
- }
1136
- if (count > 0) {
1137
- finalValue = sum / count;
1138
- }
1139
- }
1140
- }
1141
-
1142
- // --- 6. CLAMPING E VALIDAZIONE FINALE ---
1143
- if (!isFinite(finalValue)) return;
1144
- finalValue = Math.max(0, finalValue);
1145
-
1146
- // --- 7. STORAGE STORICO ---
1147
- store.histories[type].push({ val: finalValue, time: now });
1148
-
1149
- // --- TRIGGER STRATEGIA 1 (EVENT-DRIVEN) ---
1150
- // Ridisegnamo lo strumento SOLO nell'esatto istante in cui viene generato un nuovo punto storico!
1151
- refreshGraph(type);
1152
-
1153
- // --- 8. PRUNING DINAMICO ---
1154
- const maxViewportMinutes = historyMinutes * 2;
1155
- const maxHistoryMs = (maxViewportMinutes * 60000) + 60000;
1156
-
1157
- while (store.histories[type].length > 0 && (now - store.histories[type][0].time) > maxHistoryMs) {
1158
- store.histories[type].shift();
1159
- }
1160
-
1161
- // Reset per il prossimo bucket (sincronizzato)
1162
- store.graphTempBuf[type] = [];
1163
- store.lastUpdates[type] = Math.floor(now / bucketIntervalMs) * bucketIntervalMs;
1164
- }
1165
-
1166
- // ==========================================================================
1167
- // 9. INTERAZIONI E GESTI
1168
- // ==========================================================================
1169
- function toggleFocusMode(type, element) {
1170
- const container = document.querySelector('.main-container');
1171
- const isLeft = ['stw', 'sog', 'hdg', 'cog', 'tack'].includes(type);
1172
- isFocusActive = !isFocusActive;
1173
- if (isFocusActive) {
1174
- container.classList.add('focus-active', isLeft ? 'focus-side-left' : 'focus-side-right');
1175
- element.classList.add('is-focused');
1176
- } else {
1177
- container.classList.remove('focus-active', 'focus-side-left', 'focus-side-right');
1178
- document.querySelectorAll('.data-box').forEach(b => b.classList.remove('is-focused'));
1179
- }
1180
-
1181
- // --- FORZATURA REDRAW IMMEDIATO DUAL SCREEN ---
1182
- // Quando entriamo o usciamo dal Focus, ridisegnamo subito lo strumento
1183
- // interessato per applicare istantaneamente il cambio di spessore della linea!
1184
- if (['stw', 'sog', 'tws', 'depth'].includes(type)) {
1185
- refreshGraph(type);
1186
- }
1187
- }
1188
-
1189
- ['stw', 'sog', 'tws', 'depth'].forEach(type => {
1190
- const el = document.getElementById(type + '-graph').closest('.data-box');
1191
- let lastTapTime = 0, tapTimeout, isLongPressActive = false;
1192
-
1193
- el.addEventListener('pointerdown', (e) => {
1194
- isLongPressActive = false;
1195
- pressTimer = setTimeout(() => {
1196
- if (!isFocusActive) {
1197
- isLongPressActive = true;
1198
- toggleFocusMode(type, el);
1199
- lastTapTime = 0;
1200
- }
1201
- }, 1000);
1202
- });
1203
-
1204
- el.addEventListener('pointerup', (e) => {
1205
- clearTimeout(pressTimer);
1206
- if (isLongPressActive) return;
1207
-
1208
- const currentTime = new Date().getTime();
1209
- const tapDelay = currentTime - lastTapTime;
1210
-
1211
- if (tapDelay < 300 && tapDelay > 0) {
1212
- clearTimeout(tapTimeout);
1213
- graphModes[type] = (graphModes[type] === 'standard') ? 'hercules' : 'standard';
1214
- localStorage.setItem('mode_' + type, graphModes[type]);
1215
- refreshGraph(type);
1216
- lastTapTime = 0;
1217
- } else {
1218
- lastTapTime = currentTime;
1219
- tapTimeout = setTimeout(() => {
1220
- if (isFocusActive && el.classList.contains('is-focused')) {
1221
- toggleFocusMode(type, el);
1222
- } else if (!isFocusActive) {
1223
- if (type === 'sog') {
1224
- displayModeSog = (displayModeSog === 'SOG') ? 'VMG' : 'SOG';
1225
- refreshGraph('sog'); // --- FORZA RINFRESCO ISTANTANEO AL CAMBIO SOG/VMG ---
1226
- } else if (type === 'tws') {
1227
- displayModeTws = (displayModeTws === 'TWS') ? 'AWS' : 'TWS';
1228
- refreshGraph('tws'); // --- FORZA RINFRESCO ISTANTANEO AL CAMBIO TWS/AWS ---
1229
- }
1230
- el.style.backgroundColor = "rgba(0, 0, 0, 0.05)";
1231
- setTimeout(() => el.style.backgroundColor = "", 150);
1232
- }
1233
- }, 250);
1234
- }
1235
- });
1236
-
1237
- el.addEventListener('pointerleave', () => clearTimeout(pressTimer));
1238
- });
1239
-
1240
- if (ui.hotspot) {
1241
- 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); });
1242
- ui.hotspot.addEventListener('pointerup', (e) => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; const doc = document.documentElement; if (document.fullscreenElement) document.exitFullscreen(); else doc.requestFullscreen(); } });
1243
- ui.hotspot.addEventListener('pointerleave', () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } });
1244
- }
1245
-
1246
- // ==========================================================================
1247
- // 10. SIMULAZIONE E RETE
1248
- // ==========================================================================
1249
- function startDynamicSimulation() {
1250
- ui.status.innerText = "SIM ATTIVO";
1251
- let sim = { hdg: 45, tws: 12, twd: 90, depth: 12, stw: 5, startTime: Date.now() };
1252
- simInterval = setInterval(() => {
1253
- const elapsed = (Date.now() - sim.startTime) / 1000;
1254
- sim.twd = (sim.twd + (Math.sin(elapsed / 20) * 0.5) + 360) % 360;
1255
- const targetTws = 10 + Math.sin(elapsed / 10) * 2;
1256
- sim.tws += (targetTws - sim.tws) * 0.05; sim.hdg = (sim.hdg + (Math.random() - 0.5) * 1 + 360) % 360;
1257
- let twaRel = (sim.twd - sim.hdg + 360) % 360; if (twaRel > 180) twaRel -= 360;
1258
- let targetStw = 5; sim.stw += (targetStw - sim.stw) * 0.05;
1259
- processIncomingData("navigation.headingTrue", degToRad(sim.hdg));
1260
- processIncomingData("environment.wind.speedApparent", ktsToMs(sim.stw + 2));
1261
- processIncomingData("environment.wind.angleApparent", degToRad(twaRel + 5));
1262
- processIncomingData("environment.depth.belowTransducer", sim.depth);
1263
- processIncomingData("navigation.speedThroughWater", ktsToMs(sim.stw));
1264
- processIncomingData("navigation.speedOverGround", ktsToMs(sim.stw));
1265
- processIncomingData("navigation.courseOverGroundTrue", degToRad(sim.hdg));
1266
- }, 1000);
1267
- }
1268
-
1269
- function connect() {
1270
- if (simulationMode) return;
1271
-
1272
- // Pulizia preventiva del vecchio socket orfano (elimina memory leak ed eventi doppi)
1273
- if (socket) {
1274
- socket.onopen = null;
1275
- socket.onmessage = null;
1276
- socket.onerror = null;
1277
- socket.onclose = null;
1278
- try { socket.close(); } catch(e) {}
1279
- socket = null;
1280
- }
1281
-
1282
- let addr = window.location.host || CONFIG.server.fallbackIp;
1283
- try {
1284
- // Connessione con subscribe=none per impedire a Signal K l'invio automatico di tutto lo stream di bordo
1285
- socket = new WebSocket(`ws://${addr}/signalk/v1/stream?subscribe=none`);
1286
-
1287
- socket.onopen = async () => {
1288
- ui.status.className = "online";
1289
- ui.status.innerText = "ONLINE";
1290
- reconnectDelay = 1000;
1291
- lastMessageReceivedTime = Date.now(); // Reset timestamp dati
1292
-
1293
- // Sottoscrizione selettiva bilanciata: applichiamo un filtro a 3 Hz (333ms) per i dati rapidi,
1294
- // garantendo fluidità matematica senza ritardi e ottimizzando profondità (1s) e GPS (10s)
1295
- const subscriptionPayload = {
1296
- context: "vessels.self",
1297
- subscribe: [
1298
- { path: "navigation.position", minPeriod: 60000 }, // Posizione GPS lenta (60s)
1299
- { path: "navigation.magneticVariation", minPeriod: 60000 }, // Declinazione lenta (60s)
1300
- { path: "environment.depth.belowTransducer", minPeriod: 1000 }, // Profondità di sicurezza (1s)
1301
- { path: "navigation.speedThroughWater", minPeriod: 333 }, // Velocità barca a 3 Hz (333ms)
1302
- { path: "navigation.speedOverGround", minPeriod: 333 }, // Velocità GPS a 3 Hz
1303
- { path: "navigation.courseOverGroundTrue", minPeriod: 333 }, // COG a 3 Hz
1304
- { path: "navigation.headingTrue", minPeriod: 333 }, // Heading True a 3 Hz
1305
- { path: "navigation.headingMagnetic", minPeriod: 333 }, // Heading Fallback a 3 Hz
1306
- { path: "environment.wind.speedApparent", minPeriod: 333 }, // AWS a 3 Hz
1307
- { path: "environment.wind.angleApparent", minPeriod: 333 }, // AWA a 3 Hz
1308
- { path: "environment.wind.speedTrue", minPeriod: 333 }, // TWS (Nativo) a 3 Hz
1309
- { path: "environment.wind.angleTrueWater", minPeriod: 333 }, // TWA (Nativo) a 3 Hz (AGGIUNTO)
1310
- { path: "environment.wind.directionTrue", minPeriod: 333 } // TWD (Nativo) a 3 Hz
1311
- ]
1312
- };
1313
-
1314
- // Invio del payload per configurare lo streaming selettivo a 3 Hz
1315
- try {
1316
- socket.send(JSON.stringify(subscriptionPayload));
1317
- console.log("🔌 [WebSocket] Sottoscrizione selettiva a 3 Hz inviata con successo (Latenza azzerata).");
1318
- } catch (err) {
1319
- console.error("❌ [WebSocket] Impossibile inviare il payload di sottoscrizione:", err);
1320
- }
1321
-
1322
- // SINCRONIZZAZIONE AUTOMATICA: Ogni volta che la connessione si apre o si riapre,
1323
- // scarichiamo lo storico fresco dal server e ridisegnamo i grafici
1324
- try {
1325
- await fetchServerHistory();
1326
- ['stw', 'sog', 'depth', 'tws'].forEach(refreshGraph);
1327
- } catch (err) {
1328
- console.warn("⚠️ Sincronizzazione storico fallita alla connessione:", err);
1329
- }
1330
- };
1331
-
1332
- socket.onmessage = (e) => {
1333
- lastMessageReceivedTime = Date.now(); // Aggiorna il battito cardiaco del flusso dati
1334
- const d = JSON.parse(e.data);
1335
- if (d.updates) {
1336
- d.updates.forEach(u => {
1337
- // Sincronizzazione dell'orologio sul tempo reale del server (NMEA/GPS)
1338
- const timeMs = u.timestamp ? new Date(u.timestamp).getTime() : Date.now();
1339
- let sourceLabel = "Unknown";
1340
- if (u.$source) {
1341
- sourceLabel = u.$source;
1342
- } else if (u.source) {
1343
- if (typeof u.source === 'object') {
1344
- sourceLabel = u.source.label || u.source.talker || u.source.src || "Unknown";
1345
- } else {
1346
- sourceLabel = String(u.source);
1347
- }
1348
- }
1349
-
1350
- if (u.values) {
1351
- u.values.forEach(v => processIncomingData(v.path, v.value, sourceLabel, timeMs));
1352
- }
1353
- });
1354
- }
1355
- };
1356
-
1357
- socket.onclose = () => {
1358
- if (!simulationMode) {
1359
- ui.status.className = "offline";
1360
- ui.status.innerText = "OFFLINE";
1361
-
1362
- // Tentiamo la riconnessione automatica solo se lo schermo è attivo e visibile,
1363
- // evitando cicli di loop di rete infiniti in background mentre il tablet dorme
1364
- if (document.visibilityState === "visible") {
1365
- ui.status.innerText = "RECONNECTING...";
1366
- setTimeout(connect, reconnectDelay);
1367
- reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
1368
- }
1369
- }
1370
- };
1371
- } catch (e) {
1372
- setTimeout(connect, reconnectDelay);
1373
- }
1374
- }
1375
-
1376
- // ==========================================================================
1377
- // 11. INIT E CICLO DI VITA
1378
- // ==========================================================================
1379
- window.addEventListener('contextmenu', e => e.preventDefault(), true);
1380
-
1381
- async function init() {
1382
- loadDashboardState();
1383
-
1384
- // Disegna la grafica statica delle tacche di calibrazione di entrambi gli strumenti
1385
- initCompassTicks(); // Tacche del Wind Gauge analogico (gauge.js)
1386
- initRadarTicks(); // Tacche del Wind Radar storico (weather-radar.js)
1387
-
1388
- // 1. COMANDO TATTICO: Gestore Box TWD (Pressione prolungata -> Radar | Tocco rapido in modalità Radar -> Torna a Gauge)
1389
- const twdBox = document.querySelector('.box-twd');
1390
- if (twdBox) {
1391
- let twdPressTimer = null;
1392
- let longPressTriggered = false; // Flag di controllo della pressione prolungata
1393
-
1394
- twdBox.addEventListener('pointerdown', (e) => {
1395
- longPressTriggered = false;
1396
- if (activeInstrument === 'gauge') {
1397
- twdPressTimer = setTimeout(() => {
1398
- activeInstrument = 'radar';
1399
- document.getElementById('wind-gauge').style.display = 'none';
1400
- document.getElementById('wind-radar').style.display = 'block';
1401
- renderRadar(); // Disegna immediatamente il radar all'attivazione
1402
- twdPressTimer = null;
1403
- longPressTriggered = true; // Segnala che la transizione al radar è avvenuta con successo
1404
- }, 1000);
1405
- }
1406
- });
1407
- twdBox.addEventListener('pointerup', () => {
1408
- if (activeInstrument === 'gauge') {
1409
- if (twdPressTimer) {
1410
- clearTimeout(twdPressTimer);
1411
- twdPressTimer = null;
1412
- }
1413
- } else if (activeInstrument === 'radar') {
1414
- if (longPressTriggered) {
1415
- // Se l'evento di rilascio appartiene al tocco prolungato che ha appena attivato il radar, lo ignoriamo
1416
- longPressTriggered = false;
1417
- } else {
1418
- // Altrimenti è un tocco rapido indipendente: torna alla bussola analogica
1419
- activeInstrument = 'gauge';
1420
- document.getElementById('wind-radar').style.display = 'none';
1421
- document.getElementById('wind-gauge').style.display = 'block';
1422
- }
1423
- }
1424
- });
1425
- twdBox.addEventListener('pointerleave', () => {
1426
- if (twdPressTimer) {
1427
- clearTimeout(twdPressTimer);
1428
- twdPressTimer = null;
1429
- }
1430
- longPressTriggered = false;
1431
- });
1432
- }
1433
-
1434
- // 2. COMANDO TATTICO: Click in qualsiasi punto del radar per tornare all'analogico
1435
- const windRadarSvg = document.getElementById('wind-radar');
1436
- if (windRadarSvg) {
1437
- windRadarSvg.addEventListener('pointerup', () => {
1438
- if (activeInstrument === 'radar') {
1439
- activeInstrument = 'gauge';
1440
- windRadarSvg.style.display = 'none';
1441
- document.getElementById('wind-gauge').style.display = 'block';
1442
- }
1443
- });
1444
- }
1445
-
1446
- // Rileviamo se siamo sul Mac tramite file:// (Ambiente di sviluppo locale)
1447
- const isLocalFile = (window.location.protocol === 'file:');
1448
-
1449
- // 3. CARICAMENTO STORICO GRAFICI E RADAR REALI DAL CERBO GX
1450
- try {
1451
- await fetchServerHistory();
1452
- } catch (err) {
1453
- console.warn("⚠️ Impossibile caricare lo storico reale dal server.");
1454
- }
1455
-
1456
- // 4. CARICAMENTO CONFIGURAZIONI REALI (Bypassato su Mac per preservare i tuoi test!)
1457
- if (!isLocalFile) {
1458
- await fetchServerConfig();
1459
- } else {
1460
- console.log("🎮 Esecuzione locale file://: utilizzo delle calibrazioni di CONFIG locali di debug.");
1461
- }
1462
-
1463
- startDisplayLoop();
1464
- connect(); // Si collegherà in tempo reale al WebSocket reale della barca
1465
-
1466
- // 5. POLL LENTO (15 secondi): aggiorna i dati radar in background SOLO se lo strumento attivo è il radar,
1467
- // evitando di sovraccaricare la rete ed eliminando il salto visivo di reset dei grafici a 1Hz
1468
- setInterval(async () => {
1469
- try {
1470
- if (activeInstrument === 'radar') {
1471
- await fetchServerHistory();
1472
- renderRadar();
1473
- }
1474
- } catch (err) {
1475
- console.warn("⚠️ Errore aggiornamento periodico storico:", err);
1476
- }
1477
- }, 15000);
1478
-
1479
- // Controlla le modifiche di configurazione sul Cerbo solo se non siamo sul Mac via file://
1480
- if (!isLocalFile) {
1481
- setInterval(watchConfigChanges, 10000);
1482
- }
1483
- }
1484
-
1485
- // Gestore unificato di riattivazione su Android / iOS (Visibility, Focus, Pageshow, Online)
1486
- const handleWakeUp = () => {
1487
- if (!document.hidden) {
1488
- console.log("🔌 [Watchdog] Risveglio dispositivo / Tab attiva. Riconnessione immediata...");
1489
- reconnectDelay = 1000;
1490
- connect();
1491
- } else {
1492
- console.log("🔌 [Watchdog] Schermo bloccato / Tab in background. Chiusura preventiva WebSocket.");
1493
- if (socket) {
1494
- try { socket.close(); } catch(e) {}
1495
- }
1496
- }
1497
- };
1498
-
1499
- document.addEventListener('visibilitychange', handleWakeUp);
1500
- window.addEventListener('pageshow', handleWakeUp);
1501
- window.addEventListener('focus', handleWakeUp);
1502
- window.addEventListener('online', handleWakeUp);
1503
-
1504
- window.addEventListener('load', init);
1505
- window.addEventListener('pagehide', saveDashboardState);