@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/app.js +107 -586
- package/charts.js +274 -0
- package/gauge.js +81 -0
- package/index.html +3 -1
- package/index.js +21 -19
- package/package.json +1 -1
- package/radar.html +262 -119
- package/utils.js +139 -0
package/radar.html
CHANGED
|
@@ -112,18 +112,28 @@
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
.status-badge {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
499
|
+
function calculateActive30mRing() {
|
|
500
|
+
const now = Date.now();
|
|
501
|
+
const start30m = now - 1800000;
|
|
423
502
|
|
|
424
|
-
|
|
425
|
-
|
|
503
|
+
const twdRecent = store.twdMinuteBuffer.filter(p => p.time >= start30m);
|
|
504
|
+
const twsRecent = store.twsMinuteBuffer.filter(p => p.time >= start30m);
|
|
426
505
|
|
|
427
|
-
|
|
506
|
+
if (twdRecent.length === 0) return null;
|
|
428
507
|
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
+
}
|