@sailingrotevista/rotevista-dash 5.0.5 → 5.0.6

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 (3) hide show
  1. package/app.js +210 -100
  2. package/debug.html +104 -56
  3. package/package.json +1 -1
package/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * ==========================================================================
3
- * Signal K Wind Dashboard - Pro Version 3.5 (Time-Based Architecture)
3
+ * Signal K Wind Dashboard - Pro Version 3.8 (Dynamic Envelope Architecture)
4
4
  * ==========================================================================
5
5
  * Autore: Sailing Rotevista
6
6
  * Motore di calcolo tattico per navigazione e crociera.
@@ -24,10 +24,10 @@ let CONFIG = {
24
24
  },
25
25
  graphs: { reef1: 10, reef2: 15, historyMinutes: 10, samples: 60 },
26
26
  scales: {
27
- stw: { stdMax: 4, hercSpan: 4, step: 2 },
28
- sog: { stdMax: 4, hercSpan: 4, step: 2 },
29
- tws: { stdMax: 15, hercSpan: 10, step: 5 },
30
- depth: { stdMax: 8, hercSpan: 5, step: 5 }
27
+ stw: { stdMax: 4, hercSpan: 2, step: 1 },
28
+ sog: { stdMax: 4, hercSpan: 2, step: 1 },
29
+ tws: { stdMax: 15, hercSpan: 2, step: 1 },
30
+ depth: { stdMax: 8, hercSpan: 1, step: 1 }
31
31
  },
32
32
  server: { fallbackIp: "192.168.111.240:3000" }
33
33
  };
@@ -35,8 +35,7 @@ let CONFIG = {
35
35
  const RENDER_INTERVAL_MS = 1000;
36
36
  const TIMEOUT_MS = 5000;
37
37
  const SIM_SAMPLE_INTERVAL = 1000;
38
- const DASH_VERSION = "3.7"; // Major Update: Time-Based storage and GAP handling
39
- const sourceLocks = {};
38
+ const DASH_VERSION = "3.8"; // Major Update: Smart Source Locking & Breathing Hercules Scale
40
39
 
41
40
  // ==========================================================================
42
41
  // 2. STATO GLOBALE E RIFERIMENTI UI
@@ -59,6 +58,8 @@ let pressTimer, isFocusActive = false;
59
58
  let emaTwaSin = 0;
60
59
  let emaTwaCos = 0;
61
60
  let firstEmaRun = true;
61
+ // MACCHINA A STATI ALLARME STRAMBATA (Filtro Isteresi Anti-Brandeggio)
62
+ let lastGybeSide = null;
62
63
 
63
64
  const graphModes = {
64
65
  stw: 'standard',
@@ -67,13 +68,15 @@ const graphModes = {
67
68
  depth: 'standard'
68
69
  };
69
70
 
71
+ // GESTIONE SMART LOCK DELLE SORGENTI (Zero-Config)
72
+ const sourceLocks = {};
73
+
70
74
  // Database centrale dello store dati
71
75
  const store = {
72
76
  raw: {},
73
77
  timestamps: {},
74
78
  smoothBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
75
79
  longBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
76
- // histories ora conterrà array di oggetti: { time: Date.now(), val: numero }
77
80
  histories: { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [] },
78
81
  graphTempBuf: { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [] },
79
82
  lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0, aws: 0 }
@@ -326,14 +329,62 @@ function computeTrueWind() {
326
329
  }
327
330
  }
328
331
 
332
+ // ==========================================================================
333
+ // GESTIONE SMART LOCK DELLE SORGENTI (Zero-Config)
334
+ // ==========================================================================
335
+
336
+ /**
337
+ * Assegna un punteggio di qualità statico alla sorgente basato sull'hardware.
338
+ * Più alto è il punteggio, maggiore è la priorità del sensore.
339
+ */
340
+ function getSourcePriorityScore(sourceName) {
341
+ if (!sourceName) return 0;
342
+ const name = sourceName.toLowerCase();
343
+
344
+ // TIER 3: AIS / Trasmissioni lente (es. yacht_device.AI, VDO)
345
+ if (name.includes('.ai') || name.includes('ais') || name.includes('vdo')) {
346
+ return 10;
347
+ }
348
+
349
+ // TIER 2: GPS USB del Cerbo GX / Victron / Sistemi locali di servizio
350
+ if (name.includes('venus') || name.includes('victron') || name.includes('ttyacm') || name.includes('ttyusb') || name.includes('system')) {
351
+ return 50;
352
+ }
353
+
354
+ // TIER 1: Strumentazione ufficiale di navigazione (NMEA 2000, Yacht Devices GP/YD/AP, Gateway, ecc.)
355
+ 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')) {
356
+ return 100;
357
+ }
358
+
359
+ return 30; // Punteggio standard per sorgenti sconosciute
360
+ }
361
+
329
362
  function processIncomingData(path, val, source) {
330
363
  const now = Date.now();
364
+ const score = getSourcePriorityScore(source);
331
365
 
332
- // FILTRO SORGENTE STICKY
333
- if (!sourceLocks[path] || sourceLocks[path].label === source || (now - sourceLocks[path].lastSeen > 2000)) {
334
- sourceLocks[path] = { label: source, lastSeen: now };
366
+ // Gestione dello Smart Lock
367
+ if (!sourceLocks[path]) {
368
+ // Nessun blocco attivo: aggancia la sorgente corrente
369
+ sourceLocks[path] = { label: source, score: score, lastSeen: now };
335
370
  } else {
336
- return;
371
+ const currentLock = sourceLocks[path];
372
+ const isSameSource = (currentLock.label === source);
373
+ const isLockExpired = (now - currentLock.lastSeen > 12000); // Scadenza a 12 secondi per tollerare il GPS dello Yacht Devices
374
+ const hasHigherPriority = (score > currentLock.score);
375
+
376
+ if (isSameSource) {
377
+ // Stessa sorgente: aggiorna il timestamp e mantiene il blocco
378
+ currentLock.lastSeen = now;
379
+ currentLock.score = score;
380
+ } else if (isLockExpired || hasHigherPriority) {
381
+ // Ruba il blocco se la sorgente precedente è scaduta o se questa ha priorità superiore
382
+ sourceLocks[path] = { label: source, score: score, lastSeen: now };
383
+ console.log(`🔌 [Smart Lock] Path "${path}" switched to: ${source} (Score: ${score})`);
384
+ } else {
385
+ // Rifiuta i dati da sorgenti a priorità inferiore se quella principale è attiva
386
+ return;
387
+ }
337
388
  }
338
389
 
339
390
  store.timestamps[path] = now;
@@ -373,57 +424,16 @@ function processIncomingData(path, val, source) {
373
424
  // ==========================================================================
374
425
  function updateWindTrend() {
375
426
  const now = Date.now();
427
+
428
+ // --- 6.1 TREND TATTICO (AWA/TWA) ---
376
429
  const twaNow = getCircularAverageFromBuffer(store.longBuf.twa, 2000, true);
377
430
  const twaRef = getCircularAverageFromBuffer(store.longBuf.twa, 10000, true);
378
-
379
- const twdNow = getCircularAverageFromBuffer(store.longBuf.twd, 60000, false);
380
- const multiplier = isNavigating ? 1 : 2;
381
- const strategicWindowMs = CONFIG.graphs.historyMinutes * 60000 * multiplier;
382
- const twdRef = getCircularAverageFromBuffer(store.longBuf.twd, strategicWindowMs, false);
383
-
384
- if (!twaNow || !twaRef || !twdNow || !twdRef) return;
385
-
386
- const compassDots = { cw: document.getElementById('trend-dot-cw'), ccw: document.getElementById('trend-dot-ccw') };
387
431
  const gaugeDots = { cw: document.getElementById('trend-gauge-cw'), ccw: document.getElementById('trend-gauge-ccw') };
388
432
 
389
- // TREND METEO
390
- let deltaMeteo = radToDeg((twdNow.val - twdRef.val + Math.PI * 3) % (Math.PI * 2) - Math.PI);
391
- if (Math.abs(deltaMeteo) > 6.0) {
392
- const isSouth = store.raw["navigation.position"]?.latitude < 0;
393
- let meteoColor = (!isSouth) ? (deltaMeteo < 0 ? "#27ae60" : "#c0392b") : (deltaMeteo > 0 ? "#27ae60" : "#c0392b");
394
- if (deltaMeteo > 0) {
395
- compassDots.cw.classList.add('is-trending'); compassDots.cw.setAttribute('fill', meteoColor);
396
- compassDots.ccw.classList.remove('is-trending');
397
- } else {
398
- compassDots.ccw.classList.add('is-trending'); compassDots.ccw.setAttribute('fill', meteoColor);
399
- compassDots.cw.classList.remove('is-trending');
400
- }
401
- } else { [compassDots.cw, compassDots.ccw].forEach(el => { if(el){ el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }}); }
402
-
403
- // TREND TATTICO
404
- let deltaTac = radToDeg((twaNow.val - twaRef.val + Math.PI * 3) % (2 * Math.PI) - Math.PI);
405
- const curTwaDeg = radToDeg(twaNow.val);
406
- if (Math.abs(deltaTac) > 3.0) {
407
- let absTwa = Math.abs(curTwaDeg);
408
- let tacticColor;
409
- if (absTwa > 75 && absTwa < 105) {
410
- tacticColor = "#bbb";
411
- } else {
412
- let isLift = (curTwaDeg > 0) ? (deltaTac > 0) : (deltaTac < 0);
413
- if (absTwa >= 90) isLift = !isLift;
414
- tacticColor = isLift ? "#27ae60" : "#c0392b";
415
- }
416
- if (deltaTac > 0) {
417
- gaugeDots.cw.classList.add('is-trending'); gaugeDots.cw.setAttribute('fill', tacticColor);
418
- gaugeDots.ccw.classList.remove('is-trending');
419
- } else {
420
- gaugeDots.ccw.classList.add('is-trending'); gaugeDots.ccw.setAttribute('fill', tacticColor);
421
- gaugeDots.cw.classList.remove('is-trending');
422
- }
423
- } else { [gaugeDots.cw, gaugeDots.ccw].forEach(el => { if(el){ el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }}); }
424
-
425
- // ALLARME STRAMBATA (EMA FILTERED)
433
+ // --- 6.2 ALLARME STRAMBATA CON ISTERESI (MACCHINA A STATI ANTI-BRANDEGGIO) ---
426
434
  const instTwaRad = store.raw["environment.wind.angleTrueWater"];
435
+ let smoothedTwaDeg = null;
436
+
427
437
  if (instTwaRad !== undefined) {
428
438
  const dynamicAlpha = Math.max(0.05, 1.1 - CONFIG.averaging.stabilityThreshold);
429
439
  const currentSin = Math.sin(instTwaRad);
@@ -436,19 +446,98 @@ function updateWindTrend() {
436
446
  emaTwaCos = (currentCos * dynamicAlpha) + (emaTwaCos * (1 - dynamicAlpha));
437
447
  }
438
448
 
439
- const smoothedTwaDeg = radToDeg(Math.atan2(emaTwaSin, emaTwaCos));
449
+ smoothedTwaDeg = radToDeg(Math.atan2(emaTwaSin, emaTwaCos));
450
+ const absTwaDeg = Math.abs(smoothedTwaDeg);
451
+
452
+ // Allarme VISIVO: Se siamo in zona di pericolo poppa profonda (> 155°), accendi entrambi i LED di rosso pulsante
453
+ if (absTwaDeg > 155) {
454
+ if (gaugeDots.cw) { gaugeDots.cw.classList.add('is-gybing'); gaugeDots.cw.setAttribute('fill', '#ff3b30'); }
455
+ if (gaugeDots.ccw) { gaugeDots.ccw.classList.add('is-gybing'); gaugeDots.ccw.setAttribute('fill', '#ff3b30'); }
456
+
457
+ // LOGICA MACCHINA A STATI CON ISTERESI:
458
+ // Rileviamo le mure solo se siamo fuori dalla zona cieca di poppa secca (> 170°).
459
+ // Se oscilliamo tra -175° e +178° (brandeggio), lastGybeSide NON cambia e l'allarme acustico tace.
460
+ let currentTack = null;
461
+ if (smoothedTwaDeg > 155 && smoothedTwaDeg < 170) {
462
+ currentTack = 'starboard';
463
+ } else if (smoothedTwaDeg < -155 && smoothedTwaDeg > -170) {
464
+ currentTack = 'port';
465
+ }
440
466
 
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)}°`);
467
+ if (currentTack !== null) {
468
+ if (lastGybeSide === null) {
469
+ // Inizializzazione al primo ingresso nella zona di controllo
470
+ lastGybeSide = currentTack;
471
+ } else if (lastGybeSide !== currentTack) {
472
+ // Abbiamo eseguito una vera strambata stabile e siamo usciti dalla zona di poppa secca!
473
+ lastGybeSide = currentTack;
474
+
475
+ // Attivazione allarme acustico con blocco temporale di sicurezza (60 secondi)
476
+ if (isNavigating && (now - lastGybeAlarmTime > 60000)) {
477
+ lastGybeAlarmTime = now;
478
+ playGybeAlarm();
479
+ console.log(`⚠️ GYBE ALARM TRIGGERED: Tack switched to ${currentTack} (TWA: ${smoothedTwaDeg.toFixed(1)}°)`);
480
+ }
447
481
  }
448
482
  }
483
+ } else {
484
+ // Se usciamo dalla poppa profonda (< 155°), disattiva l'allarme visivo e resetta lo stato delle mure
485
+ if (gaugeDots.cw) gaugeDots.cw.classList.remove('is-gybing');
486
+ if (gaugeDots.ccw) gaugeDots.ccw.classList.remove('is-gybing');
487
+ lastGybeSide = null; // Reset per la prossima poppa
449
488
  }
450
489
  lastInstantTwa = smoothedTwaDeg;
451
490
  }
491
+
492
+ // Gestione normale dei Trend Tattici (Lifts/Headers) se NON siamo in allarme strambata
493
+ if (twaNow && twaRef && (smoothedTwaDeg === null || Math.abs(smoothedTwaDeg) <= 155)) {
494
+ let deltaTac = radToDeg((twaNow.val - twaRef.val + Math.PI * 3) % (2 * Math.PI) - Math.PI);
495
+ const curTwaDeg = radToDeg(twaNow.val);
496
+ if (Math.abs(deltaTac) > 3.0) {
497
+ let absTwa = Math.abs(curTwaDeg);
498
+ let tacticColor;
499
+ if (absTwa > 75 && absTwa < 105) {
500
+ tacticColor = "#bbb";
501
+ } else {
502
+ let isLift = (curTwaDeg > 0) ? (deltaTac > 0) : (deltaTac < 0);
503
+ if (absTwa >= 90) isLift = !isLift;
504
+ tacticColor = isLift ? "#27ae60" : "#c0392b";
505
+ }
506
+ if (deltaTac > 0) {
507
+ if (gaugeDots.cw) { gaugeDots.cw.classList.add('is-trending'); gaugeDots.cw.setAttribute('fill', tacticColor); }
508
+ if (gaugeDots.ccw) { gaugeDots.ccw.classList.remove('is-trending'); }
509
+ } else {
510
+ if (gaugeDots.ccw) { gaugeDots.ccw.classList.add('is-trending'); gaugeDots.ccw.setAttribute('fill', tacticColor); }
511
+ if (gaugeDots.cw) { gaugeDots.cw.classList.remove('is-trending'); }
512
+ }
513
+ } else {
514
+ [gaugeDots.cw, gaugeDots.ccw].forEach(el => { if(el){ el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }});
515
+ }
516
+ }
517
+
518
+ // --- 6.3 TREND METEO STRATEGICO (TWD) ---
519
+ const twdNow = getCircularAverageFromBuffer(store.longBuf.twd, 60000, false);
520
+ const multiplier = isNavigating ? 1 : 2;
521
+ const strategicWindowMs = CONFIG.graphs.historyMinutes * 60000 * multiplier;
522
+ const twdRef = getCircularAverageFromBuffer(store.longBuf.twd, strategicWindowMs, false);
523
+ const compassDots = { cw: document.getElementById('trend-dot-cw'), ccw: document.getElementById('trend-dot-ccw') };
524
+
525
+ if (twdNow && twdRef) {
526
+ let deltaMeteo = radToDeg((twdNow.val - twdRef.val + Math.PI * 3) % (Math.PI * 2) - Math.PI);
527
+ if (Math.abs(deltaMeteo) > 6.0) {
528
+ const isSouth = store.raw["navigation.position"]?.latitude < 0;
529
+ let meteoColor = (!isSouth) ? (deltaMeteo < 0 ? "#27ae60" : "#c0392b") : (deltaMeteo > 0 ? "#27ae60" : "#c0392b");
530
+ if (deltaMeteo > 0) {
531
+ if (compassDots.cw) { compassDots.cw.classList.add('is-trending'); compassDots.cw.setAttribute('fill', meteoColor); }
532
+ if (compassDots.ccw) { compassDots.ccw.classList.remove('is-trending'); }
533
+ } else {
534
+ if (compassDots.ccw) { compassDots.ccw.classList.add('is-trending'); compassDots.ccw.setAttribute('fill', meteoColor); }
535
+ if (compassDots.cw) { compassDots.cw.classList.remove('is-trending'); }
536
+ }
537
+ } else {
538
+ [compassDots.cw, compassDots.ccw].forEach(el => { if(el){ el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }});
539
+ }
540
+ }
452
541
  }
453
542
 
454
543
  // ==========================================================================
@@ -487,6 +576,9 @@ function startDisplayLoop() {
487
576
 
488
577
  isNavigating = stwKts > CONFIG.averaging.minSpeed || sogKts > CONFIG.averaging.minSpeed;
489
578
 
579
+ // --- CALCOLO TREND VENTO & ALLARME STRAMBATA ---
580
+ updateWindTrend();
581
+
490
582
  // --- AGGIORNAMENTO STATUS CON CONTEGGIO MINUTI REALE ---
491
583
  const viewportMinutes = CONFIG.graphs.historyMinutes * (isNavigating ? 1 : 2);
492
584
  const requiredMs = viewportMinutes * 60000;
@@ -766,7 +858,6 @@ function manageHistory(type, value) {
766
858
  const tempBuf = store.graphTempBuf[type];
767
859
 
768
860
  // --- 3. ANTI-DROPOUT DINAMICO (Auto-scaling) ---
769
- // Ignora cadute a zero se il valore precedente era superiore al 50% del primo Reef
770
861
  if ((type === 'tws' || type === 'aws') && value < 0.05 && tempBuf.length > 0) {
771
862
  const lastPoint = tempBuf[tempBuf.length - 1];
772
863
  const glitchThreshold = (CONFIG.graphs.reef1 || 15) * 0.5;
@@ -814,11 +905,10 @@ function manageHistory(type, value) {
814
905
  }
815
906
 
816
907
  // --- 6. CLAMPING E VALIDAZIONE FINALE ---
817
- // Protezione contro valori negativi (fisicamente impossibili per questi dati) e non finiti
818
908
  if (!isFinite(finalValue)) return;
819
909
  finalValue = Math.max(0, finalValue);
820
910
 
821
- // --- 7. STORAGE STORICO (Keys: val, time) ---
911
+ // --- 7. STORAGE STORICO ---
822
912
  store.histories[type].push({ val: finalValue, time: now });
823
913
 
824
914
  // --- 8. PRUNING DINAMICO ---
@@ -833,13 +923,47 @@ function manageHistory(type, value) {
833
923
  store.graphTempBuf[type] = [];
834
924
  store.lastUpdates[type] = now;
835
925
  }
926
+
927
+ /**
928
+ * Gestione dinamica delle scale dei grafici (Involucro Elastico e Safety Zoom)
929
+ */
836
930
  function calculateScale(type, data, mode) {
837
- const s = CONFIG.scales[type]; let aMin = Math.min(...data), aMax = Math.max(...data);
931
+ const s = CONFIG.scales[type];
932
+ let aMin = Math.min(...data), aMax = Math.max(...data);
933
+
934
+ // --- SAFETY ZOOM PER PROFONDITÀ (FONDALE BASSO) ---
935
+ if (type === 'depth' && mode !== 'hercules') {
936
+ const currentDepth = data[data.length - 1];
937
+ const shallowThreshold = Math.max(s.stdMax, 10);
938
+ if (currentDepth <= shallowThreshold) {
939
+ return { min: 0, max: shallowThreshold };
940
+ }
941
+ }
942
+
943
+ // --- MODALITÀ HERCULES AGGIORNATA (INVOLUCRO DINAMICO ELASTICO) ---
838
944
  if (mode === 'hercules') {
839
- let avg = (aMin + aMax) / 2; let span = Math.max(s.hercSpan, Math.ceil(aMax - aMin));
840
- if (span % 2 !== 0) span += 1; let min = Math.max(0, Math.floor(avg - (span / 2)));
841
- return { min, max: min + span };
945
+ const padding = (type === 'stw' || type === 'sog') ? 0.5 : 1.0;
946
+
947
+ let min = Math.max(0, Math.floor(aMin - padding));
948
+ let max = Math.ceil(aMax + padding);
949
+
950
+ // Garantisce uno span minimo configurato per evitare zoom eccessivi sul rumore di fondo
951
+ const currentSpan = max - min;
952
+ if (currentSpan < s.hercSpan) {
953
+ const diff = s.hercSpan - currentSpan;
954
+ min = Math.max(0, min - Math.floor(diff / 2));
955
+ max = min + s.hercSpan;
956
+ }
957
+
958
+ // Arrotonda la griglia numerica a step prefissati per evitare sfarfallamento visivo
959
+ const roundStep = (type === 'stw' || type === 'sog') ? 0.5 : 1.0;
960
+ min = Math.floor(min / roundStep) * roundStep;
961
+ max = Math.ceil(max / roundStep) * roundStep;
962
+
963
+ return { min, max };
842
964
  }
965
+
966
+ // Scala Standard (Autocompressione a scatti)
843
967
  return { min: 0, max: Math.max(s.stdMax, Math.ceil(aMax / s.step) * s.step) };
844
968
  }
845
969
 
@@ -863,7 +987,6 @@ function refreshGraph(t) {
863
987
 
864
988
  if (!rawData || rawData.length < 2) return;
865
989
 
866
- // ESTRAZIONE SOLO VALORI NUMERICI per calculateScale()
867
990
  const values = rawData.map(p => p.val);
868
991
  const mode = graphModes[boxType];
869
992
  const cfg = calculateScale(boxType, values, mode);
@@ -872,8 +995,6 @@ function refreshGraph(t) {
872
995
  if (box) box.classList.toggle('box-hercules', mode === 'hercules');
873
996
 
874
997
  updateScaleLabels(boxType, cfg.min, cfg.max);
875
-
876
- // Passiamo tutto l'array (oggetti) al nuovo motore
877
998
  drawGraph(rawData, boxType + '-graph', cfg.min, cfg.max, t === 'tws', mode === 'hercules');
878
999
  }
879
1000
 
@@ -889,16 +1010,13 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
889
1010
  const isDepth = (id === 'depth-graph');
890
1011
  const now = Date.now();
891
1012
 
892
- // VIEWPORT TEMPORALE DINAMICO
893
1013
  const visibleMinutes = CONFIG.graphs.historyMinutes * (isNavigating ? 1 : 2);
894
1014
  const viewportMs = visibleMinutes * 60000;
895
1015
  const viewportStart = now - viewportMs;
896
1016
 
897
- // FILTRO DATI VISIBILI (Gestisce Compressione/Zoom in modo naturale)
898
1017
  const visibleData = d.filter(p => p.time >= viewportStart);
899
1018
  if (visibleData.length < 2) return;
900
1019
 
901
- // COLORI
902
1020
  const colDanger = "#ff3b30", colWarning = "#ff9800", colTws = "#2c3e50", colAws = "#5c6bc0";
903
1021
  const colDepth = "#0088cc", colStw = "#00C851", colSog = "#ffbb33", colVmg = "#00b8d4";
904
1022
 
@@ -920,18 +1038,15 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
920
1038
  return { color, opacity, stroke };
921
1039
  };
922
1040
 
923
- // GRIGLIA ORIZZONTALE
924
1041
  let grids = "";
925
1042
  [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" />`);
926
1043
 
927
- // GRIGLIA VERTICALE VERA
928
1044
  const gridInterval = (visibleMinutes <= 15) ? 1 : 5;
929
1045
  for (let m = gridInterval; m < visibleMinutes; m += gridInterval) {
930
1046
  const x = w - ((m / visibleMinutes) * w);
931
1047
  grids += `<line x1="${x}" y1="0" x2="${x}" y2="${h}" stroke="rgba(0,0,0,0.08)" stroke-width="0.5" />`;
932
1048
  }
933
1049
 
934
- // RENDER TIME-BASED
935
1050
  let gradientStops = "", lines = "", areaPath = "";
936
1051
  let started = false;
937
1052
 
@@ -939,59 +1054,43 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
939
1054
  const pA = visibleData[i - 1];
940
1055
  const pB = visibleData[i];
941
1056
 
942
- // POSIZIONE X ESATTA AL MILLISECONDO
943
1057
  const x1 = ((pA.time - viewportStart) / viewportMs) * w;
944
1058
  const x2 = ((pB.time - viewportStart) / viewportMs) * w;
945
1059
  const y1 = h - (Math.max(0, Math.min(1, (pA.val - min) / range)) * h);
946
1060
  const y2 = h - (Math.max(0, Math.min(1, (pB.val - min) / range)) * h);
947
1061
 
948
1062
  const props = getColorProps(pB.val);
949
-
950
- // GESTIONE GAP TEMPORALI (Network loss)
951
1063
  const deltaTime = pB.time - pA.time;
952
1064
  const expectedInterval = viewportMs / CONFIG.graphs.samples;
953
1065
  const isGap = deltaTime > (expectedInterval * 2.5);
954
1066
 
955
- // STOPS GRADIENTE
956
1067
  const offset1 = (x1 / w) * 100, offset2 = (x2 / w) * 100;
957
1068
  gradientStops += `<stop offset="${offset1}%" stop-color="${props.color}" stop-opacity="${props.opacity}" />`;
958
1069
  gradientStops += `<stop offset="${offset2}%" stop-color="${props.color}" stop-opacity="${props.opacity}" />`;
959
1070
 
960
- // --- FIX: GESTIONE AREA E LINEE DURANTE I GAP ---
961
1071
  if (isGap) {
962
- // Se c'è un buco nei dati e avevamo già iniziato a disegnare...
963
1072
  if (started) {
964
- // Chiudiamo il pezzo di area precedente scendendo verticalmente (Z non serve qui)
965
1073
  areaPath += `L ${x1} ${h} `;
966
- started = false; // Resettiamo il flag per far ripartire l'area al prossimo punto
1074
+ started = false;
967
1075
  }
968
1076
  } else {
969
- // I dati sono continui, disegniamo la linea superiore
970
1077
  lines += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" style="stroke:${props.color}; stroke-width:${props.stroke}; stroke-linecap:round; shape-rendering:geometricPrecision;" />`;
971
-
972
- // Gestione Area
973
1078
  if (!started) {
974
- // Iniziamo un nuovo pezzo di area dal fondo, saliamo a y1
975
1079
  areaPath += `M ${Math.max(0, x1)} ${h} L ${Math.max(0, x1)} ${y1} `;
976
1080
  started = true;
977
1081
  }
978
- // Aggiungiamo il punto attuale
979
1082
  areaPath += `L ${x2} ${y2} `;
980
1083
  }
981
1084
  }
982
1085
 
983
- // CHIUSURA FINALE DELL'AREA (Solo se non siamo finiti dentro un gap)
984
1086
  if (started) {
985
1087
  const last = visibleData[visibleData.length - 1];
986
1088
  const lastX = ((last.time - viewportStart) / viewportMs) * w;
987
- areaPath += `L ${lastX} ${h} Z`; // Z chiude automaticamente il path tornando al punto 'M' iniziale
1089
+ areaPath += `L ${lastX} ${h} Z`;
988
1090
  }
989
1091
 
990
- // GRADIENTE
991
1092
  const gradId = `grad-${id}`;
992
1093
  const defs = `<defs><linearGradient id="${gradId}" x1="0" y1="0" x2="${w}" y2="0" gradientUnits="userSpaceOnUse">${gradientStops}</linearGradient></defs>`;
993
-
994
- // RENDER FINALE
995
1094
  svg.innerHTML = `${defs}${grids}<path d="${areaPath}" fill="url(#${gradId})" stroke="none" />${lines}`;
996
1095
  }
997
1096
 
@@ -1095,7 +1194,18 @@ function connect() {
1095
1194
  const d = JSON.parse(e.data);
1096
1195
  if (d.updates) {
1097
1196
  d.updates.forEach(u => {
1098
- const sourceLabel = u.source ? (u.source.label || u.source.talker || "Unknown") : "Unknown";
1197
+ // ESTRAZIONE AVANZATA DELLA SORGENTE (Gestisce $source e stringhe native)
1198
+ let sourceLabel = "Unknown";
1199
+ if (u.$source) {
1200
+ sourceLabel = u.$source;
1201
+ } else if (u.source) {
1202
+ if (typeof u.source === 'object') {
1203
+ sourceLabel = u.source.label || u.source.talker || u.source.src || "Unknown";
1204
+ } else {
1205
+ sourceLabel = String(u.source);
1206
+ }
1207
+ }
1208
+
1099
1209
  if (u.values) {
1100
1210
  u.values.forEach(v => processIncomingData(v.path, v.value, sourceLabel));
1101
1211
  }
package/debug.html CHANGED
@@ -56,7 +56,7 @@
56
56
  /* Stili Testo Tabella */
57
57
  .value { color: #64ffda; font-weight: bold; font-size: 16px; }
58
58
  .unit { color: #888; font-size: 12px; margin-left: 5px; }
59
- .source { color: #ffb74d; font-size: 12px; }
59
+ .source { color: #ffb74d; font-size: 12px; word-break: break-all; }
60
60
  .time { color: #90caf9; font-size: 12px; }
61
61
  .raw { color: #777; font-size: 11px; display: block; margin-top: 4px; }
62
62
 
@@ -83,7 +83,7 @@
83
83
  color: #aaa;
84
84
  }
85
85
 
86
- /* Area di testo del Log (Non più div separati, ma un unico blocco di testo preformattato) */
86
+ /* Area di testo del Log */
87
87
  textarea#log-container {
88
88
  flex-grow: 1;
89
89
  background: transparent;
@@ -122,6 +122,7 @@
122
122
  <span style="margin-left: 20px; color:#aaa;">Filtra Log:</span>
123
123
  <select id="log-filter">
124
124
  <option value="all">Tutti i dati monitorati</option>
125
+ <option value="navigation.position">Solo Posizione (GPS)</option>
125
126
  <option value="navigation.speedOverGround">Solo SOG (Velocità sul fondo)</option>
126
127
  <option value="navigation.courseOverGroundTrue">Solo COG (Rotta sul fondo)</option>
127
128
  <option value="wind">Solo Vento (TWS/TWA/AWS/AWA)</option>
@@ -140,7 +141,7 @@
140
141
  <tr>
141
142
  <th width="30%">Path (Percorso)</th>
142
143
  <th width="20%">Valore Convertito</th>
143
- <th width="30%">Sorgente (Sensore)</th>
144
+ <th width="30%">Sorgente (Sensore - Dettagliato)</th>
144
145
  <th width="20%">Ultimo Ricevuto</th>
145
146
  </tr>
146
147
  </thead>
@@ -161,15 +162,16 @@
161
162
  </div>
162
163
  </div>
163
164
 
164
- <!-- Usiamo una textarea readonly per permettere la selezione e lo scroll perfetto di testi lunghi -->
165
165
  <textarea id="log-container" readonly></textarea>
166
166
  </div>
167
167
 
168
168
  </div>
169
169
 
170
170
  <script>
171
- // Percorsi da monitorare
172
171
  const pathsToWatch = [
172
+ "navigation.position",
173
+ "navigation.position.latitude",
174
+ "navigation.position.longitude",
173
175
  "navigation.speedThroughWater",
174
176
  "navigation.speedOverGround",
175
177
  "navigation.headingTrue",
@@ -182,13 +184,11 @@
182
184
  ];
183
185
 
184
186
  let socket = null;
185
- let logLinesArray = []; // Array per conservare le righe in memoria
186
- const MAX_LOG_LINES = 1000; // Aumentato a 1000 righe per analisi lunghe in Excel
187
+ let logLinesArray = [];
188
+ const MAX_LOG_LINES = 1000;
187
189
 
188
- // Memoria per rilevare i picchi anomali (Spikes)
189
190
  let lastValues = { "navigation.speedOverGround": 0, "navigation.courseOverGroundTrue": 0 };
190
191
 
191
- // Funzioni di conversione matematica
192
192
  const radToDeg = (rad) => rad * (180 / Math.PI);
193
193
  const msToKts = (ms) => ms * 1.94384;
194
194
 
@@ -207,10 +207,33 @@
207
207
  tbody.appendChild(tr);
208
208
  });
209
209
 
210
- // Intestazione fissa del file CSV nel Log
211
210
  const csvHeader = "Timestamp,Path,Value,Unit,Source,IsSpike";
212
211
  document.getElementById('log-container').value = csvHeader + "\n";
213
212
 
213
+ // =========================================================
214
+ // ESTRAZIONE AVANZATA DELLA SORGENTE (GPS / NMEA)
215
+ // =========================================================
216
+ function extractSourceIdentifier(update) {
217
+ // 1. Cerca il campo standard moderno $source (es. "can0.GP" o "dbus.gps")
218
+ if (update.$source) {
219
+ return update.$source;
220
+ }
221
+
222
+ // 2. Se non presente, analizza l'oggetto o la stringa source tradizionale
223
+ if (update.source) {
224
+ if (typeof update.source === 'object') {
225
+ // Estrae label, nome talker o identificativo bus hardware se disponibili
226
+ return update.source.label ||
227
+ update.source.talker ||
228
+ update.source.src ||
229
+ (update.source.device ? `${update.source.device}:${update.source.pgn || ''}` : JSON.stringify(update.source));
230
+ }
231
+ return String(update.source);
232
+ }
233
+
234
+ return "Unknown-Source";
235
+ }
236
+
214
237
  // =========================================================
215
238
  // GESTIONE CONNESSIONE WEBSOCKET
216
239
  // =========================================================
@@ -233,7 +256,6 @@
233
256
  status.innerText = "CONNESSIONE IN CORSO...";
234
257
  status.className = "";
235
258
 
236
- // Resetting spike logic on new connection
237
259
  lastValues = { "navigation.speedOverGround": 0, "navigation.courseOverGroundTrue": 0 };
238
260
 
239
261
  socket = new WebSocket(`ws://${ip}/signalk/v1/stream?subscribe=self`);
@@ -245,23 +267,27 @@
245
267
  };
246
268
 
247
269
  socket.onmessage = (event) => {
248
- const data = JSON.parse(event.data);
249
-
250
- if (data.updates) {
251
- data.updates.forEach(update => {
252
- const sourceName = update.source ? update.source.label : "Unknown";
253
- // ISO String è lo standard perfetto per i file CSV ed Excel (es. 2023-10-25T17:00:56.123Z)
254
- const timeISO = new Date(update.timestamp).toISOString();
255
- const timeUI = new Date(update.timestamp).toLocaleTimeString() + '.' + new Date(update.timestamp).getMilliseconds().toString().padStart(3, '0');
256
-
257
- if (update.values) {
258
- update.values.forEach(v => {
259
- if (pathsToWatch.includes(v.path)) {
260
- processData(v.path, v.value, sourceName, timeISO, timeUI);
261
- }
262
- });
263
- }
264
- });
270
+ try {
271
+ const data = JSON.parse(event.data);
272
+
273
+ if (data.updates) {
274
+ data.updates.forEach(update => {
275
+ // Cattura l'identificativo sorgente con la nuova logica avanzata
276
+ const sourceName = extractSourceIdentifier(update);
277
+ const timeISO = new Date(update.timestamp).toISOString();
278
+ const timeUI = new Date(update.timestamp).toLocaleTimeString() + '.' + new Date(update.timestamp).getMilliseconds().toString().padStart(3, '0');
279
+
280
+ if (update.values) {
281
+ update.values.forEach(v => {
282
+ if (pathsToWatch.includes(v.path)) {
283
+ processData(v.path, v.value, sourceName, timeISO, timeUI);
284
+ }
285
+ });
286
+ }
287
+ });
288
+ }
289
+ } catch (err) {
290
+ console.error("Errore elaborazione messaggio: ", err);
265
291
  }
266
292
  };
267
293
 
@@ -283,17 +309,22 @@
283
309
  // ELABORAZIONE DATI E SPIKE DETECTION
284
310
  // =========================================================
285
311
  function processData(path, rawValue, source, timeISO, timeUI) {
286
- let formattedVal = rawValue;
312
+ let formattedVal = "---";
287
313
  let unit = "";
288
314
  let isSpike = false;
289
315
 
290
- // Conversioni
291
316
  if (rawValue !== null && rawValue !== undefined) {
292
- if (path.includes("speed")) {
317
+ if (path === "navigation.position") {
318
+ if (typeof rawValue === 'object' && rawValue.latitude !== undefined && rawValue.longitude !== undefined) {
319
+ formattedVal = `${rawValue.latitude.toFixed(6)};${rawValue.longitude.toFixed(6)}`;
320
+ unit = "lat;lon";
321
+ } else {
322
+ formattedVal = "Invalid Position";
323
+ }
324
+ } else if (path.includes("speed")) {
293
325
  formattedVal = msToKts(rawValue).toFixed(3);
294
326
  unit = "kts";
295
327
 
296
- // Rilevamento Spike SOG (> 5 nodi in un colpo solo)
297
328
  if (path === "navigation.speedOverGround") {
298
329
  let diff = Math.abs(formattedVal - lastValues[path]);
299
330
  if (diff > 5.0 && lastValues[path] > 0) isSpike = true;
@@ -304,7 +335,6 @@
304
335
  formattedVal = radToDeg(rawValue).toFixed(2);
305
336
  unit = "deg";
306
337
 
307
- // Rilevamento Spike COG (> 45 gradi improvviso)
308
338
  if (path === "navigation.courseOverGroundTrue") {
309
339
  let diff = Math.abs(formattedVal - lastValues[path]);
310
340
  if (diff > 180) diff = 360 - diff;
@@ -315,6 +345,12 @@
315
345
  } else if (path.includes("depth")) {
316
346
  formattedVal = rawValue.toFixed(2);
317
347
  unit = "m";
348
+ } else {
349
+ if (typeof rawValue === 'number') {
350
+ formattedVal = rawValue.toFixed(6);
351
+ } else {
352
+ formattedVal = String(rawValue);
353
+ }
318
354
  }
319
355
  }
320
356
 
@@ -330,31 +366,54 @@
330
366
 
331
367
  function updateTableRow(path, val, unit, raw, source, timeUI) {
332
368
  const safeId = path.replace(/\./g, '-');
333
- document.getElementById(`val-${safeId}`).innerHTML = `<span class="value">${val}</span><span class="unit">${unit}</span><span class="raw">Raw: ${raw.toFixed(4)}</span>`;
334
- document.getElementById(`src-${safeId}`).innerHTML = `<span class="source">${source}</span>`;
335
- document.getElementById(`time-${safeId}`).innerHTML = `<span class="time">${timeUI}</span>`;
369
+
370
+ let rawStr = "---";
371
+ if (raw !== null && raw !== undefined) {
372
+ if (typeof raw === 'number') {
373
+ rawStr = raw.toFixed(4);
374
+ } else if (typeof raw === 'object') {
375
+ rawStr = JSON.stringify(raw);
376
+ } else {
377
+ rawStr = String(raw);
378
+ }
379
+ }
380
+
381
+ const valCell = document.getElementById(`val-${safeId}`);
382
+ if (valCell) {
383
+ valCell.innerHTML = `<span class="value">${val}</span><span class="unit">${unit}</span><span class="raw">Raw: ${rawStr}</span>`;
384
+ }
385
+
386
+ const srcCell = document.getElementById(`src-${safeId}`);
387
+ if (srcCell) {
388
+ srcCell.innerHTML = `<span class="source">${source}</span>`;
389
+ }
390
+
391
+ const timeCell = document.getElementById(`time-${safeId}`);
392
+ if (timeCell) {
393
+ timeCell.innerHTML = `<span class="time">${timeUI}</span>`;
394
+ }
336
395
 
337
396
  const row = document.getElementById(`row-${safeId}`);
338
- row.style.backgroundColor = "#334a33";
339
- setTimeout(() => row.style.backgroundColor = "", 150);
397
+ if (row) {
398
+ row.style.backgroundColor = "#334a33";
399
+ setTimeout(() => row.style.backgroundColor = "", 150);
400
+ }
340
401
  }
341
402
 
342
403
  // =========================================================
343
404
  // GESTIONE DEL LOG CSV E COPIA APPUNTI
344
405
  // =========================================================
345
406
  function addLogEntry(timeISO, path, value, unit, source, isSpike) {
346
- // Formattazione stringa CSV pulita (niente spazi inutili)
347
- // Se c'è una virgola nel nome della sorgente, la mettiamo tra virgolette per non rompere il CSV
348
- const safeSource = source.includes(',') ? `"${source}"` : source;
407
+ const safeValue = (value.includes(',') || value.includes(';')) ? `"${value}"` : value;
408
+ const safeSource = (source.includes(',') || source.includes(';')) ? `"${source}"` : source;
349
409
  const spikeFlag = isSpike ? "TRUE" : "FALSE";
350
410
 
351
- const csvLine = `${timeISO},${path},${value},${unit},${safeSource},${spikeFlag}`;
411
+ const csvLine = `${timeISO},${path},${safeValue},${unit},${safeSource},${spikeFlag}`;
352
412
 
353
413
  logLinesArray.push(csvLine);
354
414
 
355
- // Mantieni la memoria leggera
356
415
  if (logLinesArray.length > MAX_LOG_LINES) {
357
- logLinesArray.shift(); // Rimuove la più vecchia
416
+ logLinesArray.shift();
358
417
  }
359
418
 
360
419
  renderLogTextArea();
@@ -362,11 +421,7 @@
362
421
 
363
422
  function renderLogTextArea() {
364
423
  const textArea = document.getElementById('log-container');
365
-
366
- // Ricostruisci il testo unendo l'intestazione e l'array con \n (a capo)
367
424
  textArea.value = csvHeader + "\n" + logLinesArray.join("\n");
368
-
369
- // Auto-scroll verso il basso
370
425
  textArea.scrollTop = textArea.scrollHeight;
371
426
  }
372
427
 
@@ -377,14 +432,10 @@
377
432
 
378
433
  function copyLogCSV() {
379
434
  const textArea = document.getElementById('log-container');
380
- const notif = document.getElementById('copy-notification');
381
-
382
- // Seleziona il testo
383
435
  textArea.select();
384
- textArea.setSelectionRange(0, 999999); // Per mobile
436
+ textArea.setSelectionRange(0, 999999);
385
437
 
386
438
  try {
387
- // Copia negli appunti (API moderna Navigator o execCommand classico)
388
439
  if (navigator.clipboard) {
389
440
  navigator.clipboard.writeText(textArea.value).then(() => showCopyNotif());
390
441
  } else {
@@ -401,11 +452,8 @@
401
452
  const notif = document.getElementById('copy-notification');
402
453
  notif.style.opacity = 1;
403
454
  setTimeout(() => notif.style.opacity = 0, 2000);
404
-
405
- // Deseleziona il testo
406
455
  window.getSelection().removeAllRanges();
407
456
  }
408
-
409
457
  </script>
410
458
  </body>
411
459
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sailingrotevista/rotevista-dash",
3
- "version": "5.0.5",
3
+ "version": "5.0.6",
4
4
  "description": "Wind Dashboard with navigation and course aids",
5
5
  "main": "index.js",
6
6
  "publishConfig": {