@sailingrotevista/rotevista-dash 3.0.5 → 4.0.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 +459 -278
- package/index.js +72 -54
- package/package.json +1 -1
- package/settings.json +15 -9
- package/style.css +86 -57
package/app.js
CHANGED
|
@@ -1,35 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ==========================================================================
|
|
3
|
+
* Signal K Wind Dashboard - Pro Version 2.4 (Definitive)
|
|
4
|
+
* ==========================================================================
|
|
5
|
+
* Autore: Sailing Rotevista
|
|
6
|
+
* Motore di calcolo tattico per navigazione e crociera.
|
|
7
|
+
* Gestisce: Medie Vettoriali, Deviazione Standard, Trend Strategico a 15min,
|
|
8
|
+
* Memoria UI persistente, Modalità Hercules e Focus Split Screen.
|
|
9
|
+
*/
|
|
10
|
+
|
|
1
11
|
// ==========================================================================
|
|
2
12
|
// 1. CONFIGURAZIONE E DEFAULT
|
|
3
13
|
// ==========================================================================
|
|
4
14
|
let CONFIG = {
|
|
5
|
-
alarms: {
|
|
6
|
-
|
|
15
|
+
alarms: {
|
|
16
|
+
depthDanger: 2.5,
|
|
17
|
+
depthWarning: 5.0
|
|
18
|
+
},
|
|
19
|
+
// Gestione parametri di stabilità e medie
|
|
7
20
|
averages: {
|
|
8
|
-
smoothWindow: 2000,
|
|
9
|
-
longWindow: 30000,
|
|
10
|
-
stabilityTolerance: 2000,
|
|
11
|
-
stabilityThreshold: 0.85,
|
|
12
|
-
minSpeed: 0
|
|
21
|
+
smoothWindow: 2000, // Smoothing puntatori (2s)
|
|
22
|
+
longWindow: 30000, // Finestra per i valori MEAN (30s)
|
|
23
|
+
stabilityTolerance: 2000, // Millisecondi per considerare il buffer "pieno"
|
|
24
|
+
stabilityThreshold: 0.85, // Soglia coerenza R per il lampeggio (0.7 - 0.98)
|
|
25
|
+
minSpeed: 0, // Nodi minimi per attivare gli allarmi di instabilità
|
|
26
|
+
stabilityBreakout: 15
|
|
27
|
+
},
|
|
28
|
+
// Parametri per i grafici sparkline
|
|
29
|
+
graphs: {
|
|
30
|
+
reef1: 15.0, // Soglia primo reef (Orange)
|
|
31
|
+
reef2: 20.0, // Soglia secondo reef (Red)
|
|
32
|
+
historyMinutes: 5, // Finestra temporale visualizzata
|
|
33
|
+
samples: 60 // Numero di punti di campionamento
|
|
13
34
|
},
|
|
14
|
-
|
|
35
|
+
// Configurazioni scale automatiche
|
|
15
36
|
scales: {
|
|
16
37
|
stw: { stdMax: 12, hercSpan: 4, step: 2 },
|
|
17
38
|
sog: { stdMax: 12, hercSpan: 4, step: 2 },
|
|
18
39
|
tws: { stdMax: 25, hercSpan: 10, step: 5 },
|
|
19
40
|
depth: { stdMax: 20, hercSpan: 10, step: 10 }
|
|
20
41
|
},
|
|
21
|
-
server: {
|
|
42
|
+
server: {
|
|
43
|
+
fallbackIp: "192.168.111.240:3000"
|
|
44
|
+
}
|
|
22
45
|
};
|
|
23
46
|
|
|
24
47
|
const RENDER_INTERVAL_MS = 1000;
|
|
25
48
|
const TIMEOUT_MS = 5000;
|
|
26
49
|
const SIM_SAMPLE_INTERVAL = 1000;
|
|
50
|
+
const DASH_VERSION = "2.4"; // Versione per la gestione della memoria locale
|
|
27
51
|
|
|
28
52
|
// ==========================================================================
|
|
29
53
|
// 2. STATO GLOBALE E RIFERIMENTI UI
|
|
30
54
|
// ==========================================================================
|
|
31
55
|
let simulationMode = false;
|
|
32
|
-
let displayModeSog = 'SOG';
|
|
56
|
+
let displayModeSog = 'SOG'; // Può essere 'SOG' o 'VMG'
|
|
33
57
|
let socket, renderInterval, simInterval;
|
|
34
58
|
let lastAvgUIUpdate = 0, audioCtx = null, lastAlarmTime = 0;
|
|
35
59
|
let curAwaRot = 0, curTwaRot = 0, curTrackRot = 0, curTwdRoseRot = 0;
|
|
@@ -42,23 +66,27 @@ let twDirty = false, isNavigating = false, reconnectDelay = 1000;
|
|
|
42
66
|
|
|
43
67
|
let pressTimer, isFocusActive = false;
|
|
44
68
|
|
|
69
|
+
// Stato dei singoli grafici (Standard vs Hercules Zoom)
|
|
45
70
|
const graphModes = {
|
|
46
|
-
stw:
|
|
47
|
-
sog:
|
|
48
|
-
tws:
|
|
49
|
-
depth:
|
|
71
|
+
stw: 'standard',
|
|
72
|
+
sog: 'standard',
|
|
73
|
+
tws: 'standard',
|
|
74
|
+
depth: 'standard'
|
|
50
75
|
};
|
|
51
76
|
|
|
77
|
+
// Database centrale dello store dati
|
|
52
78
|
const store = {
|
|
53
|
-
raw: {},
|
|
79
|
+
raw: {},
|
|
80
|
+
timestamps: {},
|
|
54
81
|
smoothBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
|
|
55
82
|
longBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
|
|
56
83
|
histories: { stw: [], sog: [], depth: [], tws: [], vmg: [] },
|
|
57
|
-
// Buffer temporaneo per
|
|
84
|
+
// Buffer temporaneo per il calcolo della media dell'intervallo del grafico
|
|
58
85
|
graphTempBuf: { stw: [], sog: [], depth: [], tws: [], vmg: [] },
|
|
59
86
|
lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0 }
|
|
60
87
|
};
|
|
61
88
|
|
|
89
|
+
// Riferimenti agli elementi DOM mappati all'avvio
|
|
62
90
|
const ui = {
|
|
63
91
|
stw: document.getElementById('stw'), sog: document.getElementById('sog'),
|
|
64
92
|
hdg: document.getElementById('hdg'), cog: document.getElementById('cog'),
|
|
@@ -75,43 +103,154 @@ const ui = {
|
|
|
75
103
|
};
|
|
76
104
|
|
|
77
105
|
// ==========================================================================
|
|
78
|
-
// 3. UTILITIES (MATEMATICA, BUFFER, AUDIO)
|
|
106
|
+
// 3. UTILITIES (MATEMATICA, BUFFER, AUDIO, MEMORIA)
|
|
79
107
|
// ==========================================================================
|
|
80
108
|
function radToDeg(rad) { return rad * (180 / Math.PI); }
|
|
81
109
|
function degToRad(deg) { return deg * (Math.PI / 180); }
|
|
82
110
|
function msToKts(ms) { return ms * 1.94384; }
|
|
83
111
|
function ktsToMs(kts) { return kts / 1.94384; }
|
|
84
|
-
function getShortestRotation(curr, target) { let diff = (target - curr) % 360; if (diff > 180) diff -= 360; else if (diff < -180) diff += 360; return curr + diff; }
|
|
85
112
|
|
|
86
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Calcola il percorso più breve per la rotazione di un puntatore (evita giri di 360°)
|
|
115
|
+
*/
|
|
116
|
+
function getShortestRotation(curr, target) {
|
|
117
|
+
let diff = (target - curr) % 360;
|
|
118
|
+
if (diff > 180) diff -= 360;
|
|
119
|
+
else if (diff < -180) diff += 360;
|
|
120
|
+
return curr + diff;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Inserisce un dato nel buffer circolare limitandolo a 2000 campioni (30 min)
|
|
125
|
+
*/
|
|
126
|
+
function safePush(buffer, val, time, maxLen = 2000) {
|
|
87
127
|
buffer.push({ val: val, time: time });
|
|
88
128
|
if (buffer.length > maxLen) { buffer.shift(); }
|
|
89
129
|
}
|
|
90
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Media Circolare Vettoriale: Calcola angolo medio, stabilità R e deviazione standard (±)
|
|
133
|
+
*/
|
|
91
134
|
function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false) {
|
|
92
135
|
const now = Date.now();
|
|
93
136
|
const validData = bufferArray.filter(item => (now - item.time) <= windowMs);
|
|
94
137
|
if (validData.length === 0) return null;
|
|
138
|
+
|
|
95
139
|
let sSin = 0, sCos = 0;
|
|
96
|
-
validData.forEach(item => {
|
|
140
|
+
validData.forEach(item => {
|
|
141
|
+
sSin += Math.sin(item.val);
|
|
142
|
+
sCos += Math.cos(item.val);
|
|
143
|
+
});
|
|
144
|
+
|
|
97
145
|
let R = Math.sqrt(sSin * sSin + sCos * sCos) / validData.length;
|
|
98
|
-
let
|
|
146
|
+
let historyDuration = (validData.length > 2) ? (validData[validData.length - 1].time - validData[0].time) : 0;
|
|
147
|
+
|
|
148
|
+
// Un dato è stabile se abbiamo abbastanza storia e la coerenza vettoriale R è alta
|
|
149
|
+
let isStable = (historyDuration > 10000) && (R > CONFIG.averages.stabilityThreshold);
|
|
99
150
|
let avgRad = Math.atan2(sSin, sCos);
|
|
100
|
-
|
|
151
|
+
|
|
152
|
+
// Calcolo della Deviazione Standard Circolare (±) in gradi
|
|
153
|
+
let deviation = (R < 1 && R > 0) ? Math.round(Math.sqrt(-2 * Math.log(R)) * (180 / Math.PI)) : 0;
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
val: signed ? avgRad : (avgRad + 2 * Math.PI) % (2 * Math.PI),
|
|
157
|
+
stable: isStable,
|
|
158
|
+
dev: deviation
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Salva lo stato attuale della dashboard (dati e preferenze UI) nel browser
|
|
164
|
+
*/
|
|
165
|
+
function saveDashboardState() {
|
|
166
|
+
try {
|
|
167
|
+
const focusedBox = document.querySelector('.data-box.is-focused');
|
|
168
|
+
let focusedType = null;
|
|
169
|
+
if (focusedBox) {
|
|
170
|
+
const match = focusedBox.className.match(/box-([a-z]+)/);
|
|
171
|
+
if (match) focusedType = match[1];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const state = {
|
|
175
|
+
version: DASH_VERSION,
|
|
176
|
+
histories: store.histories,
|
|
177
|
+
longBuf: store.longBuf,
|
|
178
|
+
displayModeSog: displayModeSog,
|
|
179
|
+
graphModes: graphModes,
|
|
180
|
+
isNightMode: document.body.classList.contains('night-mode'),
|
|
181
|
+
isFocusActive: isFocusActive,
|
|
182
|
+
focusedBoxType: focusedType,
|
|
183
|
+
timestamp: Date.now()
|
|
184
|
+
};
|
|
185
|
+
localStorage.setItem('rotevista_dash_state', JSON.stringify(state));
|
|
186
|
+
} catch (e) { console.error("Save error:", e); }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Carica e ripristina lo stato salvato (entro i 20 minuti di vecchiaia)
|
|
191
|
+
*/
|
|
192
|
+
function loadDashboardState() {
|
|
193
|
+
const saved = localStorage.getItem('rotevista_dash_state');
|
|
194
|
+
if (!saved) return;
|
|
195
|
+
try {
|
|
196
|
+
const state = JSON.parse(saved);
|
|
197
|
+
if (state.version !== DASH_VERSION) { localStorage.removeItem('rotevista_dash_state'); return; }
|
|
198
|
+
|
|
199
|
+
if ((Date.now() - state.timestamp) / 60000 < 20) {
|
|
200
|
+
if (state.histories) Object.assign(store.histories, state.histories);
|
|
201
|
+
if (state.longBuf) Object.assign(store.longBuf, state.longBuf);
|
|
202
|
+
if (state.graphModes) Object.assign(graphModes, state.graphModes);
|
|
203
|
+
|
|
204
|
+
// Ripristino SOG/VMG
|
|
205
|
+
if (state.displayModeSog) {
|
|
206
|
+
displayModeSog = state.displayModeSog;
|
|
207
|
+
const labelEl = document.getElementById('sog-vmg-label');
|
|
208
|
+
if (labelEl) labelEl.textContent = displayModeSog;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Ripristino Tema Notte
|
|
212
|
+
if (state.isNightMode) document.body.classList.add('night-mode');
|
|
213
|
+
|
|
214
|
+
// Ripristino Focus (Dual Screen)
|
|
215
|
+
if (state.isFocusActive && state.focusedBoxType) {
|
|
216
|
+
setTimeout(() => {
|
|
217
|
+
const el = document.querySelector(`.box-${state.focusedBoxType}`);
|
|
218
|
+
if (el) { isFocusActive = false; toggleFocusMode(state.focusedBoxType, el); }
|
|
219
|
+
}, 200);
|
|
220
|
+
}
|
|
221
|
+
console.log("Stato ripristinato con successo dalla cache.");
|
|
222
|
+
}
|
|
223
|
+
} catch (e) { localStorage.removeItem('rotevista_dash_state'); }
|
|
101
224
|
}
|
|
102
225
|
|
|
226
|
+
// ==========================================================================
|
|
227
|
+
// 4. AUDIO E ALLARMI
|
|
228
|
+
// ==========================================================================
|
|
103
229
|
function playBingBing() {
|
|
104
230
|
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
105
231
|
const n = Date.now(); if (n - lastAlarmTime < 3000) return; lastAlarmTime = n;
|
|
106
|
-
function b(f, s) {
|
|
232
|
+
function b(f, s) {
|
|
233
|
+
const o = audioCtx.createOscillator(); const g = audioCtx.createGain();
|
|
234
|
+
o.connect(g); g.connect(audioCtx.destination); o.frequency.value = f;
|
|
235
|
+
g.gain.setValueAtTime(0.1, s); g.gain.exponentialRampToValueAtTime(0.01, s + 0.4);
|
|
236
|
+
o.start(s); o.stop(s + 0.5);
|
|
237
|
+
}
|
|
107
238
|
b(880, audioCtx.currentTime); b(880, audioCtx.currentTime + 0.6);
|
|
108
239
|
}
|
|
109
240
|
|
|
110
241
|
function playGybeAlarm() {
|
|
111
242
|
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
112
243
|
const n = Date.now(); if (n - lastAlarmTime < 2000) return; lastAlarmTime = n;
|
|
113
|
-
function note(f, s, d) {
|
|
114
|
-
|
|
244
|
+
function note(f, s, d) {
|
|
245
|
+
const o = audioCtx.createOscillator(); const g = audioCtx.createGain();
|
|
246
|
+
o.connect(g); g.connect(audioCtx.destination); o.type = 'square';
|
|
247
|
+
o.frequency.value = f; g.gain.setValueAtTime(0.05, s);
|
|
248
|
+
g.gain.exponentialRampToValueAtTime(0.001, s + d); o.start(s); o.stop(s + d);
|
|
249
|
+
}
|
|
250
|
+
for (let i = 0; i < 4; i++) {
|
|
251
|
+
note(1800, audioCtx.currentTime + (i * 0.15), 0.1);
|
|
252
|
+
note(1200, audioCtx.currentTime + (i * 0.15) + 0.07, 0.1);
|
|
253
|
+
}
|
|
115
254
|
}
|
|
116
255
|
|
|
117
256
|
function checkDepthAlarm(m) {
|
|
@@ -127,270 +266,337 @@ function updateLeewayDisplay(deg) {
|
|
|
127
266
|
}
|
|
128
267
|
|
|
129
268
|
// ==========================================================================
|
|
130
|
-
//
|
|
269
|
+
// 5. MOTORE DI CALCOLO VENTO E DATA ROUTING
|
|
131
270
|
// ==========================================================================
|
|
132
271
|
function computeTrueWind() {
|
|
133
|
-
const aws = store.raw["environment.wind.speedApparent"];
|
|
134
|
-
let awa = store.raw["environment.wind.angleApparent"];
|
|
272
|
+
const aws = store.raw["environment.wind.speedApparent"], awa = store.raw["environment.wind.angleApparent"];
|
|
135
273
|
const stw = store.raw["navigation.speedThroughWater"] || 0, sog = store.raw["navigation.speedOverGround"] || 0;
|
|
136
274
|
const hdg = store.raw["navigation.headingTrue"] || 0, cog = store.raw["navigation.courseOverGroundTrue"] || 0;
|
|
137
|
-
|
|
138
275
|
if (aws === undefined || awa === undefined) return;
|
|
139
|
-
if (awa > Math.PI) awa -= 2 * Math.PI;
|
|
140
276
|
|
|
277
|
+
// Vento Reale Rispetto all'acqua (TWA/TWS Water)
|
|
141
278
|
const tw_water_x = aws * Math.cos(awa) - stw, tw_water_y = aws * Math.sin(awa);
|
|
142
279
|
const tws_water = Math.sqrt(tw_water_x * tw_water_x + tw_water_y * tw_water_y);
|
|
143
280
|
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
const tw_ground_x = aws * Math.cos(awa) -
|
|
281
|
+
// Vento Reale Rispetto al fondo (TWD Ground)
|
|
282
|
+
const drift = (cog - hdg + Math.PI * 3) % (2 * Math.PI) - Math.PI;
|
|
283
|
+
const tw_ground_x = aws * Math.cos(awa) - sog * Math.cos(drift), tw_ground_y = aws * Math.sin(awa) - sog * Math.sin(drift);
|
|
147
284
|
const tws_ground = Math.sqrt(tw_ground_x * tw_ground_x + tw_ground_y * tw_ground_y);
|
|
148
285
|
|
|
149
286
|
const now = Date.now();
|
|
150
287
|
store.raw["environment.wind.speedTrue"] = tws_water;
|
|
151
|
-
|
|
152
288
|
if (tws_water > 0.05) {
|
|
153
|
-
const
|
|
154
|
-
store.raw["environment.wind.angleTrueWater"] =
|
|
155
|
-
safePush(store.smoothBuf.twa,
|
|
289
|
+
const twa = Math.atan2(tw_water_y, tw_water_x);
|
|
290
|
+
store.raw["environment.wind.angleTrueWater"] = twa;
|
|
291
|
+
safePush(store.smoothBuf.twa, twa, now); safePush(store.longBuf.twa, twa, now);
|
|
156
292
|
}
|
|
157
293
|
if (tws_ground > 0.05) {
|
|
158
|
-
let
|
|
159
|
-
store.raw["environment.wind.directionTrue"] =
|
|
160
|
-
safePush(store.smoothBuf.twd,
|
|
294
|
+
let twd = (hdg + Math.atan2(tw_ground_y, tw_ground_x) + 2 * Math.PI) % (2 * Math.PI);
|
|
295
|
+
store.raw["environment.wind.directionTrue"] = twd;
|
|
296
|
+
safePush(store.smoothBuf.twd, twd, now); safePush(store.longBuf.twd, twd, now);
|
|
161
297
|
}
|
|
162
298
|
}
|
|
163
299
|
|
|
164
300
|
function processIncomingData(path, val) {
|
|
165
301
|
const now = Date.now(); store.timestamps[path] = now; store.raw[path] = val;
|
|
166
|
-
if (path === "navigation.position") store.raw["navigation.position"] = val;
|
|
167
302
|
if (path === "environment.wind.angleApparent") { safePush(store.smoothBuf.awa, val, now); safePush(store.longBuf.awa, val, now); }
|
|
168
|
-
|
|
169
303
|
const twPaths = ["environment.wind.speedApparent", "environment.wind.angleApparent", "navigation.speedThroughWater", "navigation.speedOverGround", "navigation.headingTrue", "navigation.courseOverGroundTrue"];
|
|
170
304
|
if (twPaths.includes(path)) twDirty = true;
|
|
171
305
|
if (twDirty && (now - lastTWCompute > 100)) { computeTrueWind(); lastTWCompute = now; twDirty = false; }
|
|
172
|
-
|
|
173
306
|
if (path === "navigation.headingTrue") { safePush(store.smoothBuf.hdg, val, now); safePush(store.longBuf.hdg, val, now); }
|
|
174
307
|
if (path === "navigation.courseOverGroundTrue") { safePush(store.smoothBuf.cog, val, now); safePush(store.longBuf.cog, val, now); }
|
|
175
308
|
}
|
|
176
309
|
|
|
177
310
|
// ==========================================================================
|
|
178
|
-
//
|
|
311
|
+
// 6. TREND VENTO (Tattico 2s vs 10s | Strategico 1m vs 10m)
|
|
179
312
|
// ==========================================================================
|
|
180
|
-
/**
|
|
181
|
-
* Analizza i trend di rotazione del vento su due scale temporali:
|
|
182
|
-
* 1. Tattica (veloce): per la regolazione delle vele (sulla lancetta TWA)
|
|
183
|
-
* 2. Strategica (lenta): per le previsioni meteo a lungo termine (bussola TWD)
|
|
184
|
-
*/
|
|
185
313
|
function updateWindTrend() {
|
|
186
314
|
const now = Date.now();
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
// ALLARME STRAMBATA
|
|
198
|
-
const gybeDetected = (Math.abs(instantTwaDeg) > 155 && Math.sign(instantTwaDeg) !== Math.sign(lastInstantTwa));
|
|
199
|
-
lastInstantTwa = instantTwaDeg;
|
|
200
|
-
|
|
315
|
+
// TATTICA (Lancetta): 2s vs 10s (reazione rapida per trim)
|
|
316
|
+
const twaNow = getCircularAverageFromBuffer(store.longBuf.twa, 2000, true);
|
|
317
|
+
const twaRef = getCircularAverageFromBuffer(store.longBuf.twa, 10000, true);
|
|
318
|
+
|
|
319
|
+
// STRATEGIA (Bussola TWD): 1 min vs 30 minuti (tendenza meteo profonda)
|
|
320
|
+
const twdNow = getCircularAverageFromBuffer(store.longBuf.twd, 60000, false);
|
|
321
|
+
const twdRef = getCircularAverageFromBuffer(store.longBuf.twd, 1800000, false); // 1.800.000 ms = 30 min
|
|
322
|
+
|
|
323
|
+
if (!twaNow || !twaRef || !twdNow || !twdRef) return;
|
|
201
324
|
const compassDots = { cw: document.getElementById('trend-dot-cw'), ccw: document.getElementById('trend-dot-ccw') };
|
|
202
325
|
const gaugeDots = { cw: document.getElementById('trend-gauge-cw'), ccw: document.getElementById('trend-gauge-ccw') };
|
|
203
326
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// CALCOLO TREND
|
|
211
|
-
let diff = (shortAvgDeg - lastShortAvgVal + 540) % 360 - 180; lastShortAvgVal = shortAvgDeg;
|
|
212
|
-
if (dt > 0) {
|
|
213
|
-
let rate = Math.max(-10, Math.min(10, diff / dt));
|
|
214
|
-
// Tattico (veloce ~15s)
|
|
215
|
-
const alphaT = Math.min(1, dt / 15);
|
|
216
|
-
rotationTrend = rotationTrend * (1 - alphaT) + rate * alphaT;
|
|
217
|
-
// Strategico (molto lento ~8-10min)
|
|
218
|
-
const alphaM = Math.min(1, dt / 500);
|
|
219
|
-
meteoTrend = meteoTrend * (1 - alphaM) + rate * alphaM;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// VISUALIZZAZIONE METEO (Bussola Centrale)
|
|
223
|
-
if (Math.abs(meteoTrend) > 0.2) {
|
|
327
|
+
// TREND METEO (Bussola Centrale TWD)
|
|
328
|
+
let deltaMeteo = radToDeg((twdNow.val - twdRef.val + Math.PI * 3) % (Math.PI * 2) - Math.PI);
|
|
329
|
+
if (Math.abs(deltaMeteo) > 6.0) {
|
|
224
330
|
const isSouth = store.raw["navigation.position"]?.latitude < 0;
|
|
225
|
-
let meteoColor = (!isSouth) ? (
|
|
226
|
-
if (
|
|
227
|
-
|
|
228
|
-
|
|
331
|
+
let meteoColor = (!isSouth) ? (deltaMeteo < 0 ? "#27ae60" : "#c0392b") : (deltaMeteo > 0 ? "#27ae60" : "#c0392b");
|
|
332
|
+
if (deltaMeteo > 0) {
|
|
333
|
+
compassDots.cw.classList.add('is-trending'); compassDots.cw.setAttribute('fill', meteoColor);
|
|
334
|
+
compassDots.ccw.classList.remove('is-trending');
|
|
229
335
|
} else {
|
|
230
|
-
|
|
231
|
-
|
|
336
|
+
compassDots.ccw.classList.add('is-trending'); compassDots.ccw.setAttribute('fill', meteoColor);
|
|
337
|
+
compassDots.cw.classList.remove('is-trending');
|
|
232
338
|
}
|
|
233
|
-
} else {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (Math.abs(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
339
|
+
} else { [compassDots.cw, compassDots.ccw].forEach(el => { if(el){ el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }}); }
|
|
340
|
+
|
|
341
|
+
// TREND TATTICO (Lancetta Arancione)
|
|
342
|
+
let deltaTac = radToDeg((twaNow.val - twaRef.val + Math.PI * 3) % (2 * Math.PI) - Math.PI);
|
|
343
|
+
const curTwaDeg = radToDeg(twaNow.val);
|
|
344
|
+
if (Math.abs(deltaTac) > 3.0) {
|
|
345
|
+
// Calcolo logica tattica: LIFT (Verde), HEADER (Rosso) o NEUTRO (Grigio)
|
|
346
|
+
let absTwa = Math.abs(curTwaDeg);
|
|
347
|
+
let tacticColor;
|
|
348
|
+
|
|
349
|
+
// Se siamo tra 75° e 105° (Traverso), il cambio è considerato neutro
|
|
350
|
+
if (absTwa > 75 && absTwa < 105) {
|
|
351
|
+
tacticColor = "#bbb"; // Grigio/Bianco sporco neutro
|
|
245
352
|
} else {
|
|
246
|
-
|
|
247
|
-
if (
|
|
353
|
+
let isLift = (curTwaDeg > 0) ? (deltaTac > 0) : (deltaTac < 0);
|
|
354
|
+
if (absTwa >= 90) isLift = !isLift; // Inversione logica per andature portanti
|
|
355
|
+
tacticColor = isLift ? "#27ae60" : "#c0392b";
|
|
248
356
|
}
|
|
249
|
-
|
|
250
|
-
|
|
357
|
+
if (deltaTac > 0) {
|
|
358
|
+
gaugeDots.cw.classList.add('is-trending'); gaugeDots.cw.setAttribute('fill', tacticColor);
|
|
359
|
+
gaugeDots.ccw.classList.remove('is-trending');
|
|
360
|
+
} else {
|
|
361
|
+
gaugeDots.ccw.classList.add('is-trending'); gaugeDots.ccw.setAttribute('fill', tacticColor);
|
|
362
|
+
gaugeDots.cw.classList.remove('is-trending');
|
|
363
|
+
}
|
|
364
|
+
} else { [gaugeDots.cw, gaugeDots.ccw].forEach(el => { if(el){ el.classList.remove('is-trending'); el.setAttribute('fill', '#bbb'); }}); }
|
|
365
|
+
|
|
366
|
+
// ALLARME STRAMBATA (GYBE)
|
|
367
|
+
const instTwa = radToDeg(store.raw["environment.wind.angleTrueWater"] || 0);
|
|
368
|
+
if (Math.abs(instTwa) > 155 && Math.sign(instTwa) !== Math.sign(lastInstantTwa)) {
|
|
369
|
+
if (isNavigating && (now - lastGybeAlarmTime > 5000)) { lastGybeAlarmTime = now; playGybeAlarm(); }
|
|
251
370
|
}
|
|
371
|
+
lastInstantTwa = instTwa;
|
|
252
372
|
}
|
|
253
373
|
|
|
254
374
|
// ==========================================================================
|
|
255
|
-
//
|
|
375
|
+
// 7. RENDERING ENGINE E AGGIORNAMENTO UI
|
|
256
376
|
// ==========================================================================
|
|
257
377
|
function refreshGraph(t) {
|
|
258
378
|
const type = (t === 'vmg') ? 'sog' : t;
|
|
259
379
|
const data = store.histories[t]; if (!data || data.length < 2) return;
|
|
260
380
|
const mode = graphModes[type], cfg = calculateScale(type, data, mode);
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
381
|
+
|
|
382
|
+
// Gestione visualizzazione Hercules Zoom (sfondo rosso)
|
|
383
|
+
const box = document.querySelector(`.box-${type}`);
|
|
384
|
+
if (box) box.classList.toggle('box-hercules', mode === 'hercules');
|
|
385
|
+
|
|
386
|
+
updateScaleLabels(type, cfg.min, cfg.max);
|
|
387
|
+
drawGraph(data, type + '-graph', cfg.min, cfg.max, t === 'tws', mode === 'hercules');
|
|
264
388
|
}
|
|
265
389
|
|
|
390
|
+
/**
|
|
391
|
+
* upUI: Aggiornamento valori digitali con logica anti-ritardo (Istantaneo vs Media)
|
|
392
|
+
*/
|
|
393
|
+
const upUI = (el, obj, instantRaw, isCompass = false) => {
|
|
394
|
+
if (!obj || obj.val === null || instantRaw === undefined) {
|
|
395
|
+
el.innerHTML = "---°"; el.classList.remove('unstable-data');
|
|
396
|
+
} else {
|
|
397
|
+
let valDeg = Math.round(radToDeg(obj.val));
|
|
398
|
+
let mainVal = (isCompass ? ((valDeg + 360) % 360).toString().padStart(3, '0') : valDeg) + "°";
|
|
399
|
+
let dev = (obj.dev > 1 && obj.dev < 90) ? `<span style="font-size: 0.35em; opacity: 0.5; margin-left: 4px; vertical-align: middle;">±${obj.dev}</span>` : "";
|
|
400
|
+
el.innerHTML = mainVal + dev;
|
|
401
|
+
|
|
402
|
+
let diff = Math.abs((radToDeg(instantRaw) - radToDeg(obj.val) + 540) % 360 - 180);
|
|
403
|
+
// Allarme lampeggio solo se in navigazione E (R bassa O deviazione alta O salto istantaneo brusco)
|
|
404
|
+
if (isNavigating && (!obj.stable || obj.dev > CONFIG.averages.stabilityBreakout || diff > CONFIG.averages.stabilityBreakout)) el.classList.add('unstable-data');
|
|
405
|
+
else el.classList.remove('unstable-data');
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Loop principale di aggiornamento interfaccia (1Hz)
|
|
411
|
+
*/
|
|
412
|
+
/**
|
|
413
|
+
* Loop principale di aggiornamento interfaccia (1Hz)
|
|
414
|
+
* Gestisce la gerarchia di aggiornamento Live (1s), Heavy (2s) e Slow (3s).
|
|
415
|
+
*/
|
|
266
416
|
function startDisplayLoop() {
|
|
267
|
-
let tick = 0;
|
|
268
417
|
renderInterval = setInterval(() => {
|
|
269
|
-
const now = Date.now();
|
|
270
|
-
|
|
271
|
-
const
|
|
418
|
+
const now = Date.now();
|
|
419
|
+
const stwKts = msToKts(store.raw["navigation.speedThroughWater"] || 0);
|
|
420
|
+
const sogKts = msToKts(store.raw["navigation.speedOverGround"] || 0);
|
|
421
|
+
|
|
422
|
+
// Verifica stato navigazione basato su soglia impostata
|
|
272
423
|
isNavigating = stwKts > CONFIG.averages.minSpeed || sogKts > CONFIG.averages.minSpeed;
|
|
273
424
|
|
|
274
|
-
//
|
|
275
|
-
const
|
|
276
|
-
for (let p in
|
|
425
|
+
// --- TIER LIVE (1s): CONTROLLO TIMEOUT DATI ---
|
|
426
|
+
const watch = { "navigation.speedThroughWater": ui.stw, "navigation.speedOverGround": ui.sog, "navigation.headingTrue": ui.hdg, "navigation.courseOverGroundTrue": ui.cog, "environment.wind.speedApparent": ui.awsSvg, "environment.depth.belowTransducer": ui.depth, "environment.wind.speedTrue": ui.tws };
|
|
427
|
+
for (let p in watch) {
|
|
428
|
+
if (!store.timestamps[p] || (now - store.timestamps[p] > TIMEOUT_MS)) {
|
|
429
|
+
watch[p].innerText = "---"; delete store.raw[p];
|
|
430
|
+
}
|
|
431
|
+
}
|
|
277
432
|
|
|
278
|
-
|
|
433
|
+
// --- AGGIORNAMENTO DATI ISTANTANEI ---
|
|
434
|
+
if (store.raw["navigation.speedThroughWater"] !== undefined) {
|
|
435
|
+
ui.stw.innerText = stwKts.toFixed(1); manageHistory('stw', stwKts);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// --- LOGICA SOG / VMG E COLORI DINAMICI ---
|
|
279
439
|
if (store.raw["navigation.speedOverGround"] !== undefined) {
|
|
280
|
-
const
|
|
281
|
-
manageHistory('vmg',
|
|
440
|
+
const vmg = Math.abs(stwKts * Math.cos(store.raw["environment.wind.angleTrueWater"] || 0));
|
|
441
|
+
manageHistory('vmg', vmg); manageHistory('sog', sogKts);
|
|
442
|
+
|
|
443
|
+
const labelEl = document.getElementById('sog-vmg-label');
|
|
282
444
|
if (displayModeSog === 'VMG') {
|
|
283
|
-
ui.sog.innerText =
|
|
445
|
+
ui.sog.innerText = vmg.toFixed(1);
|
|
446
|
+
ui.sog.style.setProperty('color', '#16a085', 'important'); // Verde Petrolio
|
|
447
|
+
if (labelEl) labelEl.textContent = 'VMG';
|
|
284
448
|
} else {
|
|
285
449
|
ui.sog.innerText = sogKts.toFixed(1);
|
|
286
|
-
|
|
287
|
-
|
|
450
|
+
if (labelEl) labelEl.textContent = 'SOG';
|
|
451
|
+
|
|
452
|
+
// Colore Corrente: se neutro usiamo "", così il CSS Night Mode può agire
|
|
453
|
+
if (sogKts - stwKts > 0.3) ui.sog.style.setProperty('color', '#27ae60', 'important');
|
|
454
|
+
else if (sogKts - stwKts < -0.3) ui.sog.style.setProperty('color', '#c0392b', 'important');
|
|
455
|
+
else ui.sog.style.color = "";
|
|
288
456
|
}
|
|
289
457
|
}
|
|
290
|
-
|
|
291
|
-
if (store.raw["environment.
|
|
292
|
-
|
|
458
|
+
|
|
459
|
+
if (store.raw["environment.depth.belowTransducer"] !== undefined) {
|
|
460
|
+
ui.depth.innerText = store.raw["environment.depth.belowTransducer"].toFixed(1);
|
|
461
|
+
checkDepthAlarm(store.raw["environment.depth.belowTransducer"]);
|
|
462
|
+
manageHistory('depth', store.raw["environment.depth.belowTransducer"]);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (store.raw["environment.wind.speedTrue"] !== undefined) {
|
|
466
|
+
const twsKts = msToKts(store.raw["environment.wind.speedTrue"]);
|
|
467
|
+
ui.tws.innerText = twsKts.toFixed(1);
|
|
468
|
+
|
|
469
|
+
// Colore Reef: se normale usiamo "", il CSS metterà Nero (giorno) o Rosso (notte)
|
|
470
|
+
if (twsKts >= CONFIG.graphs.reef2) ui.tws.style.setProperty('color', '#e74c3c', 'important');
|
|
471
|
+
else if (twsKts >= CONFIG.graphs.reef1) ui.tws.style.setProperty('color', '#e67e22', 'important');
|
|
472
|
+
else ui.tws.style.color = "";
|
|
473
|
+
|
|
474
|
+
manageHistory('tws', twsKts);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (store.raw["environment.wind.speedApparent"] !== undefined) {
|
|
478
|
+
ui.awsSvg.textContent = msToKts(store.raw["environment.wind.speedApparent"]).toFixed(1);
|
|
479
|
+
}
|
|
293
480
|
|
|
481
|
+
// --- PUNTATORI ANALOGICI (Smoothing 2s) ---
|
|
294
482
|
const smAwa = getCircularAverageFromBuffer(store.smoothBuf.awa, 2000, true);
|
|
295
|
-
if (smAwa) { curAwaRot = getShortestRotation(curAwaRot, radToDeg(smAwa.val)); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
|
|
296
483
|
const smTwa = getCircularAverageFromBuffer(store.smoothBuf.twa, 2000, true);
|
|
484
|
+
if (smAwa) { curAwaRot = getShortestRotation(curAwaRot, radToDeg(smAwa.val)); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
|
|
297
485
|
if (smTwa) { curTwaRot = getShortestRotation(curTwaRot, radToDeg(smTwa.val)); ui.twa.setAttribute('transform', `rotate(${curTwaRot}, 200, 200)`); }
|
|
298
|
-
|
|
486
|
+
|
|
487
|
+
// --- CALCOLO LEEWAY E TRACK POINTER ---
|
|
299
488
|
if (store.raw["navigation.courseOverGroundTrue"] !== undefined && store.raw["navigation.headingTrue"] !== undefined) {
|
|
300
489
|
let driftDeg = radToDeg((store.raw["navigation.courseOverGroundTrue"] - store.raw["navigation.headingTrue"] + Math.PI * 3) % (Math.PI * 2) - Math.PI);
|
|
301
|
-
|
|
490
|
+
// Azzeramento sotto soglia minima impostata
|
|
491
|
+
smoothedLeeway = (sogKts < CONFIG.averages.minSpeed) ? 0 : (smoothedLeeway * 0.9) + (driftDeg * 0.1);
|
|
302
492
|
curTrackRot = getShortestRotation(curTrackRot, smoothedLeeway); ui.track.setAttribute('transform', `rotate(${curTrackRot}, 200, 200)`);
|
|
303
|
-
ui.leewayVal.style.color = (Math.abs(sogKts - stwKts) > 0.5 && Math.abs(smoothedLeeway) > 7) ? "#e67e22" : "
|
|
493
|
+
ui.leewayVal.style.color = (Math.abs(sogKts - stwKts) > 0.5 && Math.abs(smoothedLeeway) > 7) ? "#e67e22" : "";
|
|
304
494
|
updateLeewayDisplay(Math.max(-20, Math.min(20, smoothedLeeway)));
|
|
305
495
|
}
|
|
306
|
-
|
|
496
|
+
|
|
307
497
|
updateWindTrend();
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
498
|
+
|
|
499
|
+
// TIER HEAVY (2s) - Grafici e Persistenza Browser
|
|
500
|
+
if (lastAvgUIUpdate++ % 2 === 0) {
|
|
501
|
+
['stw', 'sog', 'depth', 'tws'].forEach(refreshGraph);
|
|
502
|
+
saveDashboardState();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// TIER SLOW (3s) - Medie Lunghe e Calcolo TACK
|
|
506
|
+
if (lastAvgUIUpdate % 3 === 0) {
|
|
507
|
+
let hObj = getCircularAverageFromBuffer(store.longBuf.hdg, CONFIG.averages.longWindow * 2, false);
|
|
508
|
+
let cObj = getCircularAverageFromBuffer(store.longBuf.cog, CONFIG.averages.longWindow, false);
|
|
509
|
+
let awObj = getCircularAverageFromBuffer(store.longBuf.awa, CONFIG.averages.longWindow, true);
|
|
510
|
+
let twObj = getCircularAverageFromBuffer(store.longBuf.twa, CONFIG.averages.longWindow, true);
|
|
511
|
+
let twdObj = getCircularAverageFromBuffer(store.longBuf.twd, CONFIG.averages.longWindow, false);
|
|
512
|
+
|
|
513
|
+
upUI(ui.hdg, hObj, store.raw["navigation.headingTrue"], true);
|
|
514
|
+
upUI(ui.cog, cObj, store.raw["navigation.courseOverGroundTrue"], true);
|
|
515
|
+
upUI(ui.awaAvg, awObj, store.raw["environment.wind.angleApparent"], false);
|
|
516
|
+
upUI(ui.twaAvg, twObj, store.raw["environment.wind.angleTrueWater"], false);
|
|
517
|
+
upUI(ui.twdAvg, twdObj, store.raw["environment.wind.directionTrue"], true);
|
|
518
|
+
|
|
519
|
+
// --- LOGICA TACK STRATEGICA (Riflessione geometrica su TWD) ---
|
|
520
|
+
if (hObj && twdObj) {
|
|
521
|
+
const tH = radToDeg((2 * twdObj.val - hObj.val + Math.PI * 2) % (Math.PI * 2));
|
|
522
|
+
const unstableH = !hObj.stable || !twdObj.stable || hObj.dev > CONFIG.averages.stabilityBreakout || twdObj.dev > CONFIG.averages.stabilityBreakout;
|
|
523
|
+
|
|
524
|
+
if (!isNavigating) {
|
|
525
|
+
ui.tackHdg.innerHTML = "---°"; ui.tackHdg.classList.remove('unstable-data');
|
|
526
|
+
} else if (unstableH) {
|
|
527
|
+
ui.tackHdg.innerHTML = "---°"; ui.tackHdg.classList.add('unstable-data');
|
|
528
|
+
} else {
|
|
529
|
+
ui.tackHdg.innerHTML = `${Math.round((tH + 360) % 360).toString().padStart(3, '0')}°`;
|
|
530
|
+
ui.tackHdg.classList.remove('unstable-data');
|
|
325
531
|
}
|
|
326
|
-
|
|
327
|
-
upUI(ui.hdg, hObj, true); upUI(ui.cog, cObj, true); upUI(ui.awaAvg, awObj, false); upUI(ui.twaAvg, twObj, false); upUI(ui.twdAvg, twdObj, true);
|
|
328
|
-
|
|
329
|
-
if (hObj && twObj) {
|
|
330
|
-
const tackHdgDeg = radToDeg((hObj.val + twObj.val * 2 + Math.PI * 2) % (Math.PI * 2));
|
|
331
|
-
ui.tackHdg.innerHTML = `${Math.round((tackHdgDeg + 360) % 360).toString().padStart(3, '0')}°`;
|
|
532
|
+
|
|
332
533
|
if (cObj) {
|
|
333
|
-
const
|
|
334
|
-
|
|
534
|
+
const tC = radToDeg((2 * twdObj.val - cObj.val + Math.PI * 2) % (Math.PI * 2));
|
|
535
|
+
const unstableC = !cObj.stable || !twdObj.stable || cObj.dev > CONFIG.averages.stabilityBreakout || twdObj.dev > CONFIG.averages.stabilityBreakout;
|
|
536
|
+
|
|
537
|
+
if (!isNavigating) {
|
|
538
|
+
ui.tackCog.innerHTML = "---°"; ui.tackCog.classList.remove('unstable-data');
|
|
539
|
+
} else if (unstableC) {
|
|
540
|
+
ui.tackCog.innerHTML = "---°"; ui.tackCog.classList.add('unstable-data');
|
|
541
|
+
} else {
|
|
542
|
+
ui.tackCog.innerHTML = `${Math.round((tC + 360) % 360).toString().padStart(3, '0')}°`;
|
|
543
|
+
ui.tackCog.classList.remove('unstable-data');
|
|
544
|
+
}
|
|
335
545
|
}
|
|
336
546
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
547
|
+
|
|
548
|
+
// Rotazione Mini-Bussole
|
|
549
|
+
const smHdgIcons = getCircularAverageFromBuffer(store.smoothBuf.hdg, 2000, false);
|
|
550
|
+
const smTwdIcons = getCircularAverageFromBuffer(store.smoothBuf.twd, 2000, false);
|
|
551
|
+
if (smHdgIcons && smTwdIcons) {
|
|
552
|
+
curWindCompassRot = getShortestRotation(curWindCompassRot, radToDeg(smTwdIcons.val)); ui.twdArrow.setAttribute('transform', `rotate(${curWindCompassRot}, 20, 20)`);
|
|
553
|
+
curBoatCompassRot = getShortestRotation(curBoatCompassRot, radToDeg(smHdgIcons.val)); ui.twdBoat.setAttribute('transform', `rotate(${curBoatCompassRot}, 20, 20)`);
|
|
341
554
|
}
|
|
342
|
-
lastAvgUIUpdate = now;
|
|
343
555
|
}
|
|
344
|
-
if (tick % 60 === 0) tick = 0;
|
|
345
556
|
}, RENDER_INTERVAL_MS);
|
|
346
557
|
}
|
|
347
558
|
|
|
348
559
|
// ==========================================================================
|
|
349
|
-
//
|
|
560
|
+
// 8. CONFIGURAZIONE E GRAFICI UTILS
|
|
350
561
|
// ==========================================================================
|
|
351
562
|
async function fetchServerConfig() {
|
|
352
563
|
if (!window.location.protocol.includes("http")) return;
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
if (
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
if (actual) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if (actual.alarms) CONFIG.alarms = { ...CONFIG.alarms, ...actual.alarms };
|
|
365
|
-
if (actual.graphs) CONFIG.graphs = { ...CONFIG.graphs, ...actual.graphs };
|
|
366
|
-
if (actual.averaging) CONFIG.averages = { ...CONFIG.averages, ...actual.averaging };
|
|
367
|
-
if (actual.scales) { for (let key in actual.scales) { CONFIG.scales[key] = { ...CONFIG.scales[key], ...actual.scales[key] }; } }
|
|
368
|
-
}
|
|
564
|
+
try {
|
|
565
|
+
const response = await fetch(`/plugins/rotevista-dash/config`);
|
|
566
|
+
if (response.ok) {
|
|
567
|
+
const data = await response.json();
|
|
568
|
+
const actual = data.configuration || data;
|
|
569
|
+
if (actual) {
|
|
570
|
+
const pN = (obj) => { for (let k in obj) { if (typeof obj[k] === 'object') pN(obj[k]); else if (!isNaN(obj[k]) && obj[k] !== "") obj[k] = parseFloat(obj[k]); } };
|
|
571
|
+
pN(actual);
|
|
572
|
+
if (actual.alarms) CONFIG.alarms = { ...CONFIG.alarms, ...actual.alarms };
|
|
573
|
+
if (actual.averaging) CONFIG.averages = { ...CONFIG.averages, ...actual.averaging };
|
|
574
|
+
if (actual.scales) { for (let k in actual.scales) CONFIG.scales[k] = { ...CONFIG.scales[k], ...actual.scales[k] }; }
|
|
369
575
|
}
|
|
370
|
-
}
|
|
371
|
-
}
|
|
576
|
+
}
|
|
577
|
+
} catch (e) { }
|
|
372
578
|
}
|
|
373
579
|
|
|
374
580
|
function manageHistory(t, v) {
|
|
375
|
-
const n = Date.now();
|
|
376
|
-
const interval = simulationMode ? SIM_SAMPLE_INTERVAL : (CONFIG.graphs.historyMinutes * 60000) / CONFIG.graphs.samples;
|
|
581
|
+
const n = Date.now(), interval = (CONFIG.graphs.historyMinutes * 60000) / CONFIG.graphs.samples;
|
|
377
582
|
if (!store.graphTempBuf[t]) store.graphTempBuf[t] = [];
|
|
378
583
|
store.graphTempBuf[t].push(v);
|
|
379
|
-
|
|
380
584
|
if (n - store.lastUpdates[t] > interval || store.histories[t].length === 0) {
|
|
381
|
-
const
|
|
382
|
-
const avg = sum / store.graphTempBuf[t].length;
|
|
585
|
+
const avg = store.graphTempBuf[t].reduce((a, b) => a + b, 0) / store.graphTempBuf[t].length;
|
|
383
586
|
store.histories[t].push(avg);
|
|
384
587
|
if (store.histories[t].length > CONFIG.graphs.samples) store.histories[t].shift();
|
|
385
|
-
store.graphTempBuf[t] = [];
|
|
386
|
-
store.lastUpdates[t] = n;
|
|
588
|
+
store.graphTempBuf[t] = []; store.lastUpdates[t] = n;
|
|
387
589
|
}
|
|
388
590
|
}
|
|
389
591
|
|
|
390
592
|
function calculateScale(type, data, mode) {
|
|
391
|
-
const s = CONFIG.scales[type]
|
|
392
|
-
if (mode === 'hercules') {
|
|
393
|
-
|
|
593
|
+
const s = CONFIG.scales[type]; let aMin = Math.min(...data), aMax = Math.max(...data);
|
|
594
|
+
if (mode === 'hercules') {
|
|
595
|
+
let avg = (aMin + aMax) / 2; let span = Math.max(s.hercSpan, Math.ceil(aMax - aMin));
|
|
596
|
+
if (span % 2 !== 0) span += 1; let min = Math.max(0, Math.floor(avg - (span / 2)));
|
|
597
|
+
return { min, max: min + span };
|
|
598
|
+
}
|
|
599
|
+
return { min: 0, max: Math.max(s.stdMax, Math.ceil(aMax / s.step) * s.step) };
|
|
394
600
|
}
|
|
395
601
|
|
|
396
602
|
function updateScaleLabels(t, min, max) { const el = document.getElementById(t + '-scale'); if (el) el.innerHTML = `<span>${Math.round(max)}</span><span>${Math.round((min+max)/2)}</span><span>${Math.round(min)}</span>`; }
|
|
@@ -398,9 +604,15 @@ function updateScaleLabels(t, min, max) { const el = document.getElementById(t +
|
|
|
398
604
|
function drawGraph(d, id, min, max, isTws, isHercules) {
|
|
399
605
|
const svg = document.getElementById(id); if (!svg || d.length < 2) return;
|
|
400
606
|
const w = 200, h = 40, range = max - min || 1;
|
|
401
|
-
let grids = "";
|
|
402
|
-
|
|
403
|
-
|
|
607
|
+
let grids = ""; [0.25, 0.5, 0.75].forEach(p => { grids += `<line x1="0" y1="${h-(p*h)}" x2="${w}" y2="${h-(p*h)}" stroke="rgba(0,0,0,0.12)" stroke-width="0.5" />`; });
|
|
608
|
+
/**
|
|
609
|
+
* Griglia Temporale Intelligente:
|
|
610
|
+
* - Storia <= 15 min: linee ogni 1 minuto.
|
|
611
|
+
* - Storia > 15 min: linee ogni 5 minuti.
|
|
612
|
+
*/
|
|
613
|
+
const gridInterval = (CONFIG.graphs.historyMinutes <= 15) ? 1 : 5;
|
|
614
|
+
|
|
615
|
+
for (let m = gridInterval; m < CONFIG.graphs.historyMinutes; m += gridInterval) {
|
|
404
616
|
const x = w - (m / CONFIG.graphs.historyMinutes) * w;
|
|
405
617
|
grids += `<line x1="${x}" y1="0" x2="${x}" y2="${h}" stroke="rgba(0,0,0,0.08)" stroke-width="0.5" />`;
|
|
406
618
|
}
|
|
@@ -410,7 +622,7 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
|
|
|
410
622
|
if (isTws && i > 0) {
|
|
411
623
|
const px = ((i-1)/(CONFIG.graphs.samples-1))*w, py = h-(Math.max(0,Math.min(1,(d[i-1]-min)/range))*h);
|
|
412
624
|
let c = (v >= CONFIG.graphs.reef2) ? "#e74c3c" : (v >= CONFIG.graphs.reef1 ? "#e67e22" : "#000");
|
|
413
|
-
cS += `<line x1="${px}" y1="${py}" x2="${x}" y2="${y}" stroke="${c}" class="${isHercules?'line-hercules':''}" />`;
|
|
625
|
+
cS += `<line x1="${px}" y1="${py}" x2="${x}" y2="${y}" stroke="${c}" class="tws-reef-line ${isHercules?'line-hercules':''}" />`;
|
|
414
626
|
}
|
|
415
627
|
});
|
|
416
628
|
const clrs = { 'stw-graph': '#2ecc71', 'sog-graph': '#f39c12', 'depth-graph': '#3498db', 'tws-graph': '#000' };
|
|
@@ -419,101 +631,41 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
|
|
|
419
631
|
}
|
|
420
632
|
|
|
421
633
|
// ==========================================================================
|
|
422
|
-
//
|
|
634
|
+
// 9. INTERAZIONI E GESTI
|
|
423
635
|
// ==========================================================================
|
|
424
636
|
function toggleFocusMode(type, element) {
|
|
425
637
|
const container = document.querySelector('.main-container');
|
|
426
|
-
|
|
427
|
-
// Con la nuova struttura Flat HTML, non abbiamo più '.left-panel'.
|
|
428
|
-
// Dobbiamo determinare il lato guardando il tipo di box.
|
|
429
|
-
const leftBoxes = ['stw', 'sog', 'hdg', 'cog', 'tack'];
|
|
430
|
-
const isLeft = leftBoxes.includes(type);
|
|
431
|
-
|
|
638
|
+
const isLeft = ['stw', 'sog', 'hdg', 'cog', 'tack'].includes(type);
|
|
432
639
|
isFocusActive = !isFocusActive;
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
container.classList.add('focus-active', isLeft ? 'focus-side-left' : 'focus-side-right');
|
|
436
|
-
element.classList.add('is-focused');
|
|
437
|
-
} else {
|
|
438
|
-
container.classList.remove('focus-active', 'focus-side-left', 'focus-side-right');
|
|
439
|
-
document.querySelectorAll('.data-box').forEach(b => b.classList.remove('is-focused'));
|
|
440
|
-
}
|
|
640
|
+
if (isFocusActive) { container.classList.add('focus-active', isLeft ? 'focus-side-left' : 'focus-side-right'); element.classList.add('is-focused'); }
|
|
641
|
+
else { container.classList.remove('focus-active', 'focus-side-left', 'focus-side-right'); document.querySelectorAll('.data-box').forEach(b => b.classList.remove('is-focused')); }
|
|
441
642
|
}
|
|
442
643
|
|
|
443
644
|
['stw', 'sog', 'tws', 'depth'].forEach(type => {
|
|
444
|
-
// Risale dal grafico al contenitore principale (funziona con la nuova struttura)
|
|
445
645
|
const el = document.getElementById(type + '-graph').closest('.data-box');
|
|
446
646
|
let lastTapTime = 0, tapTimeout, isLongPressActive = false;
|
|
447
|
-
|
|
448
|
-
el.addEventListener('pointerdown', (e) => {
|
|
449
|
-
isLongPressActive = false;
|
|
450
|
-
pressTimer = setTimeout(() => {
|
|
451
|
-
if (!isFocusActive) {
|
|
452
|
-
isLongPressActive = true;
|
|
453
|
-
toggleFocusMode(type, el);
|
|
454
|
-
lastTapTime = 0;
|
|
455
|
-
}
|
|
456
|
-
}, 1000);
|
|
457
|
-
});
|
|
458
|
-
|
|
647
|
+
el.addEventListener('pointerdown', (e) => { isLongPressActive = false; pressTimer = setTimeout(() => { if (!isFocusActive) { isLongPressActive = true; toggleFocusMode(type, el); lastTapTime = 0; } }, 1000); });
|
|
459
648
|
el.addEventListener('pointerup', (e) => {
|
|
460
|
-
clearTimeout(pressTimer);
|
|
461
|
-
if (isLongPressActive) return; // Se era focus, ignora l'up
|
|
462
|
-
|
|
649
|
+
clearTimeout(pressTimer); if (isLongPressActive) return;
|
|
463
650
|
const currentTime = new Date().getTime(), tapDelay = currentTime - lastTapTime;
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
if (tapDelay < 300 && tapDelay > 0) {
|
|
467
|
-
clearTimeout(tapTimeout); // Cancella l'uscita dal Focus
|
|
468
|
-
graphModes[type] = graphModes[type] === 'standard' ? 'hercules' : 'standard';
|
|
469
|
-
localStorage.setItem('mode_' + type, graphModes[type]);
|
|
470
|
-
refreshGraph(type); // Mostra istantaneamente
|
|
471
|
-
lastTapTime = 0;
|
|
472
|
-
}
|
|
473
|
-
// TAP SINGOLO (con ritardo di 250ms per aspettare l'eventuale doppio tap)
|
|
474
|
-
else {
|
|
475
|
-
lastTapTime = currentTime;
|
|
476
|
-
tapTimeout = setTimeout(() => {
|
|
477
|
-
// Uscita Focus
|
|
478
|
-
if (isFocusActive && el.classList.contains('is-focused')) {
|
|
479
|
-
toggleFocusMode(type, el);
|
|
480
|
-
}
|
|
481
|
-
// Toggle SOG/VMG (solo se non in focus)
|
|
482
|
-
else if (!isFocusActive && type === 'sog') {
|
|
483
|
-
displayModeSog = (displayModeSog === 'SOG') ? 'VMG' : 'SOG';
|
|
484
|
-
el.style.backgroundColor = "rgba(0, 0, 0, 0.05)";
|
|
485
|
-
setTimeout(() => el.style.backgroundColor = "", 150);
|
|
486
|
-
}
|
|
487
|
-
}, 250); // Attesa critica per il doppio tap
|
|
488
|
-
}
|
|
651
|
+
if (tapDelay < 300 && tapDelay > 0) { clearTimeout(tapTimeout); graphModes[type] = (graphModes[type] === 'standard') ? 'hercules' : 'standard'; localStorage.setItem('mode_' + type, graphModes[type]); refreshGraph(type); lastTapTime = 0; }
|
|
652
|
+
else { lastTapTime = currentTime; tapTimeout = setTimeout(() => { if (isFocusActive && el.classList.contains('is-focused')) toggleFocusMode(type, el); else if (!isFocusActive && type === 'sog') { displayModeSog = (displayModeSog === 'SOG') ? 'VMG' : 'SOG'; el.style.backgroundColor = "rgba(0, 0, 0, 0.05)"; setTimeout(() => el.style.backgroundColor = "", 150); } }, 250); }
|
|
489
653
|
});
|
|
490
|
-
|
|
491
654
|
el.addEventListener('pointerleave', () => clearTimeout(pressTimer));
|
|
492
655
|
});
|
|
493
656
|
|
|
494
657
|
if (ui.hotspot) {
|
|
495
658
|
ui.hotspot.addEventListener('pointerdown', (e) => { pressTimer = setTimeout(() => { document.body.classList.toggle('night-mode'); ui.hotspot.style.opacity = "0.5"; setTimeout(() => ui.hotspot.style.opacity = "1", 200); pressTimer = null; }, 1000); });
|
|
496
|
-
ui.hotspot.addEventListener('pointerup', (e) => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; const doc = document.documentElement
|
|
659
|
+
ui.hotspot.addEventListener('pointerup', (e) => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; const doc = document.documentElement; if (document.fullscreenElement) document.exitFullscreen(); else doc.requestFullscreen(); } });
|
|
497
660
|
ui.hotspot.addEventListener('pointerleave', () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } });
|
|
498
661
|
}
|
|
499
662
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
const protocol = (window.location.protocol === 'https:') ? 'wss' : 'ws';
|
|
504
|
-
try {
|
|
505
|
-
socket = new WebSocket(`${protocol}://${addr}/signalk/v1/stream?subscribe=self`);
|
|
506
|
-
socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; reconnectDelay = 1000; };
|
|
507
|
-
socket.onmessage = (e) => { if (simulationMode) return; const d = JSON.parse(e.data); if (d.updates) d.updates.forEach(u => u.values && u.values.forEach(v => processIncomingData(v.path, v.value))); };
|
|
508
|
-
socket.onclose = () => { if (!simulationMode) { ui.status.className = "offline"; ui.status.innerText = "RECONNECTING..."; setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 1.5, 10000); } };
|
|
509
|
-
} catch (e) { setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 1.5, 10000); }
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
ui.depth.closest('.data-box').addEventListener('click', (function() { let dC = 0, lC = 0; return function() { const n = Date.now(); if (n - lC < 500) dC++; else dC = 1; lC = n; if (dC === 3) { simulationMode = !simulationMode; if (simulationMode) { if (socket) socket.close(); startDynamicSimulation(); } else location.reload(); dC = 0; } }; })());
|
|
513
|
-
|
|
663
|
+
// ==========================================================================
|
|
664
|
+
// 10. SIMULAZIONE E RETE
|
|
665
|
+
// ==========================================================================
|
|
514
666
|
function startDynamicSimulation() {
|
|
515
667
|
ui.status.innerText = "SIM ATTIVO";
|
|
516
|
-
let sim = { hdg: 45, tws: 12, twd: 90, depth: 12, stw: 5,
|
|
668
|
+
let sim = { hdg: 45, tws: 12, twd: 90, depth: 12, stw: 5, startTime: Date.now() };
|
|
517
669
|
simInterval = setInterval(() => {
|
|
518
670
|
const elapsed = (Date.now() - sim.startTime) / 1000;
|
|
519
671
|
sim.twd = (sim.twd + (Math.sin(elapsed / 20) * 0.5) + 360) % 360;
|
|
@@ -521,21 +673,50 @@ function startDynamicSimulation() {
|
|
|
521
673
|
sim.tws += (targetTws - sim.tws) * 0.05; sim.hdg = (sim.hdg + (Math.random() - 0.5) * 1 + 360) % 360;
|
|
522
674
|
let twaRel = (sim.twd - sim.hdg + 360) % 360; if (twaRel > 180) twaRel -= 360;
|
|
523
675
|
let targetStw = 5; sim.stw += (targetStw - sim.stw) * 0.05;
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
processIncomingData("
|
|
529
|
-
processIncomingData("
|
|
530
|
-
processIncomingData("navigation.
|
|
531
|
-
processIncomingData("navigation.courseOverGroundTrue", degToRad(cog));
|
|
676
|
+
processIncomingData("navigation.headingTrue", degToRad(sim.hdg));
|
|
677
|
+
processIncomingData("environment.wind.speedApparent", ktsToMs(sim.stw + 2));
|
|
678
|
+
processIncomingData("environment.wind.angleApparent", degToRad(twaRel + 5));
|
|
679
|
+
processIncomingData("environment.depth.belowTransducer", sim.depth);
|
|
680
|
+
processIncomingData("navigation.speedThroughWater", ktsToMs(sim.stw));
|
|
681
|
+
processIncomingData("navigation.speedOverGround", ktsToMs(sim.stw));
|
|
682
|
+
processIncomingData("navigation.courseOverGroundTrue", degToRad(sim.hdg));
|
|
532
683
|
}, 1000);
|
|
533
684
|
}
|
|
534
685
|
|
|
686
|
+
function connect() {
|
|
687
|
+
if (simulationMode) return;
|
|
688
|
+
let addr = window.location.host || CONFIG.server.fallbackIp;
|
|
689
|
+
try {
|
|
690
|
+
socket = new WebSocket(`ws://${addr}/signalk/v1/stream?subscribe=self`);
|
|
691
|
+
socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; reconnectDelay = 1000; };
|
|
692
|
+
socket.onmessage = (e) => { const d = JSON.parse(e.data); if (d.updates) d.updates.forEach(u => u.values && u.values.forEach(v => processIncomingData(v.path, v.value))); };
|
|
693
|
+
socket.onclose = () => { if (!simulationMode) { ui.status.className = "offline"; ui.status.innerText = "RECONNECTING..."; setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 1.5, 10000); } };
|
|
694
|
+
} catch (e) { setTimeout(connect, reconnectDelay); }
|
|
695
|
+
}
|
|
696
|
+
|
|
535
697
|
// ==========================================================================
|
|
536
|
-
//
|
|
698
|
+
// 11. INIT E CICLO DI VITA
|
|
537
699
|
// ==========================================================================
|
|
538
700
|
window.addEventListener('contextmenu', e => e.preventDefault(), true);
|
|
539
|
-
(function genTicks() {
|
|
540
|
-
|
|
701
|
+
(function genTicks() {
|
|
702
|
+
const c = document.getElementById('ticks');
|
|
703
|
+
if (c) {
|
|
704
|
+
for (let i = 0; i < 360; i += 10) {
|
|
705
|
+
const l = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
|
706
|
+
const m = i % 30 === 0;
|
|
707
|
+
l.setAttribute("x1", "200"); l.setAttribute("y1", "40"); l.setAttribute("x2", "200"); l.setAttribute("y2", (m ? 60 : 50));
|
|
708
|
+
l.setAttribute("stroke", m ? "#000" : "#bbb"); l.setAttribute("stroke-width", m ? "2" : "1");
|
|
709
|
+
l.setAttribute("transform", `rotate(${i}, 200, 200)`); c.appendChild(l);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
})();
|
|
713
|
+
|
|
714
|
+
async function init() {
|
|
715
|
+
loadDashboardState();
|
|
716
|
+
await fetchServerConfig();
|
|
717
|
+
startDisplayLoop();
|
|
718
|
+
connect();
|
|
719
|
+
}
|
|
720
|
+
|
|
541
721
|
window.addEventListener('load', init);
|
|
722
|
+
window.addEventListener('pagehide', saveDashboardState);
|