@sailingrotevista/rotevista-dash 6.1.4 → 6.2.2
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 +53 -13
- package/index.html +0 -1
- package/index.js +360 -57
- package/package.json +1 -1
- package/radar copia.html +490 -0
- package/radar.html +747 -0
- package/style.css +5 -5
package/app.js
CHANGED
|
@@ -79,7 +79,7 @@ const store = {
|
|
|
79
79
|
herculesScales: {}, // Memoria per i limiti attivi della modalità Hercules
|
|
80
80
|
smoothBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
|
|
81
81
|
longBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
|
|
82
|
-
histories: { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [] },
|
|
82
|
+
histories: { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] },
|
|
83
83
|
graphTempBuf: { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [] },
|
|
84
84
|
lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0, aws: 0 }
|
|
85
85
|
};
|
|
@@ -121,8 +121,12 @@ function getShortestRotation(curr, target) {
|
|
|
121
121
|
*/
|
|
122
122
|
function safeSetText(el, text) {
|
|
123
123
|
if (!el) return;
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
// Se l'elemento è parte di un SVG, usiamo textContent, altrimenti innerHTML
|
|
125
|
+
const isSVG = el instanceof SVGElement;
|
|
126
|
+
if (isSVG) {
|
|
127
|
+
if (el.textContent !== text) el.textContent = text;
|
|
128
|
+
} else {
|
|
129
|
+
if (el.innerHTML !== text) el.innerHTML = text;
|
|
126
130
|
}
|
|
127
131
|
}
|
|
128
132
|
|
|
@@ -182,7 +186,8 @@ function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false, now
|
|
|
182
186
|
const clampedDiff = Math.sign(diffRad) * limitRad;
|
|
183
187
|
const clampedRad = pilotRad + clampedDiff;
|
|
184
188
|
finalSin = Math.sin(clampedRad);
|
|
185
|
-
|
|
189
|
+
const relativeCos = Math.cos(clampedRad);
|
|
190
|
+
finalCos = relativeCos;
|
|
186
191
|
} else {
|
|
187
192
|
finalSin = item.sin;
|
|
188
193
|
finalCos = item.cos;
|
|
@@ -343,13 +348,14 @@ function computeTrueWind() {
|
|
|
343
348
|
// Altrimenti eseguiamo il calcolo vettoriale tattico sull'acqua
|
|
344
349
|
tws_water = Math.sqrt(aws * aws + stw * stw - 2 * aws * stw * Math.cos(awa));
|
|
345
350
|
store.raw["environment.wind.speedTrue"] = tws_water;
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// BUG RISOLTO: Calcoliamo il TWA sempre, indipendentemente dal TWS nativo!
|
|
354
|
+
if (tws_water > 0.05) {
|
|
355
|
+
const twa = Math.atan2(aws * Math.sin(awa), aws * Math.cos(awa) - stw);
|
|
356
|
+
store.raw["environment.wind.angleTrueWater"] = twa;
|
|
357
|
+
safePush(store.smoothBuf.twa, twa, now);
|
|
358
|
+
safePush(store.longBuf.twa, twa, now);
|
|
353
359
|
}
|
|
354
360
|
|
|
355
361
|
// ==========================================================================
|
|
@@ -441,6 +447,19 @@ function processIncomingData(path, val, source, timeMs) {
|
|
|
441
447
|
safePush(store.smoothBuf.awa, val, now);
|
|
442
448
|
safePush(store.longBuf.awa, val, now);
|
|
443
449
|
}
|
|
450
|
+
|
|
451
|
+
// BUG RISOLTO: Intercetta il TWD nativo e lo spinge nei buffer della bussola radar
|
|
452
|
+
if (path === "environment.wind.directionTrue") {
|
|
453
|
+
let directionVal = (val && typeof val === 'object' && val.val !== undefined) ? val.val : val;
|
|
454
|
+
safePush(store.smoothBuf.twd, directionVal, now);
|
|
455
|
+
safePush(store.longBuf.twd, directionVal, now);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// BUG RISOLTO: Intercetta il TWS nativo e lo memorizza in tempo reale
|
|
459
|
+
if (path === "environment.wind.speedTrue") {
|
|
460
|
+
let speedVal = (val && typeof val === 'object' && val.val !== undefined) ? val.val : val;
|
|
461
|
+
store.raw["environment.wind.speedTrue"] = speedVal;
|
|
462
|
+
}
|
|
444
463
|
|
|
445
464
|
// --- GESTIONE PRUA VERA / MAGNETICA CON AUTODIVIAZIONE ---
|
|
446
465
|
if (path === "navigation.headingTrue") {
|
|
@@ -713,7 +732,7 @@ function startDisplayLoop() {
|
|
|
713
732
|
else if (drift > 0.3) ui.sog.style.setProperty('color', '#00C851', 'important'); // Favore
|
|
714
733
|
else ui.sog.style.setProperty('color', '#ffbb33', 'important'); // Neutro SOG
|
|
715
734
|
} else {
|
|
716
|
-
ui.sog.style.color
|
|
735
|
+
ui.sog.style.removeProperty('color');
|
|
717
736
|
}
|
|
718
737
|
}
|
|
719
738
|
}
|
|
@@ -1274,7 +1293,7 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
|
|
|
1274
1293
|
if (visibleData.length < 2) return;
|
|
1275
1294
|
|
|
1276
1295
|
const colDanger = "#ff3b30", colWarning = "#ff9800", colTws = "#2c3e50", colAws = "#5c6bc0";
|
|
1277
|
-
const colDepth = "#0088cc", colStw = "#00C851", colSog = "#ffbb33", colVmg = "#00b8d4";
|
|
1296
|
+
const colDepth = "#0088cc", colStw = "#00C851", colStwBorder = "#007a3d", colSog = "#ffbb33", colVmg = "#00b8d4";
|
|
1278
1297
|
|
|
1279
1298
|
const getColorProps = (val) => {
|
|
1280
1299
|
// Se siamo in Dual Screen (focus), aumentiamo lo spessore base della curva di un filino (da 1.6 a 2.2)
|
|
@@ -1589,6 +1608,27 @@ async function init() {
|
|
|
1589
1608
|
}
|
|
1590
1609
|
}
|
|
1591
1610
|
|
|
1611
|
+
// Watchdog per il risveglio dallo stato di sospensione / cambio scheda
|
|
1612
|
+
document.addEventListener('visibilitychange', () => {
|
|
1613
|
+
if (document.visibilityState === 'visible') {
|
|
1614
|
+
console.log("🔌 [Watchdog] Tab ritornato visibile. Verifica connessione...");
|
|
1615
|
+
|
|
1616
|
+
// Se il socket esiste ma non è attivo o se vogliamo forzare la pulizia delle connessioni fantasma:
|
|
1617
|
+
if (socket) {
|
|
1618
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
1619
|
+
// Se era già chiuso o in errore, proviamo a riconnettere subito
|
|
1620
|
+
connect();
|
|
1621
|
+
} else {
|
|
1622
|
+
// Se risulta "OPEN" ma potrebbe essere una connessione fantasma,
|
|
1623
|
+
// la chiudiamo forzatamente per scatenare la riconnessione pulita e il download della cronologia
|
|
1624
|
+
console.log("🔌 [Watchdog] Riavvio precauzionale del WebSocket per evitare connessioni fantasma.");
|
|
1625
|
+
socket.close();
|
|
1626
|
+
}
|
|
1627
|
+
} else {
|
|
1628
|
+
connect();
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
});
|
|
1592
1632
|
|
|
1593
1633
|
window.addEventListener('load', init);
|
|
1594
1634
|
window.addEventListener('pagehide', saveDashboardState);
|
package/index.html
CHANGED
package/index.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Definisce l'interfaccia di configurazione in Signal K Admin e crea
|
|
6
6
|
* gli endpoint pubblici per la Dashboard, mantenendo lo storico in RAM.
|
|
7
7
|
*/
|
|
8
|
+
const https = require('https'); // Importazione del modulo HTTPS nativo di Node.js
|
|
8
9
|
|
|
9
10
|
module.exports = function (app) {
|
|
10
11
|
const plugin = {};
|
|
@@ -16,43 +17,70 @@ module.exports = function (app) {
|
|
|
16
17
|
let routeRegistered = false;
|
|
17
18
|
let unsubscribes = [];
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
// ==========================================================================
|
|
21
|
+
// COSTANTI DI SVILUPPO (DEVELOPER CONFIG)
|
|
22
|
+
// ==========================================================================
|
|
23
|
+
const CALM_THRESHOLD_KTS = 1.5; // Soglia di calma piatta (anello a 360°)
|
|
24
|
+
const PRESSURE_FILTER_RATIO = 0.40; // Filtro di pressione dinamico (40% del picco per ignorare i cali)
|
|
25
|
+
|
|
26
|
+
// Database dello storico in RAM sul server (Sintonizzato Pro v6.0)
|
|
27
|
+
let histories = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
|
|
28
|
+
let graphTempBuf = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
|
|
29
|
+
let lastUpdates = { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0, aws: 0, twd: 0 };
|
|
30
|
+
let raw = {};
|
|
31
|
+
|
|
32
|
+
// Nuovo database dedicato per gli archi storici della bussola (6 ore = 12 slot)
|
|
33
|
+
let windRadarSlots = [];
|
|
34
|
+
|
|
35
|
+
// Memoria temporale per rilevare la presenza di sensori nativi sul Cerbo
|
|
36
|
+
let lastNativeTwsTime = 0;
|
|
37
|
+
let lastNativeTwdTime = 0;
|
|
38
|
+
|
|
39
|
+
// Monitoraggio dello scorrere dei blocchi da 30 minuti
|
|
40
|
+
let lastFrozen30mSlot = 0;
|
|
41
|
+
|
|
42
|
+
// Variabili dedicate al recupero e calcolo previsioni meteo future
|
|
43
|
+
let futureForecast = null; // Memorizza la previsione futura { timestamp, tws, twd }
|
|
44
|
+
let lastForecast30mSlot = 0; // Orario dell'ultimo blocco orologio scaricato con successo (es. 15:30)
|
|
27
45
|
|
|
28
46
|
/**
|
|
29
47
|
* plugin.start: Inizializza il plugin.
|
|
30
|
-
* Viene chiamato all'avvio e OGNI VOLTA che
|
|
48
|
+
* Viene chiamato all'avvio e OGNI VOLTA che si salva nella configurazione.
|
|
31
49
|
*/
|
|
32
50
|
plugin.start = function (options) {
|
|
33
51
|
// 1. Aggiorna la configurazione in memoria
|
|
34
52
|
currentConfig = options;
|
|
35
53
|
app.debug(`${plugin.name} started/updated with new options`);
|
|
36
54
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
// Reset dello storico al riavvio del plugin per evitare incoerenze (Sintonizzato Pro v6.0)
|
|
56
|
+
histories = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
|
|
57
|
+
graphTempBuf = { stw: [], sog: [], depth: [], tws: [], vmg: [], aws: [], twd: [] };
|
|
58
|
+
lastUpdates = { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0, aws: 0, twd: 0 };
|
|
59
|
+
raw = {};
|
|
60
|
+
windRadarSlots = []; // Reset degli archi storici della bussola
|
|
61
|
+
lastFrozen30mSlot = 0; // Reset del monitor temporale della bussola
|
|
62
|
+
futureForecast = null; // Reset della previsione meteo futura
|
|
63
|
+
lastForecast30mSlot = 0; // Reset del monitor temporale di scaricamento meteo
|
|
64
|
+
|
|
65
|
+
// 2. Registra le rotte API solo la prima volta (Abilitate per CORS remoto)
|
|
66
|
+
if (!routeRegistered) {
|
|
67
|
+
app.get('/rotevista-config', (req, res) => {
|
|
68
|
+
res.header("Access-Control-Allow-Origin", "*"); // Sblocca la Dashboard locale su Mac
|
|
69
|
+
res.json(currentConfig);
|
|
70
|
+
});
|
|
71
|
+
app.get('/rotevista-history', (req, res) => {
|
|
72
|
+
res.header("Access-Control-Allow-Origin", "*"); // Sblocca la Dashboard locale su Mac
|
|
73
|
+
// Costruiamo un pacchetto unificato che unisce i grafici classici, i dati radar e il futuro
|
|
74
|
+
const responseData = {
|
|
75
|
+
...histories,
|
|
76
|
+
windRadarSlots: windRadarSlots,
|
|
77
|
+
futureForecast: futureForecast
|
|
78
|
+
};
|
|
79
|
+
res.json(responseData);
|
|
80
|
+
});
|
|
81
|
+
routeRegistered = true;
|
|
82
|
+
app.debug('Public API endpoints registered at /rotevista-config and /rotevista-history');
|
|
83
|
+
}
|
|
56
84
|
|
|
57
85
|
// 3. Iscrizione ai dati dei sensori di bordo tramite Signal K
|
|
58
86
|
const localSubscription = {
|
|
@@ -63,6 +91,8 @@ module.exports = function (app) {
|
|
|
63
91
|
{ path: 'environment.depth.belowTransducer' },
|
|
64
92
|
{ path: 'environment.wind.speedApparent' },
|
|
65
93
|
{ path: 'environment.wind.angleApparent' },
|
|
94
|
+
{ path: 'environment.wind.speedTrue' }, // Aggiunto chirurgicamente
|
|
95
|
+
{ path: 'environment.wind.directionTrue' }, // Aggiunto chirurgicamente
|
|
66
96
|
{ path: 'navigation.headingTrue' },
|
|
67
97
|
{ path: 'navigation.headingMagnetic' },
|
|
68
98
|
{ path: 'navigation.magneticVariation' },
|
|
@@ -103,7 +133,7 @@ module.exports = function (app) {
|
|
|
103
133
|
app.debug(msg);
|
|
104
134
|
};
|
|
105
135
|
|
|
106
|
-
/**
|
|
136
|
+
/**
|
|
107
137
|
* processIncomingDelta: Decodifica i dati dei sensori in Knots/Meters ed esegue l'aggregazione
|
|
108
138
|
* Gestisce l'architettura "Nativo Prima, Fallback Dopo" per il vento reale.
|
|
109
139
|
*/
|
|
@@ -114,7 +144,17 @@ module.exports = function (app) {
|
|
|
114
144
|
const now = Date.now();
|
|
115
145
|
|
|
116
146
|
// 1. Cattura dei dati nativi (Se presenti, li scrive direttamente nello storico)
|
|
117
|
-
if (path === 'navigation.
|
|
147
|
+
if (path === 'navigation.position') {
|
|
148
|
+
// Trigger allineato all'orologio: calcoliamo il confine della mezz'ora corrente dell'orologio
|
|
149
|
+
if (val && val.latitude !== undefined && val.longitude !== undefined) {
|
|
150
|
+
const current30mSlot = Math.floor(Date.now() / 1800000) * 1800000;
|
|
151
|
+
// Se siamo entrati in una nuova mezz'ora di orologio dall'ultimo download, avviamo il fetch
|
|
152
|
+
if (current30mSlot > lastForecast30mSlot) {
|
|
153
|
+
fetchOpenMeteoForecast(val, current30mSlot);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else if (path === 'navigation.speedThroughWater') {
|
|
118
158
|
manageHistory('stw', val * 1.94384);
|
|
119
159
|
}
|
|
120
160
|
else if (path === 'navigation.speedOverGround') {
|
|
@@ -126,6 +166,9 @@ module.exports = function (app) {
|
|
|
126
166
|
else if (path === 'environment.wind.speedApparent') {
|
|
127
167
|
manageHistory('aws', val * 1.94384);
|
|
128
168
|
}
|
|
169
|
+
else if (path === 'environment.wind.angleApparent') {
|
|
170
|
+
raw[path] = val; // BUG RISOLTO: Acquisizione dell'AWA mancante inserita!
|
|
171
|
+
}
|
|
129
172
|
else if (path === 'environment.wind.speedTrue') {
|
|
130
173
|
lastNativeTwsTime = now; // Rilevato TWS nativo della centralina!
|
|
131
174
|
manageHistory('tws', val * 1.94384);
|
|
@@ -172,6 +215,7 @@ module.exports = function (app) {
|
|
|
172
215
|
// Calcoliamo il TWD di fallback solo se non abbiamo visto dati nativi negli ultimi 5 secondi
|
|
173
216
|
if (hdg !== undefined && (now - lastNativeTwdTime > 5000)) {
|
|
174
217
|
const twd = (hdg + twa + 2 * Math.PI) % (2 * Math.PI);
|
|
218
|
+
twd = (twd + 2 * Math.PI) % (2 * Math.PI); // Sicurezza extra
|
|
175
219
|
manageHistory('twd', twd);
|
|
176
220
|
}
|
|
177
221
|
}
|
|
@@ -188,15 +232,15 @@ module.exports = function (app) {
|
|
|
188
232
|
const samples = 60;
|
|
189
233
|
const bucketIntervalMs = (historyMinutes * 60000) / samples;
|
|
190
234
|
|
|
191
|
-
|
|
192
|
-
|
|
235
|
+
if (!graphTempBuf[type]) graphTempBuf[type] = [];
|
|
236
|
+
if (!histories[type]) histories[type] = [];
|
|
193
237
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
238
|
+
// SINTONIZZAZIONE DI FASE LATO SERVER (UTC Snap)
|
|
239
|
+
if (lastUpdates[type] === undefined || lastUpdates[type] === 0) {
|
|
240
|
+
lastUpdates[type] = Math.floor(now / bucketIntervalMs) * bucketIntervalMs;
|
|
241
|
+
}
|
|
198
242
|
|
|
199
|
-
|
|
243
|
+
const tempBuf = graphTempBuf[type];
|
|
200
244
|
|
|
201
245
|
// Anti-dropout dinamico sul vento forte
|
|
202
246
|
if ((type === 'tws' || type === 'aws') && value < 0.05 && tempBuf.length > 0) {
|
|
@@ -206,17 +250,67 @@ module.exports = function (app) {
|
|
|
206
250
|
if (lastPoint && lastPoint.val > glitchThreshold) return;
|
|
207
251
|
}
|
|
208
252
|
|
|
209
|
-
|
|
253
|
+
// Cattura la velocità del vento corrente (in m/s) al momento della lettura
|
|
254
|
+
const currentTws = raw['environment.wind.speedTrue'] || 0;
|
|
255
|
+
tempBuf.push({ val: value, tws: currentTws, time: now });
|
|
210
256
|
|
|
211
257
|
// Controllo avanzamento del secchiello temporale (Bucket)
|
|
212
258
|
const bucketReady = (now - lastUpdates[type] > bucketIntervalMs) || histories[type].length === 0;
|
|
213
259
|
if (!bucketReady) return;
|
|
214
260
|
|
|
215
261
|
let finalValue = value;
|
|
262
|
+
let isTwdType = (type === 'twd');
|
|
216
263
|
|
|
217
264
|
if (tempBuf.length > 0) {
|
|
218
|
-
//
|
|
219
|
-
|
|
265
|
+
// ==========================================================================
|
|
266
|
+
// CASO PARTICOLARE: DIREZIONE VENTO (TWD) -> MEDIA PESATA E LIMITI MIN/MAX
|
|
267
|
+
// ==========================================================================
|
|
268
|
+
if (isTwdType) {
|
|
269
|
+
// 1. Identifica il vento massimo del minuto (in m/s)
|
|
270
|
+
const twsVals = tempBuf.map(p => p.tws || 0);
|
|
271
|
+
const maxTwsInMinute = Math.max(...twsVals, 0);
|
|
272
|
+
|
|
273
|
+
// 2. Calcola la soglia dinamica di pressione: max tra 40% del picco e soglia di calma piatta (in m/s)
|
|
274
|
+
const calmThresholdMs = CALM_THRESHOLD_KTS / 1.94384;
|
|
275
|
+
const pressureThreshold = Math.max(maxTwsInMinute * PRESSURE_FILTER_RATIO, calmThresholdMs);
|
|
276
|
+
|
|
277
|
+
// 3. Esclude le direzioni registrate quando il vento era debole (sotto la soglia di pressione)
|
|
278
|
+
const activePoints = tempBuf.filter(p => (p.tws || 0) >= pressureThreshold);
|
|
279
|
+
const pointsToUse = activePoints.length > 0 ? activePoints : tempBuf; // Fallback se tutto è calma piatta
|
|
280
|
+
|
|
281
|
+
// 4. Media Vettoriale Pesata (TWS * sin, TWS * cos)
|
|
282
|
+
let sumSin = 0;
|
|
283
|
+
let sumCos = 0;
|
|
284
|
+
let totalWeight = 0;
|
|
285
|
+
pointsToUse.forEach(p => {
|
|
286
|
+
const weight = Math.max(p.tws || 0.1, 0.05); // Evita pesi a zero
|
|
287
|
+
sumSin += weight * Math.sin(p.val);
|
|
288
|
+
sumCos += weight * Math.cos(p.val);
|
|
289
|
+
totalWeight += weight;
|
|
290
|
+
});
|
|
291
|
+
const avgAngle = Math.atan2(sumSin, sumCos);
|
|
292
|
+
const finalAvg = (avgAngle + Math.PI * 2) % (Math.PI * 2);
|
|
293
|
+
|
|
294
|
+
// 5. Calcolo di Min e Max angolare del minuto (rispetto alla media per gestire l'oltrepasso di 0/360°)
|
|
295
|
+
let diffs = pointsToUse.map(p => {
|
|
296
|
+
let diff = p.val - finalAvg;
|
|
297
|
+
return Math.atan2(Math.sin(diff), Math.cos(diff)); // Srotolamento tra -PI e +PI
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const minDiff = Math.min(...diffs);
|
|
301
|
+
const maxDiff = Math.max(...diffs);
|
|
302
|
+
|
|
303
|
+
const finalMin = (finalAvg + minDiff + Math.PI * 2) % (Math.PI * 2);
|
|
304
|
+
const finalMax = (finalAvg + maxDiff + Math.PI * 2) % (Math.PI * 2);
|
|
305
|
+
|
|
306
|
+
finalValue = {
|
|
307
|
+
val: finalAvg,
|
|
308
|
+
min: finalMin,
|
|
309
|
+
max: finalMax
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
// A. VENTO VELOCITÀ -> SUSTAINED PEAK (EMA Time-Aware)
|
|
313
|
+
else if (type === 'tws' || type === 'aws') {
|
|
220
314
|
const tauMs = 2500;
|
|
221
315
|
let ema = tempBuf[0].val;
|
|
222
316
|
let maxSustained = ema;
|
|
@@ -234,7 +328,7 @@ module.exports = function (app) {
|
|
|
234
328
|
const vals = tempBuf.map(p => p.val).filter(v => isFinite(v));
|
|
235
329
|
if (vals.length > 0) finalValue = Math.min(...vals);
|
|
236
330
|
}
|
|
237
|
-
// C. VELOCITÀ -> MEDIA
|
|
331
|
+
// C. VELOCITÀ BARCA / ALTRO -> MEDIA
|
|
238
332
|
else {
|
|
239
333
|
const vals = tempBuf.map(p => p.val).filter(v => isFinite(v));
|
|
240
334
|
if (vals.length > 0) {
|
|
@@ -244,27 +338,47 @@ module.exports = function (app) {
|
|
|
244
338
|
}
|
|
245
339
|
}
|
|
246
340
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
341
|
+
// Validazione e clamping di sicurezza (non si applica all'oggetto TWD)
|
|
342
|
+
if (!isTwdType) {
|
|
343
|
+
if (!isFinite(finalValue)) return;
|
|
344
|
+
finalValue = Math.max(0, finalValue);
|
|
251
345
|
histories[type].push({ val: finalValue, time: now });
|
|
252
|
-
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
346
|
+
} else {
|
|
347
|
+
// Salvataggio specifico del TWD contenente l'oggetto { val, min, max, time }
|
|
348
|
+
histories['twd'].push({
|
|
349
|
+
val: finalValue.val,
|
|
350
|
+
min: finalValue.min,
|
|
351
|
+
max: finalValue.max,
|
|
352
|
+
time: now
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// --- TRIGGER DI CONGELAMENTO ARCO (Ogni :00 e :30 dell'orologio) ---
|
|
356
|
+
const current30mSlot = Math.floor(now / 1800000) * 1800000;
|
|
357
|
+
if (lastFrozen30mSlot === 0) {
|
|
358
|
+
lastFrozen30mSlot = current30mSlot; // Inizializzazione al primo avvio
|
|
359
|
+
} else if (current30mSlot > lastFrozen30mSlot) {
|
|
360
|
+
// Lo slot da 30 minuti precedente (lastFrozen30mSlot) si è appena concluso!
|
|
361
|
+
// Avviamo la procedura di compressione e congelamento dei dati di quel periodo
|
|
362
|
+
freeze30mSlot(lastFrozen30mSlot);
|
|
363
|
+
lastFrozen30mSlot = current30mSlot;
|
|
261
364
|
}
|
|
365
|
+
}
|
|
262
366
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
367
|
+
// Pruning automatico basato sulle impostazioni di timeline
|
|
368
|
+
// (Forziamo il server a conservare sempre almeno 60 minuti per il TWD!)
|
|
369
|
+
const limitMinutes = (type === 'twd') ? 60 : historyMinutes;
|
|
370
|
+
const maxViewportMinutes = limitMinutes * 2;
|
|
371
|
+
const maxHistoryMs = (maxViewportMinutes * 60000) + 60000;
|
|
372
|
+
|
|
373
|
+
while (histories[type].length > 0 && (now - histories[type][0].time) > maxHistoryMs) {
|
|
374
|
+
histories[type].shift();
|
|
266
375
|
}
|
|
267
376
|
|
|
377
|
+
graphTempBuf[type] = [];
|
|
378
|
+
// Spostiamo il timer esattamente al confine del secchiello assoluto appena concluso
|
|
379
|
+
lastUpdates[type] = Math.floor(now / bucketIntervalMs) * bucketIntervalMs;
|
|
380
|
+
}
|
|
381
|
+
|
|
268
382
|
/**
|
|
269
383
|
* plugin.schema: Definisce l'interfaccia grafica in Signal K Admin.
|
|
270
384
|
*/
|
|
@@ -427,6 +541,195 @@ module.exports = function (app) {
|
|
|
427
541
|
}
|
|
428
542
|
}
|
|
429
543
|
};
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* freeze30mSlot: Consolida e congela i dati di una specifica mezz'ora.
|
|
547
|
+
* Raccoglie i 30 record da 1 minuto, estrae i picchi, unisce gli angoli
|
|
548
|
+
* e applica la scrematura del percentile al 5% prima di salvare lo slot.
|
|
549
|
+
*/
|
|
550
|
+
function freeze30mSlot(slotTimestamp) {
|
|
551
|
+
const startTime = slotTimestamp;
|
|
552
|
+
const endTime = slotTimestamp + 1800000; // 30 minuti in millisecondi
|
|
553
|
+
|
|
554
|
+
// 1. Estrae i record storici del TWD e del TWS che ricadono in quella mezz'ora
|
|
555
|
+
const twdPoints = histories['twd'].filter(p => p.time >= startTime && p.time < endTime);
|
|
556
|
+
const twsPoints = histories['tws'].filter(p => p.time >= startTime && p.time < endTime);
|
|
557
|
+
|
|
558
|
+
if (twdPoints.length === 0) return; // Se non ci sono dati, salta lo slot
|
|
559
|
+
|
|
560
|
+
// 2. Calcola il vento massimo sostenuto (in nodi) registrato nel periodo
|
|
561
|
+
const twsVals = twsPoints.map(p => p.val).filter(v => isFinite(v));
|
|
562
|
+
const maxTws = twsVals.length > 0 ? Math.max(...twsVals) : 0;
|
|
563
|
+
|
|
564
|
+
// 3. Estrae tutti gli estremi angolari catturati minuto per minuto
|
|
565
|
+
let allAngles = [];
|
|
566
|
+
twdPoints.forEach(p => {
|
|
567
|
+
allAngles.push(p.val);
|
|
568
|
+
allAngles.push(p.min);
|
|
569
|
+
allAngles.push(p.max);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// 4. Calcola la direzione media vettoriale complessiva della mezz'ora per srotolare gli angoli
|
|
573
|
+
let sumSin = 0;
|
|
574
|
+
let sumCos = 0;
|
|
575
|
+
allAngles.forEach(a => {
|
|
576
|
+
sumSin += Math.sin(a);
|
|
577
|
+
sumCos += Math.cos(a);
|
|
578
|
+
});
|
|
579
|
+
const avgAngle = Math.atan2(sumSin, sumCos);
|
|
580
|
+
const finalAvg = (avgAngle + Math.PI * 2) % (Math.PI * 2);
|
|
581
|
+
|
|
582
|
+
// 5. Srotola gli angoli rispetto alla direzione media per gestire l'oltrepasso dello 0/360°
|
|
583
|
+
let diffs = allAngles.map(a => {
|
|
584
|
+
let diff = a - finalAvg;
|
|
585
|
+
return Math.atan2(Math.sin(diff), Math.cos(diff)); // Mappa tra -PI e +PI
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// 6. ORDINA LE DIFFERENZE ED APPLICA IL TAGLIO PERCENTILE DEL 5% PER LATO (TRIM)
|
|
589
|
+
diffs.sort((a, b) => a - b);
|
|
590
|
+
const trimCount = Math.floor(diffs.length * 0.05); // Scarta il 5% dei disturbi a sinistra e a destra
|
|
591
|
+
const activeDiffs = diffs.slice(trimCount, diffs.length - trimCount);
|
|
592
|
+
|
|
593
|
+
// Fallback se ci sono pochissimi campioni
|
|
594
|
+
const finalDiffs = activeDiffs.length > 0 ? activeDiffs : diffs;
|
|
595
|
+
|
|
596
|
+
const minDiff = Math.min(...finalDiffs);
|
|
597
|
+
const maxDiff = Math.max(...finalDiffs);
|
|
598
|
+
|
|
599
|
+
const finalMin = (finalAvg + minDiff + Math.PI * 2) % (Math.PI * 2);
|
|
600
|
+
const finalMax = (finalAvg + maxDiff + Math.PI * 2) % (Math.PI * 2);
|
|
601
|
+
|
|
602
|
+
// 7. Salva l'arco compresso e pulito nello store dedicato
|
|
603
|
+
windRadarSlots.push({
|
|
604
|
+
timestamp: startTime,
|
|
605
|
+
twdMin: finalMin,
|
|
606
|
+
twdMax: finalMax,
|
|
607
|
+
twsPeak: maxTws
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// Pruning: manteniamo in RAM solo le ultime 6 ore (12 slot)
|
|
611
|
+
while (windRadarSlots.length > 12) {
|
|
612
|
+
windRadarSlots.shift();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
app.debug(`💨 [Wind Radar] Locked 30m slot at ${new Date(startTime).toLocaleTimeString()}: TWD ${Math.round(finalMin * 180 / Math.PI)}°-${Math.round(finalMax * 180 / Math.PI)}°, TWS Peak ${maxTws.toFixed(1)} kts`);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* fetchOpenMeteoForecast: Recupera le previsioni orarie accoppiate (Seamless) da Open-Meteo.
|
|
620
|
+
* Utilizza il modello integrato /forecast per evitare zone d'ombra in rada, con tempo forzato in UTC.
|
|
621
|
+
*/
|
|
622
|
+
function fetchOpenMeteoForecast(position, current30mSlot) {
|
|
623
|
+
if (!position || position.latitude === undefined || position.longitude === undefined) return;
|
|
624
|
+
|
|
625
|
+
const lat = position.latitude;
|
|
626
|
+
const lon = position.longitude;
|
|
627
|
+
|
|
628
|
+
// Modello accoppiato Seamless basato su forecast, con vento espresso in nodi e orario in UTC (GMT)
|
|
629
|
+
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`;
|
|
630
|
+
|
|
631
|
+
lastForecast30mSlot = current30mSlot; // Aggiorna preventivamente lo slot per evitare chiamate simultanee in caso di rallentamento di rete
|
|
632
|
+
|
|
633
|
+
https.get(url, (res) => {
|
|
634
|
+
if (res.statusCode !== 200) {
|
|
635
|
+
app.error(`[Open-Meteo] HTTP Error: ${res.statusCode}`);
|
|
636
|
+
lastForecast30mSlot = 0; // Reset in caso di errore per permettere un tentativo al prossimo pacchetto GPS
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
let data = '';
|
|
641
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
642
|
+
res.on('end', () => {
|
|
643
|
+
try {
|
|
644
|
+
const parsed = JSON.parse(data);
|
|
645
|
+
if (!parsed.hourly || !parsed.hourly.time) {
|
|
646
|
+
app.error('[Open-Meteo] Invalid API response format');
|
|
647
|
+
lastForecast30mSlot = 0;
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const times = parsed.hourly.time;
|
|
652
|
+
const speeds = parsed.hourly.wind_speed_10m;
|
|
653
|
+
const directions = parsed.hourly.wind_direction_10m;
|
|
654
|
+
|
|
655
|
+
// Costruiamo la serie storica delle previsioni in formato UTC
|
|
656
|
+
const forecastList = [];
|
|
657
|
+
for (let i = 0; i < times.length; i++) {
|
|
658
|
+
// Forziamo il parsing UTC aggiungendo la dicitura 'Z' alla stringa ISO prodotta da Open-Meteo
|
|
659
|
+
const epoch = Date.parse(times[i] + "Z");
|
|
660
|
+
if (isNaN(epoch)) continue;
|
|
661
|
+
|
|
662
|
+
// Convertiamo la direzione del vento da gradi (0-360) a radianti (0-2PI)
|
|
663
|
+
const twdRad = (directions[i] * Math.PI) / 180;
|
|
664
|
+
|
|
665
|
+
forecastList.push({
|
|
666
|
+
time: epoch,
|
|
667
|
+
tws: speeds[i], // Già in nodi grazie ai parametri della chiamata
|
|
668
|
+
twd: twdRad
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Calcola l'interpolazione per la mezz'ora futura basandosi sullo slot corrente dell'orologio
|
|
673
|
+
calculateInterpolatedFuture(forecastList);
|
|
430
674
|
|
|
675
|
+
} catch (err) {
|
|
676
|
+
app.error(`[Open-Meteo] Error parsing JSON: ${err.message}`);
|
|
677
|
+
lastForecast30mSlot = 0;
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
}).on('error', (err) => {
|
|
681
|
+
app.error(`[Open-Meteo] Network Error: ${err.message}`);
|
|
682
|
+
lastForecast30mSlot = 0;
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* calculateInterpolatedFuture: Esegue l'interpolazione lineare e vettoriale circolare
|
|
688
|
+
* per trovare la previsione del vento tra 30 minuti esatti.
|
|
689
|
+
*/
|
|
690
|
+
function calculateInterpolatedFuture(forecastList) {
|
|
691
|
+
if (forecastList.length < 2) return;
|
|
692
|
+
|
|
693
|
+
const now = Date.now();
|
|
694
|
+
const targetTime = now + 1800000; // Calcoliamo la proiezione a +30 minuti nel futuro
|
|
695
|
+
|
|
696
|
+
let s1 = null;
|
|
697
|
+
let s2 = null;
|
|
698
|
+
|
|
699
|
+
// Individuiamo i due segmenti orari che racchiudono la nostra mezz'ora futura
|
|
700
|
+
for (let i = 0; i < forecastList.length; i++) {
|
|
701
|
+
const f = forecastList[i];
|
|
702
|
+
if (f.time <= targetTime) {
|
|
703
|
+
s1 = f;
|
|
704
|
+
}
|
|
705
|
+
if (f.time > targetTime) {
|
|
706
|
+
s2 = f;
|
|
707
|
+
break; // Trovato il limite superiore, usciamo
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (s1 && s2) {
|
|
712
|
+
const ratio = (targetTime - s1.time) / (s2.time - s1.time);
|
|
713
|
+
|
|
714
|
+
// 1. Interpolazione Lineare Velocità (TWS)
|
|
715
|
+
const interpolatedTws = s1.tws + (s2.tws - s1.tws) * ratio;
|
|
716
|
+
|
|
717
|
+
// 2. Interpolazione Circolare Vettoriale Direzione (TWD) per evitare l'effetto sfasamento a 0/360°
|
|
718
|
+
const diff = Math.atan2(Math.sin(s2.twd - s1.twd), Math.cos(s2.twd - s1.twd));
|
|
719
|
+
const interpolatedTwd = (s1.twd + diff * ratio + Math.PI * 2) % (Math.PI * 2);
|
|
720
|
+
|
|
721
|
+
// Salviamo le previsioni future nel server
|
|
722
|
+
futureForecast = {
|
|
723
|
+
timestamp: targetTime,
|
|
724
|
+
tws: interpolatedTws,
|
|
725
|
+
twd: interpolatedTwd
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
app.debug(`🔮 [Open-Meteo] Target forecast interpolated for ${new Date(targetTime).toLocaleTimeString()}: TWD ${Math.round(interpolatedTwd * 180 / Math.PI)}°, TWS ${interpolatedTws.toFixed(1)} kts`);
|
|
729
|
+
} else {
|
|
730
|
+
app.error('[Open-Meteo] Forecast matching slots not found for target time');
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
431
734
|
return plugin;
|
|
432
735
|
};
|