@sailingrotevista/rotevista-dash 4.0.19 → 4.0.20
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 +214 -59
- package/package.json +1 -1
- package/style.css +6 -0
package/app.js
CHANGED
|
@@ -17,7 +17,7 @@ let CONFIG = {
|
|
|
17
17
|
smoothWindow: 2000,
|
|
18
18
|
longWindow: 30000,
|
|
19
19
|
stabilityTolerance: 2000,
|
|
20
|
-
stabilityThreshold: 0.
|
|
20
|
+
stabilityThreshold: 0.9,
|
|
21
21
|
minSpeed: 0,
|
|
22
22
|
stabilityBreakout: 15
|
|
23
23
|
},
|
|
@@ -35,6 +35,8 @@ const RENDER_INTERVAL_MS = 1000;
|
|
|
35
35
|
const TIMEOUT_MS = 5000;
|
|
36
36
|
const SIM_SAMPLE_INTERVAL = 1000;
|
|
37
37
|
const DASH_VERSION = "2.4"; // Versione per la gestione della memoria locale
|
|
38
|
+
const sourceLocks = {};
|
|
39
|
+
|
|
38
40
|
|
|
39
41
|
// ==========================================================================
|
|
40
42
|
// 2. STATO GLOBALE E RIFERIMENTI UI
|
|
@@ -53,6 +55,10 @@ let twDirty = false, isNavigating = false, reconnectDelay = 1000;
|
|
|
53
55
|
|
|
54
56
|
let pressTimer, isFocusActive = false;
|
|
55
57
|
|
|
58
|
+
let emaTwaSin = 0;
|
|
59
|
+
let emaTwaCos = 0;
|
|
60
|
+
let firstEmaRun = true;
|
|
61
|
+
|
|
56
62
|
// Stato dei singoli grafici (Standard vs Hercules Zoom)
|
|
57
63
|
const graphModes = {
|
|
58
64
|
stw: 'standard',
|
|
@@ -110,39 +116,87 @@ function getShortestRotation(curr, target) {
|
|
|
110
116
|
/**
|
|
111
117
|
* Inserisce un dato nel buffer circolare limitandolo a 2000 campioni (30 min)
|
|
112
118
|
*/
|
|
113
|
-
function safePush(buffer, val, time, maxLen = 2000) {
|
|
114
|
-
buffer.push({ val: val, time: time });
|
|
115
|
-
if (buffer.length > maxLen) { buffer.shift(); }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
119
|
/**
|
|
119
|
-
*
|
|
120
|
+
* Inserimento sicuro nel buffer con trigonometria precaricata e pruning automatico
|
|
120
121
|
*/
|
|
121
|
-
function
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
validData.forEach(item => {
|
|
128
|
-
sSin += Math.sin(item.val);
|
|
129
|
-
sCos += Math.cos(item.val);
|
|
122
|
+
function safePush(buffer, val, time) {
|
|
123
|
+
buffer.push({
|
|
124
|
+
val: val,
|
|
125
|
+
time: time,
|
|
126
|
+
sin: Math.sin(val),
|
|
127
|
+
cos: Math.cos(val)
|
|
130
128
|
});
|
|
131
|
-
|
|
132
|
-
let R = Math.sqrt(sSin * sSin + sCos * sCos) / validData.length;
|
|
133
|
-
let historyDuration = (validData.length > 2) ? (validData[validData.length - 1].time - validData[0].time) : 0;
|
|
134
|
-
|
|
135
|
-
// Un dato è stabile se abbiamo abbastanza storia e la coerenza vettoriale R è alta
|
|
136
|
-
let isStable = (historyDuration > 10000) && (R > CONFIG.averaging.stabilityThreshold);
|
|
137
|
-
let avgRad = Math.atan2(sSin, sCos);
|
|
138
129
|
|
|
139
|
-
//
|
|
140
|
-
|
|
130
|
+
// Teniamo sempre in memoria il DOPPIO della storia impostata (per la modalità ancoraggio)
|
|
131
|
+
// + 1 minuto di margine
|
|
132
|
+
const maxHistoryMs = (CONFIG.graphs.historyMinutes * 60000 * 2) + 60000;
|
|
133
|
+
|
|
134
|
+
while (buffer.length > 0 && (time - buffer[0].time) > maxHistoryMs) {
|
|
135
|
+
buffer.shift();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Tetto massimo di campioni per sicurezza (circa 2 ore a 5Hz)
|
|
139
|
+
if (buffer.length > 36000) buffer.shift();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false, now) {
|
|
143
|
+
now = now || Date.now();
|
|
144
|
+
|
|
145
|
+
const len = bufferArray.length;
|
|
146
|
+
if (len === 0) return null;
|
|
147
|
+
|
|
148
|
+
let sSin = 0;
|
|
149
|
+
let sCos = 0;
|
|
150
|
+
let count = 0;
|
|
151
|
+
|
|
152
|
+
let newestTime = 0;
|
|
153
|
+
let oldestTime = 0;
|
|
154
|
+
|
|
155
|
+
for (let i = len - 1; i >= 0; i--) {
|
|
156
|
+
const item = bufferArray[i];
|
|
157
|
+
|
|
158
|
+
if ((now - item.time) > windowMs) break;
|
|
159
|
+
|
|
160
|
+
sSin += item.sin;
|
|
161
|
+
sCos += item.cos;
|
|
162
|
+
|
|
163
|
+
if (count === 0) newestTime = item.time;
|
|
164
|
+
oldestTime = item.time;
|
|
165
|
+
|
|
166
|
+
count++;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (count === 0) return null;
|
|
170
|
+
|
|
171
|
+
const R = Math.hypot(sSin, sCos) / count;
|
|
172
|
+
|
|
173
|
+
const avgRad = Math.atan2(sSin, sCos);
|
|
174
|
+
|
|
175
|
+
const finalVal = signed
|
|
176
|
+
? avgRad
|
|
177
|
+
: (avgRad + Math.PI * 2) % (Math.PI * 2);
|
|
178
|
+
|
|
179
|
+
const historyDuration =
|
|
180
|
+
(count > 2)
|
|
181
|
+
? (newestTime - oldestTime)
|
|
182
|
+
: 0;
|
|
183
|
+
|
|
184
|
+
const isStable =
|
|
185
|
+
historyDuration > 10000 &&
|
|
186
|
+
R > CONFIG.averaging.stabilityThreshold;
|
|
187
|
+
|
|
188
|
+
const safeR = Math.max(R, 1e-9);
|
|
141
189
|
|
|
142
190
|
return {
|
|
143
|
-
val:
|
|
191
|
+
val: finalVal,
|
|
144
192
|
stable: isStable,
|
|
145
|
-
dev:
|
|
193
|
+
dev: (R < 1)
|
|
194
|
+
? Math.round(
|
|
195
|
+
Math.sqrt(-2 * Math.log(safeR)) *
|
|
196
|
+
(180 / Math.PI)
|
|
197
|
+
)
|
|
198
|
+
: 0,
|
|
199
|
+
samples: count
|
|
146
200
|
};
|
|
147
201
|
}
|
|
148
202
|
|
|
@@ -291,14 +345,58 @@ function computeTrueWind() {
|
|
|
291
345
|
}
|
|
292
346
|
}
|
|
293
347
|
|
|
294
|
-
function processIncomingData(path, val) {
|
|
295
|
-
const now = Date.now();
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
348
|
+
function processIncomingData(path, val, source) {
|
|
349
|
+
const now = Date.now();
|
|
350
|
+
|
|
351
|
+
// --- 1. FILTRO SORGENTE STICKY (Elimina conflitti yacht_device vs Unknown) ---
|
|
352
|
+
if (!sourceLocks[path] || sourceLocks[path].label === source || (now - sourceLocks[path].lastSeen > 2000)) {
|
|
353
|
+
sourceLocks[path] = { label: source, lastSeen: now };
|
|
354
|
+
} else {
|
|
355
|
+
// Se arriva un dato da un'altra sorgente mentre il lock è attivo, lo scartiamo
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// --- 2. AGGIORNAMENTO DATI (Solo per la sorgente eletta) ---
|
|
360
|
+
store.timestamps[path] = now;
|
|
361
|
+
store.raw[path] = val;
|
|
362
|
+
|
|
363
|
+
// Buffer per AWA (Vento Apparente)
|
|
364
|
+
if (path === "environment.wind.angleApparent") {
|
|
365
|
+
safePush(store.smoothBuf.awa, val, now);
|
|
366
|
+
safePush(store.longBuf.awa, val, now);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Buffer per HDG (Prua)
|
|
370
|
+
if (path === "navigation.headingTrue") {
|
|
371
|
+
safePush(store.smoothBuf.hdg, val, now);
|
|
372
|
+
safePush(store.longBuf.hdg, val, now);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Buffer per COG (Rotta Fondo)
|
|
376
|
+
if (path === "navigation.courseOverGroundTrue") {
|
|
377
|
+
safePush(store.smoothBuf.cog, val, now);
|
|
378
|
+
safePush(store.longBuf.cog, val, now);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// --- 3. TRIGGER CALCOLO VENTO REALE (TWA/TWS/TWD) ---
|
|
382
|
+
const twPaths = [
|
|
383
|
+
"environment.wind.speedApparent",
|
|
384
|
+
"environment.wind.angleApparent",
|
|
385
|
+
"navigation.speedThroughWater",
|
|
386
|
+
"navigation.speedOverGround",
|
|
387
|
+
"navigation.headingTrue",
|
|
388
|
+
"navigation.courseOverGroundTrue"
|
|
389
|
+
];
|
|
390
|
+
|
|
391
|
+
if (twPaths.includes(path)) {
|
|
392
|
+
twDirty = true;
|
|
393
|
+
// Calcolo limitato a 10Hz per non pesare sulla CPU
|
|
394
|
+
if (twDirty && (now - lastTWCompute > 100)) {
|
|
395
|
+
computeTrueWind();
|
|
396
|
+
lastTWCompute = now;
|
|
397
|
+
twDirty = false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
302
400
|
}
|
|
303
401
|
|
|
304
402
|
// ==========================================================================
|
|
@@ -312,8 +410,9 @@ function updateWindTrend() {
|
|
|
312
410
|
|
|
313
411
|
// STRATEGIA (Bussola TWD): 1 min vs 30 minuti (tendenza meteo profonda)
|
|
314
412
|
const twdNow = getCircularAverageFromBuffer(store.longBuf.twd, 60000, false);
|
|
315
|
-
const
|
|
316
|
-
|
|
413
|
+
const multiplier = isNavigating ? 1 : 2;
|
|
414
|
+
const strategicWindowMs = CONFIG.graphs.historyMinutes * 60000 * multiplier;
|
|
415
|
+
const twdRef = getCircularAverageFromBuffer(store.longBuf.twd, strategicWindowMs, false);
|
|
317
416
|
if (!twaNow || !twaRef || !twdNow || !twdRef) return;
|
|
318
417
|
const compassDots = { cw: document.getElementById('trend-dot-cw'), ccw: document.getElementById('trend-dot-ccw') };
|
|
319
418
|
const gaugeDots = { cw: document.getElementById('trend-gauge-cw'), ccw: document.getElementById('trend-gauge-ccw') };
|
|
@@ -357,12 +456,41 @@ function updateWindTrend() {
|
|
|
357
456
|
}
|
|
358
457
|
} else { [gaugeDots.cw, gaugeDots.ccw].forEach(el => { if(el){ el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }}); }
|
|
359
458
|
|
|
360
|
-
// ALLARME STRAMBATA (GYBE)
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
if (
|
|
364
|
-
|
|
365
|
-
|
|
459
|
+
// --- LOGICA ALLARME STRAMBATA (GYBE) FILTRATA ---
|
|
460
|
+
const instTwaRad = store.raw["environment.wind.angleTrueWater"];
|
|
461
|
+
|
|
462
|
+
if (instTwaRad !== undefined) {
|
|
463
|
+
// Calcolo Alfa basato sulla Steering Precision (più precisione = filtro più lento e stabile)
|
|
464
|
+
const dynamicAlpha = Math.max(0.05, 1.1 - CONFIG.averaging.stabilityThreshold);
|
|
465
|
+
|
|
466
|
+
const currentSin = Math.sin(instTwaRad);
|
|
467
|
+
const currentCos = Math.cos(instTwaRad);
|
|
468
|
+
|
|
469
|
+
if (firstEmaRun) {
|
|
470
|
+
emaTwaSin = currentSin;
|
|
471
|
+
emaTwaCos = currentCos;
|
|
472
|
+
firstEmaRun = false;
|
|
473
|
+
} else {
|
|
474
|
+
// Media Esponenziale Vettoriale
|
|
475
|
+
emaTwaSin = (currentSin * dynamicAlpha) + (emaTwaSin * (1 - dynamicAlpha));
|
|
476
|
+
emaTwaCos = (currentCos * dynamicAlpha) + (emaTwaCos * (1 - dynamicAlpha));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Angolo risultante filtrato
|
|
480
|
+
const smoothedTwaDeg = radToDeg(Math.atan2(emaTwaSin, emaTwaCos));
|
|
481
|
+
|
|
482
|
+
// Verifichiamo il cambio di mure (> 155° e inversione di segno confermata dal filtro)
|
|
483
|
+
if (Math.abs(smoothedTwaDeg) > 155) {
|
|
484
|
+
if (Math.sign(smoothedTwaDeg) !== Math.sign(lastInstantTwa) && lastInstantTwa !== null) {
|
|
485
|
+
if (isNavigating && (now - lastGybeAlarmTime > 60000)) {
|
|
486
|
+
lastGybeAlarmTime = now;
|
|
487
|
+
playGybeAlarm();
|
|
488
|
+
console.log(`⚠️ GYBE ALARM: TWA ${smoothedTwaDeg.toFixed(1)}°`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
lastInstantTwa = smoothedTwaDeg;
|
|
493
|
+
}
|
|
366
494
|
}
|
|
367
495
|
|
|
368
496
|
// ==========================================================================
|
|
@@ -390,7 +518,7 @@ const upUI = (el, obj, instantRaw, isCompass = false) => {
|
|
|
390
518
|
} else {
|
|
391
519
|
let valDeg = Math.round(radToDeg(obj.val));
|
|
392
520
|
let mainVal = (isCompass ? ((valDeg + 360) % 360).toString().padStart(3, '0') : valDeg) + "°";
|
|
393
|
-
let dev = (obj.dev > 1 && obj.dev < 90) ? `<span style="font-size: 0.
|
|
521
|
+
let dev = (obj.dev > 1 && obj.dev < 90) ? `<span style="font-size: 0.8em; opacity: 0.4; margin-left: 6px;">±${obj.dev}</span>` : "";
|
|
394
522
|
el.innerHTML = mainVal + dev;
|
|
395
523
|
|
|
396
524
|
let diff = Math.abs((radToDeg(instantRaw) - radToDeg(obj.val) + 540) % 360 - 180);
|
|
@@ -512,30 +640,39 @@ function startDisplayLoop() {
|
|
|
512
640
|
upUI(ui.twaAvg, twObj, store.raw["environment.wind.angleTrueWater"], false);
|
|
513
641
|
upUI(ui.twdAvg, twdObj, store.raw["environment.wind.directionTrue"], true);
|
|
514
642
|
|
|
515
|
-
// --- LOGICA TACK STRATEGICA (
|
|
643
|
+
// --- LOGICA TACK STRATEGICA (VETTORIALE) ---
|
|
516
644
|
if (hObj && twdObj) {
|
|
517
|
-
|
|
518
|
-
const
|
|
645
|
+
// Funzione interna per riflettere un angolo rispetto all'asse del vento (TWD)
|
|
646
|
+
const reflectAngle = (targetRad, axisRad) => {
|
|
647
|
+
const diffSin = Math.sin(axisRad - targetRad);
|
|
648
|
+
const diffCos = Math.cos(axisRad - targetRad);
|
|
649
|
+
return Math.atan2(Math.sin(axisRad) * diffCos + Math.cos(axisRad) * diffSin,
|
|
650
|
+
Math.cos(axisRad) * diffCos - Math.sin(axisRad) * diffSin);
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
const unstableH = !hObj.stable || !twdObj.stable || hObj.dev > CONFIG.averaging.stabilityBreakout;
|
|
519
654
|
|
|
520
655
|
if (!isNavigating) {
|
|
521
|
-
ui.tackHdg.innerHTML = "---°";
|
|
656
|
+
ui.tackHdg.innerHTML = "---°";
|
|
522
657
|
} else if (unstableH) {
|
|
523
658
|
ui.tackHdg.innerHTML = "---°"; ui.tackHdg.classList.add('unstable-data');
|
|
524
659
|
} else {
|
|
525
|
-
|
|
660
|
+
const reflectedH = reflectAngle(hObj.val, twdObj.val);
|
|
661
|
+
const outH = (radToDeg(reflectedH) + 360) % 360;
|
|
662
|
+
ui.tackHdg.innerHTML = `${Math.round(outH).toString().padStart(3, '0')}°`;
|
|
526
663
|
ui.tackHdg.classList.remove('unstable-data');
|
|
527
664
|
}
|
|
528
665
|
|
|
529
666
|
if (cObj) {
|
|
530
|
-
const
|
|
531
|
-
const unstableC = !cObj.stable || !twdObj.stable || cObj.dev > CONFIG.averaging.stabilityBreakout || twdObj.dev > CONFIG.averaging.stabilityBreakout;
|
|
532
|
-
|
|
667
|
+
const unstableC = !cObj.stable || !twdObj.stable || cObj.dev > CONFIG.averaging.stabilityBreakout;
|
|
533
668
|
if (!isNavigating) {
|
|
534
|
-
ui.tackCog.innerHTML = "---°";
|
|
669
|
+
ui.tackCog.innerHTML = "---°";
|
|
535
670
|
} else if (unstableC) {
|
|
536
671
|
ui.tackCog.innerHTML = "---°"; ui.tackCog.classList.add('unstable-data');
|
|
537
672
|
} else {
|
|
538
|
-
|
|
673
|
+
const reflectedC = reflectAngle(cObj.val, twdObj.val);
|
|
674
|
+
const outC = (radToDeg(reflectedC) + 360) % 360;
|
|
675
|
+
ui.tackCog.innerHTML = `${Math.round(outC).toString().padStart(3, '0')}°`;
|
|
539
676
|
ui.tackCog.classList.remove('unstable-data');
|
|
540
677
|
}
|
|
541
678
|
}
|
|
@@ -691,10 +828,13 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
|
|
|
691
828
|
style="stroke: ${color}; stroke-width: ${strokeWidth}; stroke-linecap: round; shape-rendering: geometricPrecision;" />`;
|
|
692
829
|
|
|
693
830
|
if (i === 1) areaPath += `L ${x1} ${y1} `;
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
831
|
+
areaPath += `L ${x2} ${y2} `;
|
|
832
|
+
|
|
833
|
+
// Salviamo l'ultima coordinata X calcolata per chiudere correttamente il path
|
|
834
|
+
if (i === d.length - 1) {
|
|
835
|
+
areaPath += `L ${x2} ${h} Z`;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
698
838
|
|
|
699
839
|
// 4. Iniezione del Gradiente
|
|
700
840
|
const gradId = `grad-${id}`;
|
|
@@ -775,7 +915,22 @@ function connect() {
|
|
|
775
915
|
try {
|
|
776
916
|
socket = new WebSocket(`ws://${addr}/signalk/v1/stream?subscribe=self`);
|
|
777
917
|
socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; reconnectDelay = 1000; };
|
|
778
|
-
|
|
918
|
+
|
|
919
|
+
socket.onmessage = (e) => {
|
|
920
|
+
const d = JSON.parse(e.data);
|
|
921
|
+
if (d.updates) {
|
|
922
|
+
d.updates.forEach(u => {
|
|
923
|
+
// 1. Estraiamo il nome del sensore/sorgente (es. "yacht_device" o "Unknown")
|
|
924
|
+
const sourceLabel = u.source ? (u.source.label || u.source.talker || "Unknown") : "Unknown";
|
|
925
|
+
|
|
926
|
+
// 2. Passiamo il nome della sorgente come TERZO parametro
|
|
927
|
+
if (u.values) {
|
|
928
|
+
u.values.forEach(v => processIncomingData(v.path, v.value, sourceLabel));
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
|
|
779
934
|
socket.onclose = () => { if (!simulationMode) { ui.status.className = "offline"; ui.status.innerText = "RECONNECTING..."; setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 1.5, 10000); } };
|
|
780
935
|
} catch (e) { setTimeout(connect, reconnectDelay); }
|
|
781
936
|
}
|
package/package.json
CHANGED
package/style.css
CHANGED
|
@@ -122,6 +122,12 @@ body {
|
|
|
122
122
|
.box-twd #twd-avg { font-size: clamp(1.4rem, 45cqh, 20cqw) !important; }
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/* --- OTTIMIZZAZIONE SPECIFICA TWD PER EVITARE ANDATA A CAPO in landscape--- */
|
|
126
|
+
.box-twd .value-large {
|
|
127
|
+
/* Riduciamo il limite di larghezza (18cqw) per far spazio al simbolo ± rimpicciolendo il testo se serve */
|
|
128
|
+
font-size: clamp(1.2rem, 55cqh, 18cqw) !important;
|
|
129
|
+
}
|
|
130
|
+
|
|
125
131
|
/* --- BOX TACK (Mure Opposte) --- */
|
|
126
132
|
.box-tack .dual-value-container { display: flex; flex-direction: row; justify-content: space-between; align-items: center; width: 100%; height: 100%; }
|
|
127
133
|
.box-tack .dual-value-col { display: flex; flex-direction: column; justify-content: center; }
|