@sailingrotevista/rotevista-dash 6.2.7 → 7.0.1

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.
package/radar.html CHANGED
@@ -112,18 +112,28 @@
112
112
  }
113
113
 
114
114
  .status-badge {
115
- display: inline-block;
116
- padding: 2px 6px;
117
- border-radius: 3px;
118
- font-weight: bold;
119
- color: #fff;
120
- text-transform: uppercase;
121
- font-size: 9px;
122
- }
123
- .status-online { background: #00C851; }
124
- .status-offline { background: #ff3b30; }
125
- </style>
126
- </head>
115
+ display: inline-block;
116
+ padding: 2px 6px;
117
+ border-radius: 3px;
118
+ font-weight: bold;
119
+ color: #fff;
120
+ text-transform: uppercase;
121
+ font-size: 9px;
122
+ }
123
+ .status-online { background: #00C851; }
124
+ .status-offline { background: #ff3b30; }
125
+
126
+ /* Chirurgico: Animazione e stile per la punta infuocata del trend meteo */
127
+ @keyframes blink-trend {
128
+ 0%, 100% { opacity: 1; filter: drop-shadow(0 0 6px currentColor); }
129
+ 50% { opacity: 0.3; filter: drop-shadow(0 0 1px currentColor); }
130
+ }
131
+ .is-trending {
132
+ animation: blink-trend 1.2s infinite ease-in-out !important;
133
+ transform-origin: center;
134
+ }
135
+ </style>
136
+ </head>
127
137
  <body>
128
138
 
129
139
  <!-- AREA SUPERIORE: BUSSOLA RADAR TATTICA -->
@@ -251,10 +261,74 @@
251
261
  const BORDER_STROKE_WIDTH = ARC_STROKE_WIDTH + 2; // 7px
252
262
 
253
263
  const radToDeg = (rad) => (rad * 180 / Math.PI);
254
- const degToRad = (deg) => (deg * Math.PI / 180);
255
- const msToKts = (ms) => ms * 1.94384;
264
+ const degToRad = (deg) => (deg * Math.PI / 180);
265
+ const msToKts = (ms) => ms * 1.94384;
266
+
267
+ // Chirurgico: Inserita la funzione vettoriale circolare con calcolo trigonometrico al volo per l'analisi dei trend meteo
268
+ function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false, now) {
269
+ now = now || Date.now();
270
+ const len = bufferArray.length;
271
+ if (len === 0) return null;
272
+
273
+ let sSin = 0, sCos = 0, count = 0;
274
+ let newestTime = 0, oldestTime = 0;
275
+
276
+ let pilotSin = 0, pilotCos = 0;
277
+ const pilotSamples = Math.min(len, 15);
278
+ for (let i = len - 1; i >= len - pilotSamples; i--) {
279
+ const val = bufferArray[i].val;
280
+ pilotSin += Math.sin(val);
281
+ pilotCos += Math.cos(val);
282
+ }
283
+ const pilotRad = Math.atan2(pilotSin, pilotCos);
284
+ const limitRad = (CONFIG.averaging.stabilityBreakout || 15) * (Math.PI / 180);
256
285
 
257
- function polarToCartesian(centerX, centerY, radius, angleInDegrees) {
286
+ for (let i = len - 1; i >= 0; i--) {
287
+ const item = bufferArray[i];
288
+ if ((now - item.time) > windowMs) break;
289
+
290
+ const itemVal = item.val;
291
+ const itemSin = Math.sin(itemVal);
292
+ const itemCos = Math.cos(itemVal);
293
+
294
+ let diffRad = Math.atan2(Math.sin(itemVal - pilotRad), Math.cos(itemVal - pilotRad));
295
+ let finalSin, finalCos;
296
+
297
+ if (Math.abs(diffRad) > limitRad) {
298
+ const clampedDiff = Math.sign(diffRad) * limitRad;
299
+ const clampedRad = pilotRad + clampedDiff;
300
+ finalSin = Math.sin(clampedRad);
301
+ finalCos = Math.cos(clampedRad);
302
+ } else {
303
+ finalSin = itemSin;
304
+ finalCos = itemCos;
305
+ }
306
+
307
+ sSin += finalSin;
308
+ sCos += finalCos;
309
+
310
+ if (count === 0) newestTime = item.time;
311
+ oldestTime = item.time;
312
+ count++;
313
+ }
314
+
315
+ if (count === 0) return null;
316
+
317
+ const R = Math.hypot(sSin, sCos) / count;
318
+ const avgRad = Math.atan2(sSin, sCos);
319
+ const finalVal = signed ? avgRad : (avgRad + Math.PI * 2) % (Math.PI * 2);
320
+ const historyDuration = (count > 2) ? (newestTime - oldestTime) : 0;
321
+ const safeR = Math.max(R, 1e-9);
322
+
323
+ return {
324
+ val: finalVal,
325
+ stable: historyDuration > 10000 && R > (CONFIG.averaging.stabilityThreshold || 0.95),
326
+ dev: (R < 1) ? Math.round(Math.sqrt(-2 * Math.log(safeR)) * (180 / Math.PI)) : 0,
327
+ samples: count
328
+ };
329
+ }
330
+
331
+ function polarToCartesian(centerX, centerY, radius, angleInDegrees) {
258
332
  const angleInRadians = (angleInDegrees - 90) * Math.PI / 180.0;
259
333
  return {
260
334
  x: centerX + (radius * Math.cos(angleInRadians)),
@@ -278,51 +352,56 @@
278
352
  }
279
353
 
280
354
  // --- 2. GESTIONE SEGNALI IN TEMPO REALE ---
281
- function processIncomingDelta(path, val, source, timeMs) {
282
- const now = timeMs || Date.now();
283
- store.timestamps[path] = now;
284
- store.raw[path] = val;
285
-
286
- if (path === 'navigation.speedThroughWater') document.getElementById('debug-stw').innerText = msToKts(val).toFixed(1);
287
- else if (path === 'navigation.speedOverGround') document.getElementById('debug-sog').innerText = msToKts(val).toFixed(1);
288
- else if (path === 'navigation.headingTrue') document.getElementById('debug-hdg').innerText = Math.round(radToDeg(val)) + '°';
289
- else if (path === 'navigation.courseOverGroundTrue') document.getElementById('debug-cog').innerText = Math.round(radToDeg(val)) + '°';
290
- else if (path === 'environment.wind.speedApparent') document.getElementById('debug-aws').innerText = msToKts(val).toFixed(1);
291
- else if (path === 'environment.wind.angleApparent') document.getElementById('debug-awa').innerText = Math.round(radToDeg(val)) + '°';
292
- else if (path === 'navigation.position') {
293
- document.getElementById('debug-gps').innerText = val.latitude.toFixed(4) + '; ' + val.longitude.toFixed(4);
294
- }
355
+ function setDebugText(id, text) {
356
+ const el = document.getElementById(id);
357
+ if (el) el.innerText = text;
358
+ }
295
359
 
296
- const aws = store.raw["environment.wind.speedApparent"];
297
- const awa = store.raw["environment.wind.angleApparent"];
298
- if (aws !== undefined && awa !== undefined) {
299
- const stw = store.raw["navigation.speedThroughWater"] || 0;
300
- const awsKts = msToKts(aws);
301
- const stwKts = msToKts(stw);
360
+ function processIncomingDelta(path, val, source, timeMs) {
361
+ const now = timeMs || Date.now();
362
+ store.timestamps[path] = now;
363
+ store.raw[path] = val;
364
+
365
+ if (path === 'navigation.speedThroughWater') setDebugText('debug-stw', msToKts(val).toFixed(1));
366
+ else if (path === 'navigation.speedOverGround') setDebugText('debug-sog', msToKts(val).toFixed(1));
367
+ else if (path === 'navigation.headingTrue') setDebugText('debug-hdg', Math.round(radToDeg(val)) + '°');
368
+ else if (path === 'navigation.courseOverGroundTrue') setDebugText('debug-cog', Math.round(radToDeg(val)) + '°');
369
+ else if (path === 'environment.wind.speedApparent') setDebugText('debug-aws', msToKts(val).toFixed(1));
370
+ else if (path === 'environment.wind.angleApparent') setDebugText('debug-awa', Math.round(radToDeg(val)) + '°');
371
+ else if (path === 'navigation.position') {
372
+ setDebugText('debug-gps', val.latitude.toFixed(4) + '; ' + val.longitude.toFixed(4));
373
+ }
302
374
 
303
- const tw_water_x = awsKts * Math.cos(awa) - stwKts;
304
- const tw_water_y = awsKts * Math.sin(awa);
305
-
306
- const tws = Math.sqrt(tw_water_x * tw_water_x + tw_water_y * tw_water_y);
307
- const twa = Math.atan2(tw_water_y, tw_water_x);
308
- const hdg = store.raw["navigation.headingTrue"] || 0;
309
- const twd = (hdg + twa + 2 * Math.PI) % (2 * Math.PI);
375
+ const aws = store.raw["environment.wind.speedApparent"];
376
+ const awa = store.raw["environment.wind.angleApparent"];
377
+ if (aws !== undefined && awa !== undefined) {
378
+ const stw = store.raw["navigation.speedThroughWater"] || 0;
379
+ const awsKts = msToKts(aws);
380
+ const stwKts = msToKts(stw);
310
381
 
311
- document.getElementById('debug-tws').innerText = tws.toFixed(1);
312
- document.getElementById('debug-twa').innerText = Math.round(radToDeg(twa)) + '°';
313
- document.getElementById('debug-twd').innerText = Math.round(radToDeg(twd)) + '°';
382
+ const tw_water_x = awsKts * Math.cos(awa) - stwKts;
383
+ const tw_water_y = awsKts * Math.sin(awa);
384
+
385
+ const tws = Math.sqrt(tw_water_x * tw_water_x + tw_water_y * tw_water_y);
386
+ const twa = Math.atan2(tw_water_y, tw_water_x);
387
+ const hdg = store.raw["navigation.headingTrue"] || 0;
388
+ const twd = (hdg + twa + 2 * Math.PI) % (2 * Math.PI);
314
389
 
315
- // --- ACCUMULO DATI REALI LOCALE (1Hz) ---
316
- // Popoliamo i buffer locali ogni secondo per garantire il disegno immediato del Presente
317
- store.twdMinuteBuffer.push({ val: twd, min: twd, max: twd, time: now });
318
- while(store.twdMinuteBuffer.length > 1800) store.twdMinuteBuffer.shift(); // conserva 30 minuti a 1Hz
390
+ setDebugText('debug-tws', tws.toFixed(1));
391
+ setDebugText('debug-twa', Math.round(radToDeg(twa)) + '°');
392
+ setDebugText('debug-twd', Math.round(radToDeg(twd)) + '°');
319
393
 
320
- store.twsMinuteBuffer.push({ val: tws, time: now });
321
- while(store.twsMinuteBuffer.length > 1800) store.twsMinuteBuffer.shift();
394
+ // --- AGGIORNAMENTO DATI ISTANTANEI PER L'ELABORAZIONE ---
395
+ // Manteniamo aggiornato lo stato raw istantaneo in memoria per i calcoli del radar...
396
+ store.raw["environment.wind.directionTrue"] = twd;
397
+ store.raw["environment.wind.speedTrue"] = tws;
322
398
 
323
- renderRadar(); // Rinfresco grafico istantaneo reattivo
324
- }
325
- }
399
+ // NOTA: Non popoliamo più i buffer locali a 1Hz. Il presente dei 30 minuti (Anello 1)
400
+ // viene gestito e sincronizzato direttamente dal server tramite fetchConfigAndHistory().
401
+
402
+ renderRadar(); // Rinfresco grafico istantaneo reattivo per gli indicatori/trend
403
+ }
404
+ }
326
405
 
327
406
  // --- 3. RETE E SINCRONIZZAZIONE REST ---
328
407
  function getApiUrl(path) {
@@ -417,21 +496,22 @@
417
496
  }
418
497
 
419
498
  // --- 4. MOTORE DI CALCOLO DELL'ANELLO 1 (Presente Mobile) ---
420
- function calculateActive30mRing() {
421
- const now = Date.now();
422
- const start30m = now - 1800000;
499
+ function calculateActive30mRing() {
500
+ const now = Date.now();
501
+ const start30m = now - 1800000;
423
502
 
424
- const twdRecent = store.twdMinuteBuffer.filter(p => p.time >= start30m);
425
- const twsRecent = store.twsMinuteBuffer.filter(p => p.time >= start30m);
503
+ const twdRecent = store.twdMinuteBuffer.filter(p => p.time >= start30m);
504
+ const twsRecent = store.twsMinuteBuffer.filter(p => p.time >= start30m);
426
505
 
427
- if (twdRecent.length === 0) return null;
506
+ if (twdRecent.length === 0) return null;
428
507
 
429
- const twsVals = twsRecent.map(p => p.val).filter(v => isFinite(v));
430
- const maxTws = twsVals.length > 0 ? Math.max(...twsVals) : 0;
508
+ const twsVals = twsRecent.map(p => p.val).filter(v => isFinite(v));
509
+ const maxTws = twsVals.length > 0 ? Math.max(...twsVals) : 0;
510
+ const minTws = twsVals.length > 0 ? Math.min(...twsVals) : 0; // Chirurgico: Troviamo il vento minimo reale degli ultimi 30 minuti
431
511
 
432
- if (maxTws < CALM_THRESHOLD_KTS) {
433
- return { twsPeak: maxTws, twdMin: 0, twdMax: 360, isCalm: true };
434
- }
512
+ if (maxTws < CALM_THRESHOLD_KTS) {
513
+ return { twsPeak: maxTws, twsMin: minTws, twdMin: 0, twdMax: 360, isCalm: true };
514
+ }
435
515
 
436
516
  let allAngles = [];
437
517
  twdRecent.forEach(p => {
@@ -459,15 +539,16 @@
459
539
  const maxDiff = Math.max(...finalDiffs);
460
540
 
461
541
  const finalMinDeg = Math.round(radToDeg((finalAvg + minDiff + Math.PI * 2) % (Math.PI * 2)));
462
- const finalMaxDeg = Math.round(radToDeg((finalAvg + maxDiff + Math.PI * 2) % (Math.PI * 2)));
463
-
464
- return {
465
- twdMin: finalMinDeg,
466
- twdMax: finalMaxDeg,
467
- twsPeak: maxTws,
468
- isCalm: false
469
- };
470
- }
542
+ const finalMaxDeg = Math.round(radToDeg((finalAvg + maxDiff + Math.PI * 2) % (Math.PI * 2)));
543
+
544
+ return {
545
+ twdMin: finalMinDeg,
546
+ twdMax: finalMaxDeg,
547
+ twsPeak: maxTws,
548
+ twsMin: minTws, // Chirurgico: Ritorniamo anche il valore minimo per abilitare la sfumatura sull'Anello 1
549
+ isCalm: false
550
+ };
551
+ }
471
552
 
472
553
  // --- 5. MOTORE GRAFICO DI DISEGNO ---
473
554
  function drawCompassTicks() {
@@ -642,21 +723,23 @@
642
723
  radarDataList.push(activeRing);
643
724
 
644
725
  // 3. ANELLI da 2 a 7 (I 6 slot storici da 30 min passati salvati dal server, pari a 3 ore)
645
- for (let i = 1; i <= 6; i++) {
646
- const targetTimestamp = current30mSlot - (i * 1800000);
647
- const matchedSlot = windRadarSlots.find(s => s.timestamp === targetTimestamp);
648
-
649
- if (matchedSlot) {
650
- radarDataList.push({
651
- twdMin: Math.round(radToDeg(matchedSlot.twdMin)),
652
- twdMax: Math.round(radToDeg(matchedSlot.twdMax)),
653
- twsPeak: matchedSlot.twsPeak,
654
- isCalm: matchedSlot.twsPeak < CALM_THRESHOLD_KTS
655
- });
656
- } else {
657
- radarDataList.push(null);
658
- }
659
- }
726
+ for (let i = 1; i <= 6; i++) {
727
+ const targetTimestamp = current30mSlot - (i * 1800000);
728
+ const matchedSlot = windRadarSlots.find(s => s.timestamp === targetTimestamp);
729
+
730
+ if (matchedSlot) {
731
+ radarDataList.push({
732
+ twdMin: Math.round(radToDeg(matchedSlot.twdMin)),
733
+ twdMax: Math.round(radToDeg(matchedSlot.twdMax)),
734
+ twsPeak: matchedSlot.twsPeak,
735
+ // Chirurgico: Estraiamo il minimo storico con protezione di compatibilità se assente
736
+ twsMin: matchedSlot.twsMin !== undefined ? matchedSlot.twsMin : matchedSlot.twsPeak,
737
+ isCalm: matchedSlot.twsPeak < CALM_THRESHOLD_KTS
738
+ });
739
+ } else {
740
+ radarDataList.push(null);
741
+ }
742
+ }
660
743
 
661
744
  document.getElementById('debug-rings-count').innerText = radarDataList.filter(p => p !== null).length + "/8";
662
745
 
@@ -685,17 +768,47 @@
685
768
  }
686
769
 
687
770
  // Caso B: Arco Direzionale
688
- const grad = getChordAlignedGradient(gradId, radius, data.twsPeak, data.twdMin, data.twdMax);
689
- let strokeColor = '';
690
-
691
- if (grad.type === 'gradient') {
692
- defsContainer.innerHTML += grad.xml;
693
- strokeColor = grad.url;
694
- } else {
695
- strokeColor = grad.color;
696
- }
771
+ let strokeColor = '';
772
+
773
+ // Chirurgico: Definita la funzione di colorazione universale basata sulle soglie dei Reef di bordo
774
+ const getColorForSpeed = (tws) => {
775
+ const R1 = REEF1; const R2 = REEF2; const R3 = REEF3;
776
+ if (tws < R1 * 0.4) return '#ffffff'; // Bianco (Calma)
777
+ if (tws < R1 * 0.75) return '#00C851'; // Verde (Regolare)
778
+ if (tws < R1) return '#ff9800'; // Arancio (1° Reef Alert)
779
+ if (tws < R2) return '#ffaa00'; // Dorato (Transizione)
780
+ if (tws < R2 + (R3 - R2) * 0.5) return '#ff3b30'; // Rosso (2° Reef Alert)
781
+ return '#9c27b0'; // Viola (Storm / Temporale)
782
+ };
783
+
784
+ // Chirurgico: Estraiamo i limiti minimi e massimi dell'arco corrente
785
+ // Se è la previsione, usiamo il vento medio e le raffiche di Open-Meteo, altrimenti i minimi e i picchi in RAM
786
+ const baseTws = data.isFuture && futureForecast ? futureForecast.tws : (data.twsMin !== undefined ? data.twsMin : data.twsPeak);
787
+ const peakTws = data.isFuture && futureForecast ? futureForecast.gust : data.twsPeak;
788
+
789
+ const baseColor = getColorForSpeed(baseTws);
790
+ const peakColor = getColorForSpeed(peakTws);
791
+
792
+ if (baseColor !== peakColor) {
793
+ // Se c'è variabilità d'intensità (raffica), disegnamo il gradiente termico dinamico lungo l'arco (Min -> Max -> Min)
794
+ const startPt = polarToCartesian(200, 200, radius, data.twdMax);
795
+ const endPt = polarToCartesian(200, 200, radius, data.twdMin);
796
+
797
+ const xml = `
798
+ <linearGradient id="${gradId}" x1="${startPt.x.toFixed(1)}" y1="${startPt.y.toFixed(1)}" x2="${endPt.x.toFixed(1)}" y2="${endPt.y.toFixed(1)}" gradientUnits="userSpaceOnUse">
799
+ <stop offset="0%" stop-color="${baseColor}" />
800
+ <stop offset="50%" stop-color="${peakColor}" />
801
+ <stop offset="100%" stop-color="${baseColor}" />
802
+ </linearGradient>
803
+ `;
804
+ defsContainer.innerHTML += xml;
805
+ strokeColor = `url(#${gradId})`;
806
+ } else {
807
+ // Se il vento è costante (base e picco ricadono nella stessa fascia), usiamo il colore solido puro
808
+ strokeColor = baseColor;
809
+ }
697
810
 
698
- const pathData = describeArc(200, 200, radius, data.twdMin, data.twdMax);
811
+ const pathData = describeArc(200, 200, radius, data.twdMin, data.twdMax);
699
812
 
700
813
  // Chirurgico: Se è l'anello futuro (isFuture), non disegniamo il bordo nero per evitare artefatti "a doppia bolla"
701
814
  if (!data.isFuture) {
@@ -716,28 +829,58 @@
716
829
  mainPath.setAttribute("stroke-width", ARC_STROKE_WIDTH);
717
830
  mainPath.setAttribute("stroke-linecap", "round");
718
831
  // Chirurgico: Per l'anello futuro, usiamo un'opacità morbida al 50% (0.5) continua (senza tratteggio) per un effetto "glowing" pulitissimo
719
- mainPath.setAttribute("opacity", data.isFuture ? "0.5" : opacityValue);
720
- ringsContainer.appendChild(mainPath);
721
- });
722
-
723
- // Aggiorna il pannello di debug ad ogni ridisegno del radar
724
- updateDebugPanel();
725
- }
832
+ mainPath.setAttribute("opacity", data.isFuture ? "0.5" : opacityValue);
833
+ mainPath.id = data.isFuture ? "" : (index === 1 ? "active-present-arc" : ""); // Identifichiamo l'Anello 1 per calcoli geometrici
834
+ ringsContainer.appendChild(mainPath);
835
+ });
836
+
837
+ // --- Chirurgico: Motore di calcolo e posizionamento LED dinamici sulle punte dell'Anello 1 ---
838
+ if (activeRing && !activeRing.isCalm) {
839
+ const twdNow = getCircularAverageFromBuffer(store.twdMinuteBuffer, 60000, false, now); // 1 minuto fisso
840
+ const isNavigating = (store.raw["navigation.speedThroughWater"] || 0) > 0.25 || (store.raw["navigation.speedOverGround"] || 0) > 0.25;
841
+ const strategicWindowMs = (isNavigating ? 15 : 60) * 60000;
842
+ const twdRef = getCircularAverageFromBuffer(store.twdMinuteBuffer, strategicWindowMs, false, now);
843
+
844
+ if (twdNow && twdRef) {
845
+ let deltaMeteo = radToDeg((twdNow.val - twdRef.val + Math.PI * 3) % (Math.PI * 2) - Math.PI);
846
+
847
+ if (Math.abs(deltaMeteo) > 6.0) {
848
+ const isSouth = store.raw["navigation.position"] && store.raw["navigation.position"].latitude < 0;
849
+ let meteoColor = (!isSouth) ? (deltaMeteo < 0 ? "#00C851" : "#ff3b30") : (deltaMeteo > 0 ? "#00C851" : "#ff3b30");
850
+
851
+ // Calcoliamo la posizione geometrica esatta delle due punte dell'Anello 1 (raggio 67.2px)
852
+ const radiusAnello1 = ringRadii[1];
853
+ const angleTarget = deltaMeteo > 0 ? activeRing.twdMax : activeRing.twdMin; // Estremità destra (CW) o sinistra (CCW)
854
+ const pt = polarToCartesian(200, 200, radiusAnello1, angleTarget);
855
+
856
+ // Disegniamo il LED lampeggiante esattamente sulla punta dell'arco
857
+ const led = document.createElementNS("http://www.w3.org/2000/svg", "circle");
858
+ led.setAttribute("cx", pt.x.toFixed(1));
859
+ led.setAttribute("cy", pt.y.toFixed(1));
860
+ led.setAttribute("r", "5.5"); // Leggermente più spesso dell'arco per renderlo evidente
861
+ led.setAttribute("fill", meteoColor);
862
+ led.setAttribute("class", "is-trending"); // Attiva il lampeggio CSS
863
+ led.setAttribute("filter", "url(#center-glow)"); // Aggiunge un leggero bagliore visivo
864
+ ringsContainer.appendChild(led);
865
+ }
866
+ }
867
+ }
868
+
869
+ // Aggiorna il pannello di debug ad ogni ridisegno del radar
870
+ updateDebugPanel();
871
+ }
726
872
 
727
873
  // --- 6. CICLO DI VITA E AVVIO ---
728
- function init() {
729
- drawCompassTicks();
730
- connect();
731
-
732
- // Sincronizzazione strategica: scarica lo storico consolidato dal server ogni 30 secondi e ridisegna
733
- setInterval(async () => {
734
- await fetchConfigAndHistory();
735
- renderRadar();
736
- }, 30000);
737
- }
738
-
739
- window.onload = init;
740
-
874
+ function init() {
875
+ drawCompassTicks();
876
+ connect();
877
+
878
+ // Sincronizzazione strategica: scarica lo storico consolidato dal server ogni 15 secondi e ridisegna
879
+ setInterval(async () => {
880
+ await fetchConfigAndHistory();
881
+ renderRadar();
882
+ }, 15000);
883
+ }
741
884
  window.onload = init;
742
885
  </script>
743
886
  </body>
package/utils.js ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * ==========================================================================
3
+ * Sailing Dashboard Pro - Math, Conversions & Audio Utilities
4
+ * ==========================================================================
5
+ * Raccoglie le funzioni pure di calcolo vettoriale e sintesi sonora.
6
+ */
7
+
8
+ // --- 1. CONVERSIONI STANDARD ---
9
+ const radToDeg = (rad) => rad * (180 / Math.PI);
10
+ const degToRad = (deg) => deg * (Math.PI / 180);
11
+ const msToKts = (ms) => ms * 1.94384;
12
+ const ktsToMs = (kts) => kts / 1.94384;
13
+
14
+ // Calcola la rotta di rotazione più breve tra due angoli (evita scatti a 360°)
15
+ function getShortestRotation(curr, target) {
16
+ let diff = (target - curr) % 360;
17
+ if (diff > 180) diff -= 360;
18
+ else if (diff < -180) diff += 360;
19
+ return curr + diff;
20
+ }
21
+
22
+ // Scrive testo in sicurezza evitando reflow inutili nel DOM/SVG
23
+ function safeSetText(el, text) {
24
+ if (!el) return;
25
+ const isSVG = el instanceof SVGElement;
26
+ if (isSVG) {
27
+ if (el.textContent !== text) el.textContent = text;
28
+ } else {
29
+ if (el.innerHTML !== text) el.innerHTML = text;
30
+ }
31
+ }
32
+
33
+ // --- 2. MOTORE MATEMATICO: MEDIA CIRCOLARE VETTORIALE ---
34
+ function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false, now, stabilityThreshold = 0.95, stabilityBreakout = 15) {
35
+ now = now || Date.now();
36
+ const len = bufferArray.length;
37
+ if (len === 0) return null;
38
+
39
+ let sSin = 0, sCos = 0, count = 0;
40
+ let newestTime = 0, oldestTime = 0;
41
+
42
+ let pilotSin = 0, pilotCos = 0;
43
+ const pilotSamples = Math.min(len, 15);
44
+ for (let i = len - 1; i >= len - pilotSamples; i--) {
45
+ pilotSin += bufferArray[i].sin;
46
+ pilotCos += bufferArray[i].cos;
47
+ }
48
+ const pilotRad = Math.atan2(pilotSin, pilotCos);
49
+ const limitRad = stabilityBreakout * (Math.PI / 180);
50
+
51
+ for (let i = len - 1; i >= 0; i--) {
52
+ const item = bufferArray[i];
53
+ if ((now - item.time) > windowMs) break;
54
+
55
+ let diffRad = Math.atan2(Math.sin(item.val - pilotRad), Math.cos(item.val - pilotRad));
56
+ let finalSin, finalCos;
57
+
58
+ if (Math.abs(diffRad) > limitRad) {
59
+ const clampedDiff = Math.sign(diffRad) * limitRad;
60
+ const clampedRad = pilotRad + clampedDiff;
61
+ finalSin = Math.sin(clampedRad);
62
+ finalCos = Math.cos(clampedRad);
63
+ } else {
64
+ finalSin = item.sin;
65
+ finalCos = item.cos;
66
+ }
67
+
68
+ sSin += finalSin;
69
+ sCos += finalCos;
70
+
71
+ if (count === 0) newestTime = item.time;
72
+ oldestTime = item.time;
73
+ count++;
74
+ }
75
+
76
+ if (count === 0) return null;
77
+
78
+ const R = Math.hypot(sSin, sCos) / count;
79
+ const avgRad = Math.atan2(sSin, sCos);
80
+ const finalVal = signed ? avgRad : (avgRad + Math.PI * 2) % (Math.PI * 2);
81
+ const historyDuration = (count > 2) ? (newestTime - oldestTime) : 0;
82
+ const safeR = Math.max(R, 1e-9);
83
+
84
+ return {
85
+ val: finalVal,
86
+ stable: historyDuration > 10000 && R > stabilityThreshold,
87
+ dev: (R < 1) ? Math.round(Math.sqrt(-2 * Math.log(safeR)) * (180 / Math.PI)) : 0,
88
+ samples: count
89
+ };
90
+ }
91
+
92
+ // --- 3. SINTESI AUDIO WEB AUDIO API (ALLARMI ACUSTICI) ---
93
+ let audioCtx = null;
94
+ let lastAlarmTime = 0;
95
+
96
+ function playBingBing() {
97
+ if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
98
+ const n = Date.now();
99
+ if (n - lastAlarmTime < 3000) return;
100
+ lastAlarmTime = n;
101
+
102
+ function b(f, s) {
103
+ const o = audioCtx.createOscillator();
104
+ const g = audioCtx.createGain();
105
+ o.connect(g);
106
+ g.connect(audioCtx.destination);
107
+ o.frequency.value = f;
108
+ g.gain.setValueAtTime(0.1, s);
109
+ g.gain.exponentialRampToValueAtTime(0.01, s + 0.4);
110
+ o.start(s);
111
+ o.stop(s + 0.5);
112
+ }
113
+ b(880, audioCtx.currentTime);
114
+ b(880, audioCtx.currentTime + 0.6);
115
+ }
116
+
117
+ function playGybeAlarm() {
118
+ if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
119
+ const n = Date.now();
120
+ if (n - lastAlarmTime < 2000) return;
121
+ lastAlarmTime = n;
122
+
123
+ function note(f, s, d) {
124
+ const o = audioCtx.createOscillator();
125
+ const g = audioCtx.createGain();
126
+ o.connect(g);
127
+ g.connect(audioCtx.destination);
128
+ o.type = 'square';
129
+ o.frequency.value = f;
130
+ g.gain.setValueAtTime(0.05, s);
131
+ g.gain.exponentialRampToValueAtTime(0.001, s + d);
132
+ o.start(s);
133
+ o.stop(s + d);
134
+ }
135
+ for (let i = 0; i < 4; i++) {
136
+ note(1800, audioCtx.currentTime + (i * 0.15), 0.1);
137
+ note(1200, audioCtx.currentTime + (i * 0.15) + 0.07, 0.1);
138
+ }
139
+ }