@sailingrotevista/rotevista-dash 6.2.6 → 6.2.8

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/index.js +88 -80
  2. package/package.json +1 -1
  3. package/radar.html +122 -83
package/index.js CHANGED
@@ -559,21 +559,22 @@ module.exports = function (app) {
559
559
  * Raccoglie i 30 record da 1 minuto, estrae i picchi, unisce gli angoli
560
560
  * e applica la scrematura del percentile al 5% prima di salvare lo slot.
561
561
  */
562
- function freeze30mSlot(slotTimestamp) {
563
- const startTime = slotTimestamp;
564
- const endTime = slotTimestamp + 1800000; // 30 minuti in millisecondi
562
+ function freeze30mSlot(slotTimestamp) {
563
+ const startTime = slotTimestamp;
564
+ const endTime = slotTimestamp + 1800000; // 30 minuti in millisecondi
565
565
 
566
- // 1. Estrae i record storici del TWD e del TWS che ricadono in quella mezz'ora
567
- const twdPoints = histories['twd'].filter(p => p.time >= startTime && p.time < endTime);
568
- const twsPoints = histories['tws'].filter(p => p.time >= startTime && p.time < endTime);
566
+ // 1. Estrae i record storici del TWD e del TWS che ricadono in quella mezz'ora
567
+ const twdPoints = histories['twd'].filter(p => p.time >= startTime && p.time < endTime);
568
+ const twsPoints = histories['tws'].filter(p => p.time >= startTime && p.time < endTime);
569
569
 
570
- if (twdPoints.length === 0) return; // Se non ci sono dati, salta lo slot
570
+ if (twdPoints.length === 0) return; // Se non ci sono dati, salta lo slot
571
571
 
572
- // 2. Calcola il vento massimo sostenuto (in nodi) registrato nel periodo
573
- const twsVals = twsPoints.map(p => p.val).filter(v => isFinite(v));
574
- const maxTws = twsVals.length > 0 ? Math.max(...twsVals) : 0;
572
+ // 2. Calcola il vento massimo sostenuto (in nodi) registrato nel periodo
573
+ const twsVals = twsPoints.map(p => p.val).filter(v => isFinite(v));
574
+ const maxTws = twsVals.length > 0 ? Math.max(...twsVals) : 0;
575
+ const minTws = twsVals.length > 0 ? Math.min(...twsVals) : 0; // Chirurgico: Calcoliamo il vento minimo del periodo
575
576
 
576
- // 3. Estrae tutti gli estremi angolari catturati minuto per minuto
577
+ // 3. Estrae tutti gli estremi angolari catturati minuto per minuto
577
578
  let allAngles = [];
578
579
  twdPoints.forEach(p => {
579
580
  allAngles.push(p.val);
@@ -611,15 +612,16 @@ module.exports = function (app) {
611
612
  const finalMin = (finalAvg + minDiff + Math.PI * 2) % (Math.PI * 2);
612
613
  const finalMax = (finalAvg + maxDiff + Math.PI * 2) % (Math.PI * 2);
613
614
 
614
- // 7. Salva l'arco compresso e pulito nello store dedicato
615
- windRadarSlots.push({
616
- timestamp: startTime,
617
- twdMin: finalMin,
618
- twdMax: finalMax,
619
- twsPeak: maxTws
620
- });
615
+ // 7. Salva l'arco compresso e pulito nello store dedicato
616
+ windRadarSlots.push({
617
+ timestamp: startTime,
618
+ twdMin: finalMin,
619
+ twdMax: finalMax,
620
+ twsPeak: maxTws,
621
+ twsMin: minTws // Chirurgico: Salviamo il vento minimo per poter tracciare la variabilità (Gust Factor)
622
+ });
621
623
 
622
- // Pruning: manteniamo in RAM solo le ultime 6 ore (12 slot)
624
+ // Pruning: manteniamo in RAM solo le ultime 6 ore (12 slot)
623
625
  while (windRadarSlots.length > 12) {
624
626
  windRadarSlots.shift();
625
627
  }
@@ -631,56 +633,58 @@ module.exports = function (app) {
631
633
  * fetchOpenMeteoForecast: Recupera le previsioni orarie accoppiate (Seamless) da Open-Meteo.
632
634
  * Utilizza il modello integrato /forecast per evitare zone d'ombra in rada, con tempo forzato in UTC.
633
635
  */
634
- function fetchOpenMeteoForecast(position, current30mSlot) {
635
- if (!position || position.latitude === undefined || position.longitude === undefined) return;
636
-
637
- const lat = position.latitude;
638
- const lon = position.longitude;
639
-
640
- // Modello accoppiato Seamless basato su forecast, con vento espresso in nodi e orario in UTC (GMT)
641
- const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&hourly=wind_speed_10m,wind_direction_10m&wind_speed_unit=kn&timezone=GMT&forecast_days=2`;
642
-
643
- lastForecast30mSlot = current30mSlot; // Aggiorna preventivamente lo slot per evitare chiamate simultanee in caso di rallentamento di rete
644
-
645
- https.get(url, (res) => {
646
- if (res.statusCode !== 200) {
647
- app.error(`[Open-Meteo] HTTP Error: ${res.statusCode}`);
648
- res.resume();
649
- lastForecast30mSlot = 0; // Reset in caso di errore per permettere un tentativo al prossimo pacchetto GPS
650
- return;
651
- }
652
-
653
- let data = '';
654
- res.on('data', (chunk) => { data += chunk; });
655
- res.on('end', () => {
656
- try {
657
- const parsed = JSON.parse(data);
658
- if (!parsed.hourly || !parsed.hourly.time) {
659
- app.error('[Open-Meteo] Invalid API response format');
660
- lastForecast30mSlot = 0;
636
+ function fetchOpenMeteoForecast(position, current30mSlot) {
637
+ if (!position || position.latitude === undefined || position.longitude === undefined) return;
638
+
639
+ const lat = position.latitude;
640
+ const lon = position.longitude;
641
+
642
+ // Chirurgico: Aggiunto wind_gusts_10m alla chiamata per ottenere l'intensità delle raffiche previste
643
+ const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&hourly=wind_speed_10m,wind_direction_10m,wind_gusts_10m&wind_speed_unit=kn&timezone=GMT&forecast_days=2`;
644
+
645
+ lastForecast30mSlot = current30mSlot; // Aggiorna preventivamente lo slot per evitare chiamate simultanee in caso di rallentamento di rete
646
+
647
+ https.get(url, (res) => {
648
+ if (res.statusCode !== 200) {
649
+ app.error(`[Open-Meteo] HTTP Error: ${res.statusCode}`);
650
+ res.resume();
651
+ lastForecast30mSlot = 0; // Reset in caso di errore per permettere un tentativo al prossimo pacchetto GPS
661
652
  return;
662
653
  }
663
654
 
664
- const times = parsed.hourly.time;
665
- const speeds = parsed.hourly.wind_speed_10m;
666
- const directions = parsed.hourly.wind_direction_10m;
667
-
668
- // Costruiamo la serie storica delle previsioni in formato UTC
669
- const forecastList = [];
670
- for (let i = 0; i < times.length; i++) {
671
- // Forziamo il parsing UTC aggiungendo la dicitura 'Z' alla stringa ISO prodotta da Open-Meteo
672
- const epoch = Date.parse(times[i] + "Z");
673
- if (isNaN(epoch)) continue;
674
-
675
- // Convertiamo la direzione del vento da gradi (0-360) a radianti (0-2PI)
676
- const twdRad = (directions[i] * Math.PI) / 180;
655
+ let data = '';
656
+ res.on('data', (chunk) => { data += chunk; });
657
+ res.on('end', () => {
658
+ try {
659
+ const parsed = JSON.parse(data);
660
+ if (!parsed.hourly || !parsed.hourly.time) {
661
+ app.error('[Open-Meteo] Invalid API response format');
662
+ lastForecast30mSlot = 0;
663
+ return;
664
+ }
677
665
 
678
- forecastList.push({
679
- time: epoch,
680
- tws: speeds[i], // Già in nodi grazie ai parametri della chiamata
681
- twd: twdRad
682
- });
683
- }
666
+ const times = parsed.hourly.time;
667
+ const speeds = parsed.hourly.wind_speed_10m;
668
+ const directions = parsed.hourly.wind_direction_10m;
669
+ const gusts = parsed.hourly.wind_gusts_10m || []; // Chirurgico: Catturiamo le raffiche dal payload JSON
670
+
671
+ // Costruiamo la serie storica delle previsioni in formato UTC
672
+ const forecastList = [];
673
+ for (let i = 0; i < times.length; i++) {
674
+ // Forziamo il parsing UTC aggiungendo la dicitura 'Z' alla stringa ISO prodotta da Open-Meteo
675
+ const epoch = Date.parse(times[i] + "Z");
676
+ if (isNaN(epoch)) continue;
677
+
678
+ // Convertiamo la direzione del vento da gradi (0-360) a radianti (0-2PI)
679
+ const twdRad = (directions[i] * Math.PI) / 180;
680
+
681
+ forecastList.push({
682
+ time: epoch,
683
+ tws: speeds[i], // Già in nodi grazie ai parametri della chiamata
684
+ twd: twdRad,
685
+ gust: gusts[i] !== undefined ? gusts[i] : speeds[i] // Chirurgico: Memorizziamo la raffica oraria (con fallback sulla velocità media)
686
+ });
687
+ }
684
688
 
685
689
  // Calcola l'interpolazione per la mezz'ora futura basandosi sullo slot corrente dell'orologio
686
690
  calculateInterpolatedFuture(forecastList);
@@ -721,25 +725,29 @@ module.exports = function (app) {
721
725
  }
722
726
  }
723
727
 
724
- if (s1 && s2) {
725
- const ratio = (targetTime - s1.time) / (s2.time - s1.time);
728
+ if (s1 && s2) {
729
+ const ratio = (targetTime - s1.time) / (s2.time - s1.time);
726
730
 
727
- // 1. Interpolazione Lineare Velocità (TWS)
728
- const interpolatedTws = s1.tws + (s2.tws - s1.tws) * ratio;
731
+ // 1. Interpolazione Lineare Velocità (TWS)
732
+ const interpolatedTws = s1.tws + (s2.tws - s1.tws) * ratio;
729
733
 
730
- // 2. Interpolazione Circolare Vettoriale Direzione (TWD) per evitare l'effetto sfasamento a 0/360°
731
- const diff = Math.atan2(Math.sin(s2.twd - s1.twd), Math.cos(s2.twd - s1.twd));
732
- const interpolatedTwd = (s1.twd + diff * ratio + Math.PI * 2) % (Math.PI * 2);
734
+ // 2. Interpolazione Circolare Vettoriale Direzione (TWD) per evitare l'effetto sfasamento a 0/360°
735
+ const diff = Math.atan2(Math.sin(s2.twd - s1.twd), Math.cos(s2.twd - s1.twd));
736
+ const interpolatedTwd = (s1.twd + diff * ratio + Math.PI * 2) % (Math.PI * 2);
733
737
 
734
- // Salviamo le previsioni future nel server
735
- futureForecast = {
736
- timestamp: targetTime,
737
- tws: interpolatedTws,
738
- twd: interpolatedTwd
739
- };
738
+ // 3. Interpolazione Lineare Raffiche (Gust)
739
+ const interpolatedGust = s1.gust + (s2.gust - s1.gust) * ratio; // Chirurgico: Calcolo interpolato della raffica futura
740
+
741
+ // Salviamo le previsioni future nel server
742
+ futureForecast = {
743
+ timestamp: targetTime,
744
+ tws: interpolatedTws,
745
+ twd: interpolatedTwd,
746
+ gust: interpolatedGust // Chirurgico: Aggiunta la raffica nel pacchetto dati della previsione
747
+ };
740
748
 
741
- app.debug(`🔮 [Open-Meteo] Target forecast interpolated for ${new Date(targetTime).toLocaleTimeString()}: TWD ${Math.round(interpolatedTwd * 180 / Math.PI)}°, TWS ${interpolatedTws.toFixed(1)} kts`);
742
- } else {
749
+ app.debug(`🔮 [Open-Meteo] Target forecast interpolated for ${new Date(targetTime).toLocaleTimeString()}: TWD ${Math.round(interpolatedTwd * 180 / Math.PI)}°, TWS ${interpolatedTws.toFixed(1)} kts (Gust: ${interpolatedGust.toFixed(1)} kts)`);
750
+ } else {
743
751
  app.error('[Open-Meteo] Forecast matching slots not found for target time');
744
752
  }
745
753
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sailingrotevista/rotevista-dash",
3
- "version": "6.2.6",
3
+ "version": "6.2.8",
4
4
  "description": "Wind Dashboard with navigation and course aids",
5
5
  "main": "index.js",
6
6
  "publishConfig": {
package/radar.html CHANGED
@@ -350,14 +350,16 @@
350
350
  }
351
351
 
352
352
  const historyRes = await fetch(getApiUrl('/rotevista-history'));
353
- if (historyRes.ok) {
354
- const data = await historyRes.json();
355
- if (data.windRadarSlots) windRadarSlots = data.windRadarSlots;
356
- if (data.futureForecast) {
357
- futureForecast = data.futureForecast;
358
- document.getElementById('debug-future').innerText = `TWD: ${Math.round(radToDeg(futureForecast.twd))}°, TWS: ${futureForecast.tws.toFixed(1)} kts`;
359
- }
360
- if (data['navigation.position']) {
353
+ if (historyRes.ok) {
354
+ const data = await historyRes.json();
355
+ if (data.windRadarSlots) windRadarSlots = data.windRadarSlots;
356
+ if (data.futureForecast) {
357
+ futureForecast = data.futureForecast;
358
+ // Chirurgico: Formattazione testuale avanzata che estrae ed espone le raffiche (gusts) previste
359
+ const gustVal = futureForecast.gust ? futureForecast.gust.toFixed(1) : futureForecast.tws.toFixed(1);
360
+ document.getElementById('debug-future').innerText = `TWD: ${Math.round(radToDeg(futureForecast.twd))}°, TWS: ${futureForecast.tws.toFixed(1)} kts (Gusts: ${gustVal} kts)`;
361
+ }
362
+ if (data['navigation.position']) {
361
363
  const val = data['navigation.position'];
362
364
  document.getElementById('debug-gps').innerText = val.latitude.toFixed(4) + '; ' + val.longitude.toFixed(4);
363
365
  }
@@ -415,21 +417,22 @@
415
417
  }
416
418
 
417
419
  // --- 4. MOTORE DI CALCOLO DELL'ANELLO 1 (Presente Mobile) ---
418
- function calculateActive30mRing() {
419
- const now = Date.now();
420
- const start30m = now - 1800000;
420
+ function calculateActive30mRing() {
421
+ const now = Date.now();
422
+ const start30m = now - 1800000;
421
423
 
422
- const twdRecent = store.twdMinuteBuffer.filter(p => p.time >= start30m);
423
- const twsRecent = store.twsMinuteBuffer.filter(p => p.time >= start30m);
424
+ const twdRecent = store.twdMinuteBuffer.filter(p => p.time >= start30m);
425
+ const twsRecent = store.twsMinuteBuffer.filter(p => p.time >= start30m);
424
426
 
425
- if (twdRecent.length === 0) return null;
427
+ if (twdRecent.length === 0) return null;
426
428
 
427
- const twsVals = twsRecent.map(p => p.val).filter(v => isFinite(v));
428
- const maxTws = twsVals.length > 0 ? Math.max(...twsVals) : 0;
429
+ const twsVals = twsRecent.map(p => p.val).filter(v => isFinite(v));
430
+ const maxTws = twsVals.length > 0 ? Math.max(...twsVals) : 0;
431
+ const minTws = twsVals.length > 0 ? Math.min(...twsVals) : 0; // Chirurgico: Troviamo il vento minimo reale degli ultimi 30 minuti
429
432
 
430
- if (maxTws < CALM_THRESHOLD_KTS) {
431
- return { twsPeak: maxTws, twdMin: 0, twdMax: 360, isCalm: true };
432
- }
433
+ if (maxTws < CALM_THRESHOLD_KTS) {
434
+ return { twsPeak: maxTws, twsMin: minTws, twdMin: 0, twdMax: 360, isCalm: true };
435
+ }
433
436
 
434
437
  let allAngles = [];
435
438
  twdRecent.forEach(p => {
@@ -457,15 +460,16 @@
457
460
  const maxDiff = Math.max(...finalDiffs);
458
461
 
459
462
  const finalMinDeg = Math.round(radToDeg((finalAvg + minDiff + Math.PI * 2) % (Math.PI * 2)));
460
- const finalMaxDeg = Math.round(radToDeg((finalAvg + maxDiff + Math.PI * 2) % (Math.PI * 2)));
461
-
462
- return {
463
- twdMin: finalMinDeg,
464
- twdMax: finalMaxDeg,
465
- twsPeak: maxTws,
466
- isCalm: false
467
- };
468
- }
463
+ const finalMaxDeg = Math.round(radToDeg((finalAvg + maxDiff + Math.PI * 2) % (Math.PI * 2)));
464
+
465
+ return {
466
+ twdMin: finalMinDeg,
467
+ twdMax: finalMaxDeg,
468
+ twsPeak: maxTws,
469
+ twsMin: minTws, // Chirurgico: Ritorniamo anche il valore minimo per abilitare la sfumatura sull'Anello 1
470
+ isCalm: false
471
+ };
472
+ }
469
473
 
470
474
  // --- 5. MOTORE GRAFICO DI DISEGNO ---
471
475
  function drawCompassTicks() {
@@ -622,15 +626,16 @@
622
626
  const radarDataList = [];
623
627
 
624
628
  // 1. ANELLO 0 (Centro - Previsione Futura Open-Meteo)
625
- if (futureForecast) {
626
- const twdDeg = Math.round(radToDeg(futureForecast.twd));
627
- radarDataList.push({
628
- twdMin: (twdDeg - 5 + 360) % 360,
629
- twdMax: (twdDeg + 5 + 360) % 360,
630
- twsPeak: futureForecast.tws,
631
- isFuture: true
632
- });
633
- } else {
629
+ if (futureForecast) {
630
+ const twdDeg = Math.round(radToDeg(futureForecast.twd));
631
+ radarDataList.push({
632
+ // Chirurgico: Impostata una finta ampiezza di 40 gradi (+-20°) per rappresentare l'area di incertezza meteo
633
+ twdMin: (twdDeg - 20 + 360) % 360,
634
+ twdMax: (twdDeg + 20 + 360) % 360,
635
+ twsPeak: futureForecast.tws,
636
+ isFuture: true
637
+ });
638
+ } else {
634
639
  radarDataList.push(null);
635
640
  }
636
641
 
@@ -639,21 +644,23 @@
639
644
  radarDataList.push(activeRing);
640
645
 
641
646
  // 3. ANELLI da 2 a 7 (I 6 slot storici da 30 min passati salvati dal server, pari a 3 ore)
642
- for (let i = 1; i <= 6; i++) {
643
- const targetTimestamp = current30mSlot - (i * 1800000);
644
- const matchedSlot = windRadarSlots.find(s => s.timestamp === targetTimestamp);
645
-
646
- if (matchedSlot) {
647
- radarDataList.push({
648
- twdMin: Math.round(radToDeg(matchedSlot.twdMin)),
649
- twdMax: Math.round(radToDeg(matchedSlot.twdMax)),
650
- twsPeak: matchedSlot.twsPeak,
651
- isCalm: matchedSlot.twsPeak < CALM_THRESHOLD_KTS
652
- });
653
- } else {
654
- radarDataList.push(null);
655
- }
656
- }
647
+ for (let i = 1; i <= 6; i++) {
648
+ const targetTimestamp = current30mSlot - (i * 1800000);
649
+ const matchedSlot = windRadarSlots.find(s => s.timestamp === targetTimestamp);
650
+
651
+ if (matchedSlot) {
652
+ radarDataList.push({
653
+ twdMin: Math.round(radToDeg(matchedSlot.twdMin)),
654
+ twdMax: Math.round(radToDeg(matchedSlot.twdMax)),
655
+ twsPeak: matchedSlot.twsPeak,
656
+ // Chirurgico: Estraiamo il minimo storico con protezione di compatibilità se assente
657
+ twsMin: matchedSlot.twsMin !== undefined ? matchedSlot.twsMin : matchedSlot.twsPeak,
658
+ isCalm: matchedSlot.twsPeak < CALM_THRESHOLD_KTS
659
+ });
660
+ } else {
661
+ radarDataList.push(null);
662
+ }
663
+ }
657
664
 
658
665
  document.getElementById('debug-rings-count').innerText = radarDataList.filter(p => p !== null).length + "/8";
659
666
 
@@ -682,37 +689,69 @@
682
689
  }
683
690
 
684
691
  // Caso B: Arco Direzionale
685
- const grad = getChordAlignedGradient(gradId, radius, data.twsPeak, data.twdMin, data.twdMax);
686
- let strokeColor = '';
687
-
688
- if (grad.type === 'gradient') {
689
- defsContainer.innerHTML += grad.xml;
690
- strokeColor = grad.url;
691
- } else {
692
- strokeColor = grad.color;
693
- }
694
-
695
- const pathData = describeArc(200, 200, radius, data.twdMin, data.twdMax);
696
-
697
- const borderPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
698
- borderPath.setAttribute("d", pathData);
699
- borderPath.setAttribute("fill", "none");
700
- borderPath.setAttribute("stroke", "#000000");
701
- borderPath.setAttribute("stroke-width", BORDER_STROKE_WIDTH);
702
- borderPath.setAttribute("stroke-linecap", "round");
703
- borderPath.setAttribute("opacity", opacityValue);
704
- if (data.isFuture) borderPath.setAttribute("stroke-dasharray", "10, 6");
705
- ringsContainer.appendChild(borderPath);
706
-
707
- const mainPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
708
- mainPath.setAttribute("d", pathData);
709
- mainPath.setAttribute("fill", "none");
710
- mainPath.setAttribute("stroke", strokeColor);
711
- mainPath.setAttribute("stroke-width", ARC_STROKE_WIDTH);
712
- mainPath.setAttribute("stroke-linecap", "round");
713
- mainPath.setAttribute("opacity", opacityValue);
714
- if (data.isFuture) mainPath.setAttribute("stroke-dasharray", "10, 6");
715
- ringsContainer.appendChild(mainPath);
692
+ let strokeColor = '';
693
+
694
+ // Chirurgico: Definita la funzione di colorazione universale basata sulle soglie dei Reef di bordo
695
+ const getColorForSpeed = (tws) => {
696
+ const R1 = REEF1; const R2 = REEF2; const R3 = REEF3;
697
+ if (tws < R1 * 0.4) return '#ffffff'; // Bianco (Calma)
698
+ if (tws < R1 * 0.75) return '#00C851'; // Verde (Regolare)
699
+ if (tws < R1) return '#ff9800'; // Arancio (1° Reef Alert)
700
+ if (tws < R2) return '#ffaa00'; // Dorato (Transizione)
701
+ if (tws < R2 + (R3 - R2) * 0.5) return '#ff3b30'; // Rosso (2° Reef Alert)
702
+ return '#9c27b0'; // Viola (Storm / Temporale)
703
+ };
704
+
705
+ // Chirurgico: Estraiamo i limiti minimi e massimi dell'arco corrente
706
+ // Se è la previsione, usiamo il vento medio e le raffiche di Open-Meteo, altrimenti i minimi e i picchi in RAM
707
+ const baseTws = data.isFuture && futureForecast ? futureForecast.tws : (data.twsMin !== undefined ? data.twsMin : data.twsPeak);
708
+ const peakTws = data.isFuture && futureForecast ? futureForecast.gust : data.twsPeak;
709
+
710
+ const baseColor = getColorForSpeed(baseTws);
711
+ const peakColor = getColorForSpeed(peakTws);
712
+
713
+ if (baseColor !== peakColor) {
714
+ // Se c'è variabilità d'intensità (raffica), disegnamo il gradiente termico dinamico lungo l'arco (Min -> Max -> Min)
715
+ const startPt = polarToCartesian(200, 200, radius, data.twdMax);
716
+ const endPt = polarToCartesian(200, 200, radius, data.twdMin);
717
+
718
+ const xml = `
719
+ <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">
720
+ <stop offset="0%" stop-color="${baseColor}" />
721
+ <stop offset="50%" stop-color="${peakColor}" />
722
+ <stop offset="100%" stop-color="${baseColor}" />
723
+ </linearGradient>
724
+ `;
725
+ defsContainer.innerHTML += xml;
726
+ strokeColor = `url(#${gradId})`;
727
+ } else {
728
+ // Se il vento è costante (base e picco ricadono nella stessa fascia), usiamo il colore solido puro
729
+ strokeColor = baseColor;
730
+ }
731
+
732
+ const pathData = describeArc(200, 200, radius, data.twdMin, data.twdMax);
733
+
734
+ // Chirurgico: Se è l'anello futuro (isFuture), non disegniamo il bordo nero per evitare artefatti "a doppia bolla"
735
+ if (!data.isFuture) {
736
+ const borderPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
737
+ borderPath.setAttribute("d", pathData);
738
+ borderPath.setAttribute("fill", "none");
739
+ borderPath.setAttribute("stroke", "#000000");
740
+ borderPath.setAttribute("stroke-width", BORDER_STROKE_WIDTH);
741
+ borderPath.setAttribute("stroke-linecap", "round");
742
+ borderPath.setAttribute("opacity", opacityValue);
743
+ ringsContainer.appendChild(borderPath);
744
+ }
745
+
746
+ const mainPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
747
+ mainPath.setAttribute("d", pathData);
748
+ mainPath.setAttribute("fill", "none");
749
+ mainPath.setAttribute("stroke", strokeColor);
750
+ mainPath.setAttribute("stroke-width", ARC_STROKE_WIDTH);
751
+ mainPath.setAttribute("stroke-linecap", "round");
752
+ // Chirurgico: Per l'anello futuro, usiamo un'opacità morbida al 50% (0.5) continua (senza tratteggio) per un effetto "glowing" pulitissimo
753
+ mainPath.setAttribute("opacity", data.isFuture ? "0.5" : opacityValue);
754
+ ringsContainer.appendChild(mainPath);
716
755
  });
717
756
 
718
757
  // Aggiorna il pannello di debug ad ogni ridisegno del radar