@sailingrotevista/rotevista-dash 4.0.21 → 5.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 (4) hide show
  1. package/app.js +344 -311
  2. package/index.html +4 -1
  3. package/index.js +3 -3
  4. package/package.json +2 -2
package/app.js CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * ==========================================================================
3
- * Signal K Wind Dashboard - Pro Version 2.4 (Definitive)
3
+ * Signal K Wind Dashboard - Pro Version 3.5 (Time-Based Architecture)
4
4
  * ==========================================================================
5
5
  * Autore: Sailing Rotevista
6
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.
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).
9
10
  */
10
11
 
11
12
  // ==========================================================================
@@ -17,14 +18,14 @@ let CONFIG = {
17
18
  smoothWindow: 2000,
18
19
  longWindow: 30000,
19
20
  stabilityTolerance: 2000,
20
- stabilityThreshold: 0.95,
21
- minSpeed: 0,
21
+ stabilityThreshold: 0.99,
22
+ minSpeed: 0.5,
22
23
  stabilityBreakout: 15
23
24
  },
24
- graphs: { reef1: 8, reef2: 10.0, historyMinutes: 30, samples: 60 },
25
+ graphs: { reef1: 10, reef2: 15, historyMinutes: 10, samples: 60 },
25
26
  scales: {
26
- stw: { stdMax: 8, hercSpan: 4, step: 2 },
27
- sog: { stdMax: 8, hercSpan: 4, step: 2 },
27
+ stw: { stdMax: 4, hercSpan: 4, step: 2 },
28
+ sog: { stdMax: 4, hercSpan: 4, step: 2 },
28
29
  tws: { stdMax: 15, hercSpan: 10, step: 5 },
29
30
  depth: { stdMax: 8, hercSpan: 5, step: 5 }
30
31
  },
@@ -34,15 +35,15 @@ let CONFIG = {
34
35
  const RENDER_INTERVAL_MS = 1000;
35
36
  const TIMEOUT_MS = 5000;
36
37
  const SIM_SAMPLE_INTERVAL = 1000;
37
- const DASH_VERSION = "2.4"; // Versione per la gestione della memoria locale
38
+ const DASH_VERSION = "3.5"; // Major Update: Time-Based storage and GAP handling
38
39
  const sourceLocks = {};
39
40
 
40
-
41
41
  // ==========================================================================
42
42
  // 2. STATO GLOBALE E RIFERIMENTI UI
43
43
  // ==========================================================================
44
44
  let simulationMode = false;
45
- let displayModeSog = 'SOG'; // Può essere 'SOG' o 'VMG'
45
+ let displayModeSog = 'SOG';
46
+ let displayModeTws = 'TWS';
46
47
  let socket, renderInterval, simInterval;
47
48
  let lastAvgUIUpdate = 0, audioCtx = null, lastAlarmTime = 0;
48
49
  let curAwaRot = 0, curTwaRot = 0, curTrackRot = 0, curTwdRoseRot = 0;
@@ -59,7 +60,6 @@ let emaTwaSin = 0;
59
60
  let emaTwaCos = 0;
60
61
  let firstEmaRun = true;
61
62
 
62
- // Stato dei singoli grafici (Standard vs Hercules Zoom)
63
63
  const graphModes = {
64
64
  stw: 'standard',
65
65
  sog: 'standard',
@@ -73,10 +73,10 @@ const store = {
73
73
  timestamps: {},
74
74
  smoothBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
75
75
  longBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
76
- histories: { stw: [], sog: [], depth: [], tws: [], vmg: [] },
77
- // Buffer temporaneo per il calcolo della media dell'intervallo del grafico
78
- graphTempBuf: { stw: [], sog: [], depth: [], tws: [], vmg: [] },
79
- lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0 }
76
+ // histories ora conterrà array di oggetti: { time: Date.now(), val: numero }
77
+ histories: { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [] },
78
+ graphTempBuf: { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [] },
79
+ lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0, aws: 0 }
80
80
  };
81
81
 
82
82
  // Riferimenti agli elementi DOM mappati all'avvio
@@ -96,16 +96,13 @@ const ui = {
96
96
  };
97
97
 
98
98
  // ==========================================================================
99
- // 3. UTILITIES (MATEMATICA, BUFFER, AUDIO, MEMORIA)
99
+ // 3. UTILITIES (MATEMATICA, BUFFER E MEMORIA)
100
100
  // ==========================================================================
101
101
  function radToDeg(rad) { return rad * (180 / Math.PI); }
102
102
  function degToRad(deg) { return deg * (Math.PI / 180); }
103
103
  function msToKts(ms) { return ms * 1.94384; }
104
104
  function ktsToMs(kts) { return kts / 1.94384; }
105
105
 
106
- /**
107
- * Calcola il percorso più breve per la rotazione di un puntatore (evita giri di 360°)
108
- */
109
106
  function getShortestRotation(curr, target) {
110
107
  let diff = (target - curr) % 360;
111
108
  if (diff > 180) diff -= 360;
@@ -113,15 +110,10 @@ function getShortestRotation(curr, target) {
113
110
  return curr + diff;
114
111
  }
115
112
 
116
- /**
117
- * Inserisce un dato nel buffer circolare limitandolo a 2000 campioni (30 min)
118
- */
119
113
  /**
120
114
  * Inserimento sicuro nel buffer con trigonometria precaricata e pruning automatico
121
115
  */
122
116
  function safePush(buffer, val, time) {
123
- // --- PROTEZIONE ANTI-NaN (FONDAMENTALE ALL'AVVIO) ---
124
- // Se il valore è nullo, non definito o non è un numero, ignoriamo l'inserimento
125
117
  if (val === null || val === undefined || isNaN(val)) return;
126
118
 
127
119
  buffer.push({
@@ -131,21 +123,15 @@ function safePush(buffer, val, time) {
131
123
  cos: Math.cos(val)
132
124
  });
133
125
 
134
- // Teniamo sempre in memoria il DOPPIO della storia impostata (per la modalità ancoraggio)
135
- // + 1 minuto di margine
136
126
  const maxHistoryMs = (CONFIG.graphs.historyMinutes * 60000 * 2) + 60000;
137
-
138
127
  while (buffer.length > 0 && (time - buffer[0].time) > maxHistoryMs) {
139
128
  buffer.shift();
140
129
  }
141
-
142
- // Tetto massimo di campioni per sicurezza (circa 2 ore a 5Hz)
143
130
  if (buffer.length > 36000) buffer.shift();
144
131
  }
145
132
 
146
133
  /**
147
134
  * Media Circolare Vettoriale - Versione "Soft Outlier Rejection"
148
- * Riduce l'impatto degli sbalzi limitando il loro angolo massimo di discostamento.
149
135
  */
150
136
  function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false, now) {
151
137
  now = now || Date.now();
@@ -155,7 +141,6 @@ function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false, now
155
141
  let sSin = 0, sCos = 0, count = 0;
156
142
  let newestTime = 0, oldestTime = 0;
157
143
 
158
- // 1. MEDIA PILOTA: Guardiamo l'ultima frazione di dati (es. max 15 campioni) per sapere dove punta "ora"
159
144
  let pilotSin = 0, pilotCos = 0;
160
145
  const pilotSamples = Math.min(len, 15);
161
146
  for (let i = len - 1; i >= len - pilotSamples; i--) {
@@ -163,34 +148,21 @@ function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false, now
163
148
  pilotCos += bufferArray[i].cos;
164
149
  }
165
150
  const pilotRad = Math.atan2(pilotSin, pilotCos);
166
-
167
- // Il limite elastico in radianti (basato sul tuo stabilityBreakout in gradi)
168
151
  const limitRad = (CONFIG.averaging.stabilityBreakout || 15) * (Math.PI / 180);
169
152
 
170
- // 2. CALCOLO AMMORTIZZATO
171
153
  for (let i = len - 1; i >= 0; i--) {
172
154
  const item = bufferArray[i];
173
155
  if ((now - item.time) > windowMs) break;
174
156
 
175
- // Troviamo la differenza angolare (da -Pi a +Pi) tra il dato e la Media Pilota
176
- let diffRad = Math.atan2(
177
- Math.sin(item.val - pilotRad),
178
- Math.cos(item.val - pilotRad)
179
- );
180
-
157
+ let diffRad = Math.atan2(Math.sin(item.val - pilotRad), Math.cos(item.val - pilotRad));
181
158
  let finalSin, finalCos;
182
159
 
183
- // Se lo scarto è maggiore del limite, "Pattiniamo" (Ammortizzazione)
184
160
  if (Math.abs(diffRad) > limitRad) {
185
- // Tronchiamo la differenza al limite massimo consentito (mantenendo il segno)
186
161
  const clampedDiff = Math.sign(diffRad) * limitRad;
187
- // Ricalcoliamo l'angolo ammortizzato
188
162
  const clampedRad = pilotRad + clampedDiff;
189
-
190
163
  finalSin = Math.sin(clampedRad);
191
164
  finalCos = Math.cos(clampedRad);
192
165
  } else {
193
- // Il dato è buono, usiamo i valori precalcolati
194
166
  finalSin = item.sin;
195
167
  finalCos = item.cos;
196
168
  }
@@ -219,9 +191,6 @@ function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false, now
219
191
  };
220
192
  }
221
193
 
222
- /**
223
- * Salva lo stato attuale della dashboard (dati e preferenze UI) nel browser
224
- */
225
194
  function saveDashboardState() {
226
195
  try {
227
196
  const focusedBox = document.querySelector('.data-box.is-focused');
@@ -236,6 +205,7 @@ function saveDashboardState() {
236
205
  histories: store.histories,
237
206
  longBuf: store.longBuf,
238
207
  displayModeSog: displayModeSog,
208
+ displayModeTws: displayModeTws,
239
209
  graphModes: graphModes,
240
210
  isNightMode: document.body.classList.contains('night-mode'),
241
211
  isFocusActive: isFocusActive,
@@ -246,9 +216,6 @@ function saveDashboardState() {
246
216
  } catch (e) { console.error("Save error:", e); }
247
217
  }
248
218
 
249
- /**
250
- * Carica e ripristina lo stato salvato (entro i 20 minuti di vecchiaia)
251
- */
252
219
  function loadDashboardState() {
253
220
  const saved = localStorage.getItem('rotevista_dash_state');
254
221
  if (!saved) return;
@@ -261,24 +228,24 @@ function loadDashboardState() {
261
228
  if (state.longBuf) Object.assign(store.longBuf, state.longBuf);
262
229
  if (state.graphModes) Object.assign(graphModes, state.graphModes);
263
230
 
264
- // Ripristino SOG/VMG
265
231
  if (state.displayModeSog) {
266
232
  displayModeSog = state.displayModeSog;
267
233
  const labelEl = document.getElementById('sog-vmg-label');
268
234
  if (labelEl) labelEl.textContent = displayModeSog;
269
235
  }
270
-
271
- // Ripristino Tema Notte
236
+ if (state.displayModeTws) {
237
+ displayModeTws = state.displayModeTws;
238
+ const labelEl = document.getElementById('tws-aws-label');
239
+ if (labelEl) labelEl.textContent = displayModeTws;
240
+ }
272
241
  if (state.isNightMode) document.body.classList.add('night-mode');
273
242
 
274
- // Ripristino Focus (Dual Screen)
275
243
  if (state.isFocusActive && state.focusedBoxType) {
276
244
  setTimeout(() => {
277
245
  const el = document.querySelector(`.box-${state.focusedBoxType}`);
278
246
  if (el) { isFocusActive = false; toggleFocusMode(state.focusedBoxType, el); }
279
247
  }, 200);
280
248
  }
281
- console.log("Stato ripristinato con successo dalla cache.");
282
249
  }
283
250
  } catch (e) { localStorage.removeItem('rotevista_dash_state'); }
284
251
  }
@@ -314,13 +281,10 @@ function playGybeAlarm() {
314
281
  }
315
282
 
316
283
  function checkDepthAlarm(m) {
317
- // Rimuoviamo sempre le classi prima di riapplicarle
318
284
  ui.depth.classList.remove('alarm-warning', 'alarm-danger', 'blink-alarm');
319
-
320
- // Logica di confronto dinamica
321
285
  if (m < CONFIG.alarms.depthDanger) {
322
286
  ui.depth.classList.add('alarm-danger', 'blink-alarm');
323
- playBingBing(); // Il tuo suono di allarme
287
+ playBingBing();
324
288
  } else if (m < CONFIG.alarms.depthWarning) {
325
289
  ui.depth.classList.add('alarm-warning');
326
290
  }
@@ -341,11 +305,9 @@ function computeTrueWind() {
341
305
  const hdg = store.raw["navigation.headingTrue"] || 0, cog = store.raw["navigation.courseOverGroundTrue"] || 0;
342
306
  if (aws === undefined || awa === undefined) return;
343
307
 
344
- // Vento Reale Rispetto all'acqua (TWA/TWS Water)
345
308
  const tw_water_x = aws * Math.cos(awa) - stw, tw_water_y = aws * Math.sin(awa);
346
309
  const tws_water = Math.sqrt(tw_water_x * tw_water_x + tw_water_y * tw_water_y);
347
310
 
348
- // Vento Reale Rispetto al fondo (TWD Ground)
349
311
  const drift = (cog - hdg + Math.PI * 3) % (2 * Math.PI) - Math.PI;
350
312
  const tw_ground_x = aws * Math.cos(awa) - sog * Math.cos(drift), tw_ground_y = aws * Math.sin(awa) - sog * Math.sin(drift);
351
313
  const tws_ground = Math.sqrt(tw_ground_x * tw_ground_x + tw_ground_y * tw_ground_y);
@@ -367,49 +329,37 @@ function computeTrueWind() {
367
329
  function processIncomingData(path, val, source) {
368
330
  const now = Date.now();
369
331
 
370
- // --- 1. FILTRO SORGENTE STICKY (Elimina conflitti yacht_device vs Unknown) ---
332
+ // FILTRO SORGENTE STICKY
371
333
  if (!sourceLocks[path] || sourceLocks[path].label === source || (now - sourceLocks[path].lastSeen > 2000)) {
372
334
  sourceLocks[path] = { label: source, lastSeen: now };
373
335
  } else {
374
- // Se arriva un dato da un'altra sorgente mentre il lock è attivo, lo scartiamo
375
336
  return;
376
337
  }
377
338
 
378
- // --- 2. AGGIORNAMENTO DATI (Solo per la sorgente eletta) ---
379
339
  store.timestamps[path] = now;
380
340
  store.raw[path] = val;
381
341
 
382
- // Buffer per AWA (Vento Apparente)
383
342
  if (path === "environment.wind.angleApparent") {
384
343
  safePush(store.smoothBuf.awa, val, now);
385
344
  safePush(store.longBuf.awa, val, now);
386
345
  }
387
-
388
- // Buffer per HDG (Prua)
389
346
  if (path === "navigation.headingTrue") {
390
347
  safePush(store.smoothBuf.hdg, val, now);
391
348
  safePush(store.longBuf.hdg, val, now);
392
349
  }
393
-
394
- // Buffer per COG (Rotta Fondo)
395
350
  if (path === "navigation.courseOverGroundTrue") {
396
351
  safePush(store.smoothBuf.cog, val, now);
397
352
  safePush(store.longBuf.cog, val, now);
398
353
  }
399
354
 
400
- // --- 3. TRIGGER CALCOLO VENTO REALE (TWA/TWS/TWD) ---
401
355
  const twPaths = [
402
- "environment.wind.speedApparent",
403
- "environment.wind.angleApparent",
404
- "navigation.speedThroughWater",
405
- "navigation.speedOverGround",
406
- "navigation.headingTrue",
407
- "navigation.courseOverGroundTrue"
356
+ "environment.wind.speedApparent", "environment.wind.angleApparent",
357
+ "navigation.speedThroughWater", "navigation.speedOverGround",
358
+ "navigation.headingTrue", "navigation.courseOverGroundTrue"
408
359
  ];
409
360
 
410
361
  if (twPaths.includes(path)) {
411
362
  twDirty = true;
412
- // Calcolo limitato a 10Hz per non pesare sulla CPU
413
363
  if (twDirty && (now - lastTWCompute > 100)) {
414
364
  computeTrueWind();
415
365
  lastTWCompute = now;
@@ -423,20 +373,20 @@ function processIncomingData(path, val, source) {
423
373
  // ==========================================================================
424
374
  function updateWindTrend() {
425
375
  const now = Date.now();
426
- // TATTICA (Lancetta): 2s vs 10s (reazione rapida per trim)
427
376
  const twaNow = getCircularAverageFromBuffer(store.longBuf.twa, 2000, true);
428
377
  const twaRef = getCircularAverageFromBuffer(store.longBuf.twa, 10000, true);
429
378
 
430
- // STRATEGIA (Bussola TWD): 1 min vs 30 minuti (tendenza meteo profonda)
431
379
  const twdNow = getCircularAverageFromBuffer(store.longBuf.twd, 60000, false);
432
380
  const multiplier = isNavigating ? 1 : 2;
433
381
  const strategicWindowMs = CONFIG.graphs.historyMinutes * 60000 * multiplier;
434
382
  const twdRef = getCircularAverageFromBuffer(store.longBuf.twd, strategicWindowMs, false);
383
+
435
384
  if (!twaNow || !twaRef || !twdNow || !twdRef) return;
385
+
436
386
  const compassDots = { cw: document.getElementById('trend-dot-cw'), ccw: document.getElementById('trend-dot-ccw') };
437
387
  const gaugeDots = { cw: document.getElementById('trend-gauge-cw'), ccw: document.getElementById('trend-gauge-ccw') };
438
388
 
439
- // TREND METEO (Bussola Centrale TWD)
389
+ // TREND METEO
440
390
  let deltaMeteo = radToDeg((twdNow.val - twdRef.val + Math.PI * 3) % (Math.PI * 2) - Math.PI);
441
391
  if (Math.abs(deltaMeteo) > 6.0) {
442
392
  const isSouth = store.raw["navigation.position"]?.latitude < 0;
@@ -450,20 +400,17 @@ function updateWindTrend() {
450
400
  }
451
401
  } else { [compassDots.cw, compassDots.ccw].forEach(el => { if(el){ el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }}); }
452
402
 
453
- // TREND TATTICO (Lancetta Arancione)
403
+ // TREND TATTICO
454
404
  let deltaTac = radToDeg((twaNow.val - twaRef.val + Math.PI * 3) % (2 * Math.PI) - Math.PI);
455
405
  const curTwaDeg = radToDeg(twaNow.val);
456
406
  if (Math.abs(deltaTac) > 3.0) {
457
- // Calcolo logica tattica: LIFT (Verde), HEADER (Rosso) o NEUTRO (Grigio)
458
407
  let absTwa = Math.abs(curTwaDeg);
459
408
  let tacticColor;
460
-
461
- // Se siamo tra 75° e 105° (Traverso), il cambio è considerato neutro
462
409
  if (absTwa > 75 && absTwa < 105) {
463
- tacticColor = "#bbb"; // Grigio/Bianco sporco neutro
410
+ tacticColor = "#bbb";
464
411
  } else {
465
412
  let isLift = (curTwaDeg > 0) ? (deltaTac > 0) : (deltaTac < 0);
466
- if (absTwa >= 90) isLift = !isLift; // Inversione logica per andature portanti
413
+ if (absTwa >= 90) isLift = !isLift;
467
414
  tacticColor = isLift ? "#27ae60" : "#c0392b";
468
415
  }
469
416
  if (deltaTac > 0) {
@@ -475,65 +422,45 @@ function updateWindTrend() {
475
422
  }
476
423
  } else { [gaugeDots.cw, gaugeDots.ccw].forEach(el => { if(el){ el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }}); }
477
424
 
478
- // --- LOGICA ALLARME STRAMBATA (GYBE) FILTRATA ---
479
- const instTwaRad = store.raw["environment.wind.angleTrueWater"];
480
-
481
- if (instTwaRad !== undefined) {
482
- // Calcolo Alfa basato sulla Steering Precision (più precisione = filtro più lento e stabile)
483
- const dynamicAlpha = Math.max(0.05, 1.1 - CONFIG.averaging.stabilityThreshold);
425
+ // ALLARME STRAMBATA (EMA FILTERED)
426
+ const instTwaRad = store.raw["environment.wind.angleTrueWater"];
427
+ if (instTwaRad !== undefined) {
428
+ const dynamicAlpha = Math.max(0.05, 1.1 - CONFIG.averaging.stabilityThreshold);
429
+ const currentSin = Math.sin(instTwaRad);
430
+ const currentCos = Math.cos(instTwaRad);
484
431
 
485
- const currentSin = Math.sin(instTwaRad);
486
- const currentCos = Math.cos(instTwaRad);
487
-
488
- if (firstEmaRun) {
489
- emaTwaSin = currentSin;
490
- emaTwaCos = currentCos;
491
- firstEmaRun = false;
492
- } else {
493
- // Media Esponenziale Vettoriale
494
- emaTwaSin = (currentSin * dynamicAlpha) + (emaTwaSin * (1 - dynamicAlpha));
495
- emaTwaCos = (currentCos * dynamicAlpha) + (emaTwaCos * (1 - dynamicAlpha));
496
- }
432
+ if (firstEmaRun) {
433
+ emaTwaSin = currentSin; emaTwaCos = currentCos; firstEmaRun = false;
434
+ } else {
435
+ emaTwaSin = (currentSin * dynamicAlpha) + (emaTwaSin * (1 - dynamicAlpha));
436
+ emaTwaCos = (currentCos * dynamicAlpha) + (emaTwaCos * (1 - dynamicAlpha));
437
+ }
497
438
 
498
- // Angolo risultante filtrato
499
- const smoothedTwaDeg = radToDeg(Math.atan2(emaTwaSin, emaTwaCos));
439
+ const smoothedTwaDeg = radToDeg(Math.atan2(emaTwaSin, emaTwaCos));
500
440
 
501
- // Verifichiamo il cambio di mure (> 155° e inversione di segno confermata dal filtro)
502
- if (Math.abs(smoothedTwaDeg) > 155) {
503
- if (Math.sign(smoothedTwaDeg) !== Math.sign(lastInstantTwa) && lastInstantTwa !== null) {
504
- if (isNavigating && (now - lastGybeAlarmTime > 60000)) {
505
- lastGybeAlarmTime = now;
506
- playGybeAlarm();
507
- console.log(`⚠️ GYBE ALARM: TWA ${smoothedTwaDeg.toFixed(1)}°`);
508
- }
441
+ if (Math.abs(smoothedTwaDeg) > 155) {
442
+ if (Math.sign(smoothedTwaDeg) !== Math.sign(lastInstantTwa) && lastInstantTwa !== null) {
443
+ if (isNavigating && (now - lastGybeAlarmTime > 60000)) {
444
+ lastGybeAlarmTime = now;
445
+ playGybeAlarm();
446
+ console.log(`⚠️ GYBE ALARM: TWA ${smoothedTwaDeg.toFixed(1)}°`);
509
447
  }
510
448
  }
511
- lastInstantTwa = smoothedTwaDeg;
512
449
  }
450
+ lastInstantTwa = smoothedTwaDeg;
451
+ }
513
452
  }
514
453
 
515
454
  // ==========================================================================
516
455
  // 7. RENDERING ENGINE E AGGIORNAMENTO UI
517
456
  // ==========================================================================
518
- function refreshGraph(t) {
519
- const type = (t === 'vmg') ? 'sog' : t;
520
- const data = store.histories[t]; if (!data || data.length < 2) return;
521
- const mode = graphModes[type], cfg = calculateScale(type, data, mode);
522
-
523
- // Gestione visualizzazione Hercules Zoom (sfondo rosso)
524
- const box = document.querySelector(`.box-${type}`);
525
- if (box) box.classList.toggle('box-hercules', mode === 'hercules');
526
-
527
- updateScaleLabels(type, cfg.min, cfg.max);
528
- drawGraph(data, type + '-graph', cfg.min, cfg.max, t === 'tws', mode === 'hercules');
529
- }
530
457
 
531
458
  /**
532
- * upUI: Aggiornamento valori digitali con logica anti-ritardo (Istantaneo vs Media)
459
+ * upUI: Aggiornamento valori digitali
533
460
  */
534
461
  const upUI = (el, obj, instantRaw, isCompass = false) => {
535
462
  if (!obj || obj.val === null || isNaN(obj.val) || instantRaw === undefined) {
536
- el.innerHTML = "---&deg;";
463
+ el.innerHTML = "---&deg;";
537
464
  el.classList.remove('unstable-data');
538
465
  } else {
539
466
  let valDeg = Math.round(radToDeg(obj.val));
@@ -542,7 +469,6 @@ const upUI = (el, obj, instantRaw, isCompass = false) => {
542
469
  el.innerHTML = mainVal + dev;
543
470
 
544
471
  let diff = Math.abs((radToDeg(instantRaw) - radToDeg(obj.val) + 540) % 360 - 180);
545
- // Allarme lampeggio solo se in navigazione E (R bassa O deviazione alta O salto istantaneo brusco)
546
472
  if (isNavigating && (!obj.stable || obj.dev > CONFIG.averaging.stabilityBreakout || diff > CONFIG.averaging.stabilityBreakout)) el.classList.add('unstable-data');
547
473
  else el.classList.remove('unstable-data');
548
474
  }
@@ -551,102 +477,143 @@ const upUI = (el, obj, instantRaw, isCompass = false) => {
551
477
  /**
552
478
  * Loop principale di aggiornamento interfaccia (1Hz)
553
479
  */
554
- /**
555
- * Loop principale di aggiornamento interfaccia (1Hz)
556
- * Gestisce la gerarchia di aggiornamento Live (1s), Heavy (2s) e Slow (3s).
557
- */
558
480
  function startDisplayLoop() {
559
481
  renderInterval = setInterval(() => {
560
482
  const now = Date.now();
483
+ const isNight = document.body.classList.contains('night-mode');
484
+
561
485
  const stwKts = msToKts(store.raw["navigation.speedThroughWater"] || 0);
562
486
  const sogKts = msToKts(store.raw["navigation.speedOverGround"] || 0);
563
487
 
564
- // Verifica stato navigazione basato su soglia impostata
565
488
  isNavigating = stwKts > CONFIG.averaging.minSpeed || sogKts > CONFIG.averaging.minSpeed;
566
489
 
567
- // --- TIER LIVE (1s): CONTROLLO TIMEOUT DATI ---
568
- 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 };
490
+ // --- AGGIORNAMENTO STATUS CON CONTEGGIO MINUTI REALE ---
491
+ const viewportMinutes = CONFIG.graphs.historyMinutes * (isNavigating ? 1 : 2);
492
+ const requiredMs = viewportMinutes * 60000;
493
+ const oldestStw = store.histories.stw ? store.histories.stw[0] : null;
494
+
495
+ if (oldestStw) {
496
+ const availableMs = now - oldestStw.time;
497
+ if (availableMs >= requiredMs) {
498
+ ui.status.innerText = `ONLINE ${viewportMinutes}min`;
499
+ } else {
500
+ const availableMin = Math.max(1, Math.floor(availableMs / 60000));
501
+ ui.status.innerText = `ONLINE ${availableMin}/${viewportMinutes}min`;
502
+ }
503
+ } else {
504
+ ui.status.innerText = `ONLINE`;
505
+ }
506
+ ui.status.className = (socket && socket.readyState === WebSocket.OPEN) ? "online" : "offline";
507
+
508
+ // --- WATCHDOG: CONTROLLO TIMEOUT ---
509
+ const watch = {
510
+ "navigation.speedThroughWater": ui.stw, "navigation.speedOverGround": ui.sog,
511
+ "navigation.headingTrue": ui.hdg, "navigation.courseOverGroundTrue": ui.cog,
512
+ "environment.wind.speedApparent": ui.awsSvg, "environment.depth.belowTransducer": ui.depth,
513
+ "environment.wind.speedTrue": ui.tws
514
+ };
569
515
  for (let p in watch) {
570
516
  if (!store.timestamps[p] || (now - store.timestamps[p] > TIMEOUT_MS)) {
571
517
  watch[p].innerText = "---"; delete store.raw[p];
572
518
  }
573
519
  }
574
520
 
575
- // --- AGGIORNAMENTO DATI ISTANTANEI ---
521
+ // --- AGGIORNAMENTO VELOCITÀ SULL'ACQUA (STW) ---
576
522
  if (store.raw["navigation.speedThroughWater"] !== undefined) {
577
- ui.stw.innerText = stwKts.toFixed(1); manageHistory('stw', stwKts);
523
+ ui.stw.innerText = stwKts.toFixed(1);
524
+ ui.stw.style.color = ""; // Neutro
525
+ manageHistory('stw', stwKts);
578
526
  }
579
527
 
580
- // --- LOGICA SOG / VMG E COLORI DINAMICI ---
528
+ // --- LOGICA SOG / VMG ---
581
529
  if (store.raw["navigation.speedOverGround"] !== undefined) {
582
- const vmg = Math.abs(stwKts * Math.cos(store.raw["environment.wind.angleTrueWater"] || 0));
583
- manageHistory('vmg', vmg); manageHistory('sog', sogKts);
530
+ const vmgVal = Math.abs(stwKts * Math.cos(store.raw["environment.wind.angleTrueWater"] || 0));
531
+ manageHistory('vmg', vmgVal);
532
+ manageHistory('sog', sogKts);
584
533
 
585
- const labelEl = document.getElementById('sog-vmg-label');
534
+ const labelSogVmg = document.getElementById('sog-vmg-label');
586
535
  if (displayModeSog === 'VMG') {
587
- ui.sog.innerText = vmg.toFixed(1);
588
- // AGGIORNATO AL NUOVO COLORE CYAN VIBRANTE (#00b8d4)
589
- ui.sog.style.setProperty('color', '#00b8d4', 'important');
590
- if (labelEl) labelEl.textContent = 'VMG';
536
+ ui.sog.innerText = vmgVal.toFixed(1);
537
+ ui.sog.style.setProperty('color', '#00b8d4', 'important'); // Cyan
538
+ if (labelSogVmg) labelSogVmg.textContent = 'VMG';
591
539
  } else {
592
540
  ui.sog.innerText = sogKts.toFixed(1);
593
- if (labelEl) labelEl.textContent = 'SOG';
541
+ if (labelSogVmg) labelSogVmg.textContent = 'SOG';
594
542
 
595
- // Colore Corrente: Giallo Caldo per corrente a favore, Rosso per corrente contraria
596
- // (Allineiamo il colore della corrente a favore con il #ffbb33 usato nei grafici)
597
- if (sogKts - stwKts > 0.3) ui.sog.style.setProperty('color', '#ffbb33', 'important');
598
- else if (sogKts - stwKts < -0.3) ui.sog.style.setProperty('color', '#ff3b30', 'important'); // Rosso vivido
599
- else ui.sog.style.color = ""; // Torna normale
543
+ if (isNavigating) {
544
+ const lastSog = store.histories.sog.length > 0 ? store.histories.sog[store.histories.sog.length - 1].val : sogKts;
545
+ const lastStw = store.histories.stw.length > 0 ? store.histories.stw[store.histories.stw.length - 1].val : stwKts;
546
+ const drift = lastSog - lastStw;
547
+
548
+ if (drift < -0.3) ui.sog.style.setProperty('color', '#ff3b30', 'important'); // Contro
549
+ else if (drift > 0.3) ui.sog.style.setProperty('color', '#00C851', 'important'); // Favore
550
+ else ui.sog.style.setProperty('color', '#ffbb33', 'important'); // Neutro SOG
551
+ } else {
552
+ ui.sog.style.color = "";
553
+ }
600
554
  }
601
555
  }
602
556
 
557
+ // --- AGGIORNAMENTO PROFONDITÀ (DEPTH) ---
603
558
  if (store.raw["environment.depth.belowTransducer"] !== undefined) {
604
559
  ui.depth.innerText = store.raw["environment.depth.belowTransducer"].toFixed(1);
605
560
  checkDepthAlarm(store.raw["environment.depth.belowTransducer"]);
606
561
  manageHistory('depth', store.raw["environment.depth.belowTransducer"]);
607
562
  }
608
563
 
609
- if (store.raw["environment.wind.speedTrue"] !== undefined) {
610
- const twsKts = msToKts(store.raw["environment.wind.speedTrue"]);
611
- ui.tws.innerText = twsKts.toFixed(1);
564
+ // --- GESTIONE VENTO (TWS / AWS SWITCH) ---
565
+ const twsVal = store.raw["environment.wind.speedTrue"] ? msToKts(store.raw["environment.wind.speedTrue"]) : 0;
566
+ const awsVal = store.raw["environment.wind.speedApparent"] ? msToKts(store.raw["environment.wind.speedApparent"]) : 0;
567
+
568
+ if (store.raw["environment.wind.speedTrue"] !== undefined) manageHistory('tws', twsVal);
569
+ if (store.raw["environment.wind.speedApparent"] !== undefined) manageHistory('aws', awsVal);
570
+
571
+ if (store.raw["environment.wind.speedTrue"] !== undefined || store.raw["environment.wind.speedApparent"] !== undefined) {
572
+ const labelWind = document.getElementById('tws-aws-label');
573
+ const currentWind = (displayModeTws === 'AWS') ? awsVal : twsVal;
612
574
 
613
- // Colore Reef: se normale usiamo "", il CSS metterà Nero (giorno) o Rosso (notte)
614
- if (twsKts >= CONFIG.graphs.reef2) ui.tws.style.setProperty('color', '#e74c3c', 'important');
615
- else if (twsKts >= CONFIG.graphs.reef1) ui.tws.style.setProperty('color', '#e67e22', 'important');
616
- else ui.tws.style.color = "";
575
+ ui.tws.innerText = currentWind.toFixed(1);
576
+ if (labelWind) labelWind.textContent = displayModeTws;
617
577
 
618
- manageHistory('tws', twsKts);
578
+ if (currentWind >= CONFIG.graphs.reef2) {
579
+ ui.tws.style.setProperty('color', '#ff3b30', 'important');
580
+ } else if (currentWind >= CONFIG.graphs.reef1) {
581
+ ui.tws.style.setProperty('color', '#ff9800', 'important');
582
+ } else {
583
+ if (displayModeTws === 'AWS') {
584
+ ui.tws.style.setProperty('color', '#5c6bc0', 'important');
585
+ } else {
586
+ const navyNight = isNight ? '#6c8ea0' : '#2c3e50';
587
+ ui.tws.style.setProperty('color', navyNight, 'important');
588
+ }
589
+ }
619
590
  }
620
591
 
621
592
  if (store.raw["environment.wind.speedApparent"] !== undefined) {
622
- ui.awsSvg.textContent = msToKts(store.raw["environment.wind.speedApparent"]).toFixed(1);
593
+ ui.awsSvg.textContent = awsVal.toFixed(1);
623
594
  }
624
595
 
625
- // --- PUNTATORI ANALOGICI (Smoothing 2s) ---
596
+ // --- PUNTATORI ANALOGICI ---
626
597
  const smAwa = getCircularAverageFromBuffer(store.smoothBuf.awa, 2000, true);
627
598
  const smTwa = getCircularAverageFromBuffer(store.smoothBuf.twa, 2000, true);
628
- if (smAwa) { curAwaRot = getShortestRotation(curAwaRot, radToDeg(smAwa.val)); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
629
- if (smTwa) { curTwaRot = getShortestRotation(curTwaRot, radToDeg(smTwa.val)); ui.twa.setAttribute('transform', `rotate(${curTwaRot}, 200, 200)`); }
599
+ if (smAwa) ui.awa.setAttribute('transform', `rotate(${curAwaRot = getShortestRotation(curAwaRot, radToDeg(smAwa.val))}, 200, 200)`);
600
+ if (smTwa) ui.twa.setAttribute('transform', `rotate(${curTwaRot = getShortestRotation(curTwaRot, radToDeg(smTwa.val))}, 200, 200)`);
630
601
 
631
- // --- CALCOLO LEEWAY E TRACK POINTER ---
632
602
  if (store.raw["navigation.courseOverGroundTrue"] !== undefined && store.raw["navigation.headingTrue"] !== undefined) {
633
603
  let driftDeg = radToDeg((store.raw["navigation.courseOverGroundTrue"] - store.raw["navigation.headingTrue"] + Math.PI * 3) % (Math.PI * 2) - Math.PI);
634
- // Azzeramento sotto soglia minima impostata
635
604
  smoothedLeeway = (sogKts < CONFIG.averaging.minSpeed) ? 0 : (smoothedLeeway * 0.9) + (driftDeg * 0.1);
636
- curTrackRot = getShortestRotation(curTrackRot, smoothedLeeway); ui.track.setAttribute('transform', `rotate(${curTrackRot}, 200, 200)`);
637
- ui.leewayVal.style.color = (Math.abs(sogKts - stwKts) > 0.5 && Math.abs(smoothedLeeway) > 7) ? "#e67e22" : "";
605
+ curTrackRot = getShortestRotation(curTrackRot, smoothedLeeway);
606
+ ui.track.setAttribute('transform', `rotate(${curTrackRot}, 200, 200)`);
607
+ ui.leewayVal.style.color = (Math.abs(sogKts - stwKts) > 0.5 && Math.abs(smoothedLeeway) > 7) ? "#ff9800" : "";
638
608
  updateLeewayDisplay(Math.max(-20, Math.min(20, smoothedLeeway)));
639
609
  }
640
610
 
641
- updateWindTrend();
642
-
643
- // TIER HEAVY (2s) - Grafici e Persistenza Browser
611
+ // --- HEAVY TIER (2s) E SLOW TIER (3s) ---
644
612
  if (lastAvgUIUpdate++ % 2 === 0) {
645
613
  ['stw', 'sog', 'depth', 'tws'].forEach(refreshGraph);
646
614
  saveDashboardState();
647
615
  }
648
616
 
649
- // TIER SLOW (3s) - Medie Lunghe e Calcolo TACK
650
617
  if (lastAvgUIUpdate % 3 === 0) {
651
618
  let hObj = getCircularAverageFromBuffer(store.longBuf.hdg, CONFIG.averaging.longWindow * 2, false);
652
619
  let cObj = getCircularAverageFromBuffer(store.longBuf.cog, CONFIG.averaging.longWindow, false);
@@ -660,50 +627,39 @@ function startDisplayLoop() {
660
627
  upUI(ui.twaAvg, twObj, store.raw["environment.wind.angleTrueWater"], false);
661
628
  upUI(ui.twdAvg, twdObj, store.raw["environment.wind.directionTrue"], true);
662
629
 
663
- // --- LOGICA TACK STRATEGICA (VETTORIALE) ---
664
630
  if (hObj && twdObj) {
665
- // Funzione interna per riflettere un angolo rispetto all'asse del vento (TWD)
666
631
  const reflectAngle = (targetRad, axisRad) => {
667
- const diffSin = Math.sin(axisRad - targetRad);
668
- const diffCos = Math.cos(axisRad - targetRad);
669
- return Math.atan2(Math.sin(axisRad) * diffCos + Math.cos(axisRad) * diffSin,
670
- Math.cos(axisRad) * diffCos - Math.sin(axisRad) * diffSin);
632
+ const dS = Math.sin(axisRad - targetRad);
633
+ const dC = Math.cos(axisRad - targetRad);
634
+ return Math.atan2(Math.sin(axisRad) * dC + Math.cos(axisRad) * dS, Math.cos(axisRad) * dC - Math.sin(axisRad) * dS);
671
635
  };
672
-
673
636
  const unstableH = !hObj.stable || !twdObj.stable || hObj.dev > CONFIG.averaging.stabilityBreakout;
674
-
675
- if (!isNavigating) {
676
- ui.tackHdg.innerHTML = "---&deg;";
677
- } else if (unstableH) {
678
- ui.tackHdg.innerHTML = "---&deg;"; ui.tackHdg.classList.add('unstable-data');
679
- } else {
680
- const reflectedH = reflectAngle(hObj.val, twdObj.val);
681
- const outH = (radToDeg(reflectedH) + 360) % 360;
682
- ui.tackHdg.innerHTML = `${Math.round(outH).toString().padStart(3, '0')}&deg;`;
637
+ if (!isNavigating) ui.tackHdg.innerHTML = "---&deg;";
638
+ else if (unstableH) { ui.tackHdg.innerHTML = "---&deg;"; ui.tackHdg.classList.add('unstable-data'); }
639
+ else {
640
+ const rH = (radToDeg(reflectAngle(hObj.val, twdObj.val)) + 360) % 360;
641
+ ui.tackHdg.innerHTML = `${Math.round(rH).toString().padStart(3, '0')}&deg;`;
683
642
  ui.tackHdg.classList.remove('unstable-data');
684
643
  }
685
-
686
644
  if (cObj) {
687
645
  const unstableC = !cObj.stable || !twdObj.stable || cObj.dev > CONFIG.averaging.stabilityBreakout;
688
- if (!isNavigating) {
689
- ui.tackCog.innerHTML = "---&deg;";
690
- } else if (unstableC) {
691
- ui.tackCog.innerHTML = "---&deg;"; ui.tackCog.classList.add('unstable-data');
692
- } else {
693
- const reflectedC = reflectAngle(cObj.val, twdObj.val);
694
- const outC = (radToDeg(reflectedC) + 360) % 360;
695
- ui.tackCog.innerHTML = `${Math.round(outC).toString().padStart(3, '0')}&deg;`;
646
+ if (!isNavigating) ui.tackCog.innerHTML = "---&deg;";
647
+ else if (unstableC) { ui.tackCog.innerHTML = "---&deg;"; ui.tackCog.classList.add('unstable-data'); }
648
+ else {
649
+ const rC = (radToDeg(reflectAngle(cObj.val, twdObj.val)) + 360) % 360;
650
+ ui.tackCog.innerHTML = `${Math.round(rC).toString().padStart(3, '0')}&deg;`;
696
651
  ui.tackCog.classList.remove('unstable-data');
697
652
  }
698
653
  }
699
654
  }
700
655
 
701
- // Rotazione Mini-Bussole
702
656
  const smHdgIcons = getCircularAverageFromBuffer(store.smoothBuf.hdg, 2000, false);
703
657
  const smTwdIcons = getCircularAverageFromBuffer(store.smoothBuf.twd, 2000, false);
704
658
  if (smHdgIcons && smTwdIcons) {
705
- curWindCompassRot = getShortestRotation(curWindCompassRot, radToDeg(smTwdIcons.val)); ui.twdArrow.setAttribute('transform', `rotate(${curWindCompassRot}, 20, 20)`);
706
- curBoatCompassRot = getShortestRotation(curBoatCompassRot, radToDeg(smHdgIcons.val)); ui.twdBoat.setAttribute('transform', `rotate(${curBoatCompassRot}, 20, 20)`);
659
+ curWindCompassRot = getShortestRotation(curWindCompassRot, radToDeg(smTwdIcons.val));
660
+ ui.twdArrow.setAttribute('transform', `rotate(${curWindCompassRot}, 20, 20)`);
661
+ curBoatCompassRot = getShortestRotation(curBoatCompassRot, radToDeg(smHdgIcons.val));
662
+ ui.twdBoat.setAttribute('transform', `rotate(${curBoatCompassRot}, 20, 20)`);
707
663
  }
708
664
  }
709
665
  }, RENDER_INTERVAL_MS);
@@ -712,53 +668,57 @@ function startDisplayLoop() {
712
668
  // ==========================================================================
713
669
  // 8. CONFIGURAZIONE E GRAFICI UTILS
714
670
  // ==========================================================================
715
- /**
716
- * Recupera la configurazione dal server e applica migrazioni automatiche per le vecchie versioni
717
- */
718
671
  async function fetchServerConfig() {
719
672
  try {
720
673
  const response = await fetch('/rotevista-config');
721
674
  if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
722
675
  const data = await response.json();
723
676
 
724
- // Stampa di debug per verificare cosa riceve il client
725
- console.log("🔍 Configurazione ricevuta dal Server:", data);
726
-
727
- // Merge intelligente dei dati ricevuti
728
677
  Object.assign(CONFIG.alarms, data.alarms || {});
729
678
  Object.assign(CONFIG.graphs, data.graphs || {});
730
679
  Object.assign(CONFIG.averaging, data.averaging || {});
731
680
 
732
- // --- LOGICA DI MIGRAZIONE SILENZIOSA ---
733
- // Se il valore ricevuto è il vecchio default (0.85) o inferiore, lo portiamo al nuovo standard 0.95.
734
- // Questo è necessario perché con i nuovi filtri "Soft" lo 0.85 non farebbe quasi mai lampeggiare gli allarmi.
735
681
  if (CONFIG.averaging.stabilityThreshold <= 0.85) {
736
682
  CONFIG.averaging.stabilityThreshold = 0.95;
737
683
  console.log("♻️ Migrazione Silenziosa: Rilevato vecchio parametro stabilità (<= 0.85). Aggiornato a 0.95 per ottimizzazione filtri.");
738
684
  }
739
685
 
740
- // Per le scale, siccome sono nidificate, facciamo un loop di merge profondo
741
686
  if (data.scales) {
742
687
  for (let key in data.scales) {
743
688
  if (CONFIG.scales[key]) Object.assign(CONFIG.scales[key], data.scales[key]);
744
689
  }
745
690
  }
746
-
747
- console.log("✅ Configurazione applicata. Stabilità attiva:", CONFIG.averaging.stabilityThreshold);
748
691
  } catch (err) {
749
692
  console.warn("⚠️ Utilizzo default locali. Motivo:", err.message);
750
693
  }
751
694
  }
752
695
 
696
+ /**
697
+ * Rielaborazione Storica Time-Based (Rimuove accumulo e gestisce il Pruning dinamicamente)
698
+ */
753
699
  function manageHistory(t, v) {
754
- const n = Date.now(), interval = (CONFIG.graphs.historyMinutes * 60000) / CONFIG.graphs.samples;
700
+ const n = Date.now();
701
+ const interval = (CONFIG.graphs.historyMinutes * 60000) / CONFIG.graphs.samples;
702
+
755
703
  if (!store.graphTempBuf[t]) store.graphTempBuf[t] = [];
756
704
  store.graphTempBuf[t].push(v);
705
+
757
706
  if (n - store.lastUpdates[t] > interval || store.histories[t].length === 0) {
758
707
  const avg = store.graphTempBuf[t].reduce((a, b) => a + b, 0) / store.graphTempBuf[t].length;
759
- store.histories[t].push(avg);
760
- if (store.histories[t].length > CONFIG.graphs.samples) store.histories[t].shift();
761
- store.graphTempBuf[t] = []; store.lastUpdates[t] = n;
708
+
709
+ // NUOVO STORAGE TIME-BASED
710
+ store.histories[t].push({ time: n, val: avg });
711
+
712
+ // PRUNING DINAMICO
713
+ const maxViewportMinutes = CONFIG.graphs.historyMinutes * 2;
714
+ const maxHistoryMs = (maxViewportMinutes * 60000) + 30000;
715
+
716
+ while (store.histories[t].length > 0 && (n - store.histories[t][0].time) > maxHistoryMs) {
717
+ store.histories[t].shift();
718
+ }
719
+
720
+ store.graphTempBuf[t] = [];
721
+ store.lastUpdates[t] = n;
762
722
  }
763
723
  }
764
724
 
@@ -778,12 +738,36 @@ function updateScaleLabels(t, min, max) {
778
738
  }
779
739
 
780
740
  /**
781
- * drawGraph: Disegna i grafici con griglia temporale intelligente
782
- * Usa un Gradiente Lineare SVG dinamico per eliminare le giunzioni dei poligoni.
741
+ * refreshGraph: Recupero dati, switch AWS/TWS e passaggio a motore grafico.
783
742
  */
743
+ function refreshGraph(t) {
744
+ const boxType = (t === 'vmg') ? 'sog' : t;
745
+ let rawData;
746
+
747
+ if (t === 'tws' && displayModeTws === 'AWS') {
748
+ rawData = store.histories['aws'];
749
+ } else {
750
+ rawData = store.histories[t];
751
+ }
752
+
753
+ if (!rawData || rawData.length < 2) return;
754
+
755
+ // ESTRAZIONE SOLO VALORI NUMERICI per calculateScale()
756
+ const values = rawData.map(p => p.val);
757
+ const mode = graphModes[boxType];
758
+ const cfg = calculateScale(boxType, values, mode);
759
+
760
+ const box = document.querySelector(`.box-${boxType}`);
761
+ if (box) box.classList.toggle('box-hercules', mode === 'hercules');
762
+
763
+ updateScaleLabels(boxType, cfg.min, cfg.max);
764
+
765
+ // Passiamo tutto l'array (oggetti) al nuovo motore
766
+ drawGraph(rawData, boxType + '-graph', cfg.min, cfg.max, t === 'tws', mode === 'hercules');
767
+ }
768
+
784
769
  /**
785
- * drawGraph: Disegna i grafici con griglia temporale intelligente
786
- * Versione bilanciata: 1.5px per allarmi critici, 1px per il resto.
770
+ * drawGraph: Motore SVG con Timeline Reale e Gestione GAP
787
771
  */
788
772
  function drawGraph(d, id, min, max, isTws, isHercules) {
789
773
  const svg = document.getElementById(id);
@@ -792,96 +776,112 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
792
776
  const w = 200, h = 40;
793
777
  const range = max - min || 1;
794
778
  const isDepth = (id === 'depth-graph');
795
- const samples = CONFIG.graphs.samples;
796
-
797
- // 1. Griglia (Sottile 0.5px)
798
- let grids = "";
799
- [0.25, 0.5, 0.75].forEach(p => {
800
- 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" />`;
801
- });
802
- const gridInterval = (CONFIG.graphs.historyMinutes <= 15) ? 1 : 5;
803
- for (let m = gridInterval; m < CONFIG.graphs.historyMinutes; m += gridInterval) {
804
- const x = w - (m / CONFIG.graphs.historyMinutes) * w;
805
- grids += `<line x1="${x}" y1="0" x2="${x}" y2="${h}" stroke="rgba(0,0,0,0.08)" stroke-width="0.5" />`;
806
- }
779
+ const now = Date.now();
807
780
 
808
- // 2. Tavolozza Colori Vividi
809
- const baseColorTws = "#2c3e50";
810
- const baseColorDepth = "#0088cc";
811
-
812
- const getColor = (val) => {
813
- if (isTws) return (val >= CONFIG.graphs.reef2) ? "#ff3b30" : (val >= CONFIG.graphs.reef1 ? "#ff9800" : baseColorTws);
814
- if (isDepth) return (val < CONFIG.alarms.depthDanger) ? "#ff3b30" : (val < CONFIG.alarms.depthWarning ? "#ff9800" : baseColorDepth);
815
- switch(id) {
816
- case 'stw-graph': return "#00C851";
817
- case 'sog-graph': return (displayModeSog === 'VMG') ? "#00b8d4" : "#ffbb33";
818
- default: return baseColorTws;
781
+ // VIEWPORT TEMPORALE DINAMICO
782
+ const visibleMinutes = CONFIG.graphs.historyMinutes * (isNavigating ? 1 : 2);
783
+ const viewportMs = visibleMinutes * 60000;
784
+ const viewportStart = now - viewportMs;
785
+
786
+ // FILTRO DATI VISIBILI (Gestisce Compressione/Zoom in modo naturale)
787
+ const visibleData = d.filter(p => p.time >= viewportStart);
788
+ if (visibleData.length < 2) return;
789
+
790
+ // COLORI
791
+ const colDanger = "#ff3b30", colWarning = "#ff9800", colTws = "#2c3e50", colAws = "#5c6bc0";
792
+ const colDepth = "#0088cc", colStw = "#00C851", colSog = "#ffbb33", colVmg = "#00b8d4";
793
+
794
+ const getColorProps = (val) => {
795
+ let color = colTws, opacity = "0.15", stroke = "1";
796
+ if (isTws) {
797
+ const baseWind = (displayModeTws === 'AWS') ? colAws : colTws;
798
+ if (val >= CONFIG.graphs.reef2) { color = colDanger; opacity = "0.55"; stroke = "1.2"; }
799
+ else if (val >= CONFIG.graphs.reef1) { color = colWarning; opacity = "0.45"; stroke = "1"; }
800
+ else color = baseWind;
801
+ } else if (isDepth) {
802
+ if (val < CONFIG.alarms.depthDanger) { color = colDanger; opacity = "0.55"; stroke = "1.2"; }
803
+ else if (val < CONFIG.alarms.depthWarning) { color = colWarning; opacity = "0.45"; stroke = "1"; }
804
+ else color = colDepth;
805
+ } else {
806
+ if (id === 'stw-graph') color = colStw;
807
+ else if (id === 'sog-graph') color = (displayModeSog === 'VMG') ? colVmg : colSog;
819
808
  }
809
+ return { color, opacity, stroke };
820
810
  };
821
811
 
822
- // 3. Creazione Gradiente e Linee
823
- let gradientStops = "";
824
- let lines = "";
825
- let areaPath = `M 0 ${h} `;
826
-
827
- for (let i = 1; i < d.length; i++) {
828
- const percentPrev = ((i - 1) / (samples - 1)) * 100;
829
- const percentCurr = (i / (samples - 1)) * 100;
830
-
831
- const x1 = ((i - 1) / (samples - 1)) * w;
832
- const y1 = h - (Math.max(0, Math.min(1, (d[i - 1] - min) / range)) * h);
833
- const x2 = (i / (samples - 1)) * w;
834
- const y2 = h - (Math.max(0, Math.min(1, (d[i] - min) / range)) * h);
835
-
836
- const color = getColor(d[i]);
837
-
838
- // Logica Opacità e Spessore (Tua configurazione)
839
- let fillOpacity = "0.15";
840
- let strokeWidth = "1";
841
-
842
- if (color === "#ff3b30") {
843
- fillOpacity = "0.85"; // Rosso: Molto visibile
844
- strokeWidth = "1.5"; // Rosso: Più marcato
845
- } else if (color === "#ff9800") {
846
- fillOpacity = "0.45"; // Arancio: Velo medio
847
- strokeWidth = "1"; // Arancio: Sottile
848
- }
812
+ // GRIGLIA ORIZZONTALE
813
+ let grids = "";
814
+ [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" />`);
849
815
 
850
- // Costruzione Gradiente (stacchi netti tra i colori)
851
- gradientStops += `<stop offset="${percentPrev}%" stop-color="${color}" stop-opacity="${fillOpacity}" />`;
852
- gradientStops += `<stop offset="${percentCurr}%" stop-color="${color}" stop-opacity="${fillOpacity}" />`;
816
+ // GRIGLIA VERTICALE VERA
817
+ const gridInterval = (visibleMinutes <= 15) ? 1 : 5;
818
+ for (let m = gridInterval; m < visibleMinutes; m += gridInterval) {
819
+ const x = w - ((m / visibleMinutes) * w);
820
+ grids += `<line x1="${x}" y1="0" x2="${x}" y2="${h}" stroke="rgba(0,0,0,0.08)" stroke-width="0.5" />`;
821
+ }
853
822
 
854
- // Disegno Linea con precisione geometrica
855
- lines += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"
856
- style="stroke: ${color}; stroke-width: ${strokeWidth}; stroke-linecap: round; shape-rendering: geometricPrecision;" />`;
857
-
858
- if (i === 1) areaPath += `L ${x1} ${y1} `;
859
- areaPath += `L ${x2} ${y2} `;
823
+ // RENDER TIME-BASED
824
+ let gradientStops = "", lines = "", areaPath = "";
825
+ let started = false;
826
+
827
+ for (let i = 1; i < visibleData.length; i++) {
828
+ const pA = visibleData[i - 1];
829
+ const pB = visibleData[i];
830
+
831
+ // POSIZIONE X ESATTA AL MILLISECONDO
832
+ const x1 = ((pA.time - viewportStart) / viewportMs) * w;
833
+ const x2 = ((pB.time - viewportStart) / viewportMs) * w;
834
+ const y1 = h - (Math.max(0, Math.min(1, (pA.val - min) / range)) * h);
835
+ const y2 = h - (Math.max(0, Math.min(1, (pB.val - min) / range)) * h);
836
+
837
+ const props = getColorProps(pB.val);
838
+
839
+ // GESTIONE GAP TEMPORALI (Network loss)
840
+ const deltaTime = pB.time - pA.time;
841
+ const expectedInterval = viewportMs / CONFIG.graphs.samples;
842
+ const isGap = deltaTime > (expectedInterval * 2.5);
843
+
844
+ // STOPS GRADIENTE
845
+ const offset1 = (x1 / w) * 100, offset2 = (x2 / w) * 100;
846
+ gradientStops += `<stop offset="${offset1}%" stop-color="${props.color}" stop-opacity="${props.opacity}" />`;
847
+ gradientStops += `<stop offset="${offset2}%" stop-color="${props.color}" stop-opacity="${props.opacity}" />`;
848
+
849
+ // --- FIX: GESTIONE AREA E LINEE DURANTE I GAP ---
850
+ if (isGap) {
851
+ // Se c'è un buco nei dati e avevamo già iniziato a disegnare...
852
+ if (started) {
853
+ // Chiudiamo il pezzo di area precedente scendendo verticalmente (Z non serve qui)
854
+ areaPath += `L ${x1} ${h} `;
855
+ started = false; // Resettiamo il flag per far ripartire l'area al prossimo punto
856
+ }
857
+ } else {
858
+ // I dati sono continui, disegniamo la linea superiore
859
+ lines += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" style="stroke:${props.color}; stroke-width:${props.stroke}; stroke-linecap:round; shape-rendering:geometricPrecision;" />`;
860
860
 
861
- // Salviamo l'ultima coordinata X calcolata per chiudere correttamente il path
862
- if (i === d.length - 1) {
863
- areaPath += `L ${x2} ${h} Z`;
861
+ // Gestione Area
862
+ if (!started) {
863
+ // Iniziamo un nuovo pezzo di area dal fondo, saliamo a y1
864
+ areaPath += `M ${Math.max(0, x1)} ${h} L ${Math.max(0, x1)} ${y1} `;
865
+ started = true;
864
866
  }
867
+ // Aggiungiamo il punto attuale
868
+ areaPath += `L ${x2} ${y2} `;
865
869
  }
870
+ }
866
871
 
867
- // 4. Iniezione del Gradiente
868
- const gradId = `grad-${id}`;
869
- const defs = `
870
- <defs>
871
- <linearGradient id="${gradId}" x1="0%" y1="0%" x2="100%" y2="0%">
872
- ${gradientStops}
873
- </linearGradient>
874
- </defs>
875
- `;
876
-
877
- // 5. Render Finale
878
- if (isTws || isDepth) {
879
- svg.innerHTML = `${defs}${grids}<path d="${areaPath}" fill="url(#${gradId})" stroke="none" />${lines}`;
880
- } else {
881
- // Per STW/SOG usiamo il colore dell'ultimo punto con area fissa 0.15
882
- const currentPathColor = getColor(d[d.length - 1]);
883
- svg.innerHTML = `${grids}<path d="${areaPath}" fill="${currentPathColor}" fill-opacity="0.15" stroke="none" />${lines}`;
872
+ // CHIUSURA FINALE DELL'AREA (Solo se non siamo finiti dentro un gap)
873
+ if (started) {
874
+ const last = visibleData[visibleData.length - 1];
875
+ const lastX = ((last.time - viewportStart) / viewportMs) * w;
876
+ areaPath += `L ${lastX} ${h} Z`; // Z chiude automaticamente il path tornando al punto 'M' iniziale
884
877
  }
878
+
879
+ // GRADIENTE
880
+ const gradId = `grad-${id}`;
881
+ const defs = `<defs><linearGradient id="${gradId}" x1="0" y1="0" x2="${w}" y2="0" gradientUnits="userSpaceOnUse">${gradientStops}</linearGradient></defs>`;
882
+
883
+ // RENDER FINALE
884
+ svg.innerHTML = `${defs}${grids}<path d="${areaPath}" fill="url(#${gradId})" stroke="none" />${lines}`;
885
885
  }
886
886
 
887
887
  // ==========================================================================
@@ -898,13 +898,49 @@ function toggleFocusMode(type, element) {
898
898
  ['stw', 'sog', 'tws', 'depth'].forEach(type => {
899
899
  const el = document.getElementById(type + '-graph').closest('.data-box');
900
900
  let lastTapTime = 0, tapTimeout, isLongPressActive = false;
901
- el.addEventListener('pointerdown', (e) => { isLongPressActive = false; pressTimer = setTimeout(() => { if (!isFocusActive) { isLongPressActive = true; toggleFocusMode(type, el); lastTapTime = 0; } }, 1000); });
901
+
902
+ el.addEventListener('pointerdown', (e) => {
903
+ isLongPressActive = false;
904
+ pressTimer = setTimeout(() => {
905
+ if (!isFocusActive) {
906
+ isLongPressActive = true;
907
+ toggleFocusMode(type, el);
908
+ lastTapTime = 0;
909
+ }
910
+ }, 1000);
911
+ });
912
+
902
913
  el.addEventListener('pointerup', (e) => {
903
- clearTimeout(pressTimer); if (isLongPressActive) return;
904
- const currentTime = new Date().getTime(), tapDelay = currentTime - lastTapTime;
905
- if (tapDelay < 300 && tapDelay > 0) { clearTimeout(tapTimeout); graphModes[type] = (graphModes[type] === 'standard') ? 'hercules' : 'standard'; localStorage.setItem('mode_' + type, graphModes[type]); refreshGraph(type); lastTapTime = 0; }
906
- 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); }
914
+ clearTimeout(pressTimer);
915
+ if (isLongPressActive) return;
916
+
917
+ const currentTime = new Date().getTime();
918
+ const tapDelay = currentTime - lastTapTime;
919
+
920
+ if (tapDelay < 300 && tapDelay > 0) {
921
+ clearTimeout(tapTimeout);
922
+ graphModes[type] = (graphModes[type] === 'standard') ? 'hercules' : 'standard';
923
+ localStorage.setItem('mode_' + type, graphModes[type]);
924
+ refreshGraph(type);
925
+ lastTapTime = 0;
926
+ } else {
927
+ lastTapTime = currentTime;
928
+ tapTimeout = setTimeout(() => {
929
+ if (isFocusActive && el.classList.contains('is-focused')) {
930
+ toggleFocusMode(type, el);
931
+ } else if (!isFocusActive) {
932
+ if (type === 'sog') {
933
+ displayModeSog = (displayModeSog === 'SOG') ? 'VMG' : 'SOG';
934
+ } else if (type === 'tws') {
935
+ displayModeTws = (displayModeTws === 'TWS') ? 'AWS' : 'TWS';
936
+ }
937
+ el.style.backgroundColor = "rgba(0, 0, 0, 0.05)";
938
+ setTimeout(() => el.style.backgroundColor = "", 150);
939
+ }
940
+ }, 250);
941
+ }
907
942
  });
943
+
908
944
  el.addEventListener('pointerleave', () => clearTimeout(pressTimer));
909
945
  });
910
946
 
@@ -948,10 +984,7 @@ function connect() {
948
984
  const d = JSON.parse(e.data);
949
985
  if (d.updates) {
950
986
  d.updates.forEach(u => {
951
- // 1. Estraiamo il nome del sensore/sorgente (es. "yacht_device" o "Unknown")
952
987
  const sourceLabel = u.source ? (u.source.label || u.source.talker || "Unknown") : "Unknown";
953
-
954
- // 2. Passiamo il nome della sorgente come TERZO parametro
955
988
  if (u.values) {
956
989
  u.values.forEach(v => processIncomingData(v.path, v.value, sourceLabel));
957
990
  }
package/index.html CHANGED
@@ -168,7 +168,10 @@
168
168
  </div>
169
169
 
170
170
  <div class="data-box box-tws">
171
- <div class="label-row"><span class="label">TWS</span><span class="unit">kts</span></div>
171
+ <div class="label-row">
172
+ <span class="label" id="tws-aws-label">TWS</span>
173
+ <span class="unit">kts</span>
174
+ </div>
172
175
  <span class="value" id="tws">0.0</span>
173
176
  <div class="graph-wrapper">
174
177
  <div class="scale-labels" id="tws-scale"></div>
package/index.js CHANGED
@@ -84,19 +84,19 @@ module.exports = function (app) {
84
84
  reef1: {
85
85
  type: 'number',
86
86
  title: '1st Reef Alert (Orange)',
87
- description: "Wind speed (TWS) at which the graph turns orange, suggesting it's time to prepare for the first sail reduction.",
87
+ description: "Wind speed at which the graph turns orange. This threshold applies to the active mode: in TWS it indicates weather intensity, in AWS it indicates pressure on sails/rigging.",
88
88
  default: 15.0
89
89
  },
90
90
  reef2: {
91
91
  type: 'number',
92
92
  title: '2nd Reef Alert (Red)',
93
- description: "Wind speed (TWS) at which the graph turns red, indicating urgent need for sail reduction.",
93
+ description: "Critical wind speed at which the graph turns red. This threshold applies to the active mode: in TWS it warns of high sea state, in AWS it warns of excessive load on the mast/sails.",
94
94
  default: 20.0
95
95
  },
96
96
  historyMinutes: {
97
97
  type: 'number',
98
98
  title: 'Strategic Timeline (Minutes)',
99
- description: "Total duration shown in the charts. Vertical grid lines mark 1-minute intervals for short durations and 5-minute intervals for long ones.",
99
+ description: "Sets the time duration for all charts. It also defines the comparison window for the Strategic Weather Trend (the dot in the TWD compass) to detect long-term wind shifts.",
100
100
  default: 5,
101
101
  enum: [5, 10, 15, 30, 60]
102
102
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sailingrotevista/rotevista-dash",
3
- "version": "4.0.21",
4
- "description": "Public Wind Dashboard with navigation and course aids",
3
+ "version": "5.0.3",
4
+ "description": "Wind Dashboard with navigation and course aids",
5
5
  "main": "index.js",
6
6
  "publishConfig": {
7
7
  "access": "public"