@sailingrotevista/rotevista-dash 1.0.0

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 ADDED
@@ -0,0 +1,462 @@
1
+ // ==========================================================================
2
+ // 1. CONFIGURAZIONE E COSTANTI
3
+ // ==========================================================================
4
+ const SIGNALK_SERVER_IP = "192.168.111.240:3000"; // Indirizzo IP e porta server SignalK
5
+ const ALARM_DANGER_DEPTH = 2.5; // Allarme rosso + suono (metri)
6
+ const ALARM_WARNING_DEPTH = 5.0; // Pre-allarme giallo (metri)
7
+
8
+ const RENDER_INTERVAL_MS = 1000; // Refresh UI (1 sec)
9
+ const TIMEOUT_MS = 5000; // Watchdog: oscura dato se manca per 5 sec
10
+
11
+ const SMOOTH_WINDOW_MS = 2000; // Media veloce (2 sec) per lancette SVG
12
+ const LONG_AVG_WINDOW_MS = 60000; // Media lunga (60 sec) per riquadri "MEAN"
13
+
14
+ const MAX_HISTORY_SAMPLES = 60; // Pixel max per sparklines
15
+ const REAL_SAMPLE_INTERVAL = 5000; // Campionamento sparkline reale (5 sec)
16
+ const SIM_SAMPLE_INTERVAL = 1000; // Campionamento sparkline simulazione (1 sec)
17
+
18
+ const STABILITY_TIME_TOLERANCE_MS = 2000; // Tolleranza riempimento buffer (es. 58s su 60s)
19
+ const STABILITY_CONFIDENCE_THRESHOLD = 0.90; // Indice circolare (R) minimo per non lampeggiare
20
+ const MIN_SPEED_FOR_STABILITY_KTS = 0.5; // Sotto questa velocità NON lampeggia nulla (barca ferma)
21
+
22
+ // ==========================================================================
23
+ // 2. VARIABILI GLOBALI E STATO
24
+ // ==========================================================================
25
+ let simulationMode = false;
26
+ let socket;
27
+ let renderInterval = null;
28
+ let simInterval = null;
29
+ let lastAvgUIUpdate = 0;
30
+ let audioCtx = null, lastAlarmTime = 0;
31
+ let curAwaRot = 0, curTwaRot = 0, curTrackRot = 0, curTwdRoseRot = 0;
32
+
33
+ const store = {
34
+ raw: {},
35
+ timestamps: {},
36
+ smoothBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [], leeway: [] },
37
+ longBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
38
+ histories: { stw: [], sog: [], depth: [], tws: [] },
39
+ lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0 }
40
+ };
41
+
42
+ const ui = {
43
+ stw: document.getElementById('stw'), sog: document.getElementById('sog'),
44
+ hdg: document.getElementById('hdg'), cog: document.getElementById('cog'),
45
+ awsSvg: document.getElementById('aws-val-svg'), awa: document.getElementById('awa-pointer'),
46
+ twa: document.getElementById('twa-pointer'), track: document.getElementById('track-pointer'),
47
+ tws: document.getElementById('tws'), depth: document.getElementById('depth'),
48
+ twaAvg: document.getElementById('twa-avg'), awaAvg: document.getElementById('awa-avg'),
49
+ twdAvg: document.getElementById('twd-avg'), twdArrow: document.getElementById('twd-arrow'),
50
+ leewayMask: document.getElementById('leeway-mask-rect'), leewayVal: document.getElementById('leeway-val'),
51
+ tackHdg: document.getElementById('tack-hdg'), tackCog: document.getElementById('tack-cog'),
52
+ status: document.getElementById('status')
53
+ };
54
+
55
+ // ==========================================================================
56
+ // 3. FUNZIONI MATEMATICHE E HELPER
57
+ // ==========================================================================
58
+ function radToDeg(rad) { return rad * (180 / Math.PI); }
59
+ function degToRad(deg) { return deg * (Math.PI / 180); }
60
+ function msToKts(ms) { return ms * 1.94384; }
61
+ function ktsToMs(kts) { return kts / 1.94384; }
62
+
63
+ function getShortestRotation(curr, target) {
64
+ let diff = (target - curr) % 360;
65
+ if (diff > 180) diff -= 360; else if (diff < -180) diff += 360;
66
+ return curr + diff;
67
+ }
68
+
69
+ function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false) {
70
+ const now = Date.now();
71
+ const validData = bufferArray.filter(item => (now - item.time) <= windowMs);
72
+ if (validData.length === 0) return null;
73
+
74
+ let sumSin = 0, sumCos = 0;
75
+ validData.forEach(item => { sumSin += Math.sin(item.val); sumCos += Math.cos(item.val); });
76
+
77
+ let R = Math.sqrt(sumSin * sumSin + sumCos * sumCos) / validData.length;
78
+ let timeSpan = validData[validData.length - 1].time - validData[0].time;
79
+ let isStable = (timeSpan >= windowMs - STABILITY_TIME_TOLERANCE_MS) && (R > STABILITY_CONFIDENCE_THRESHOLD);
80
+ let avgRad = Math.atan2(sumSin, sumCos);
81
+ let avgDeg = Math.round(radToDeg(avgRad));
82
+ let finalVal = signed ? avgDeg : (avgDeg + 360) % 360;
83
+
84
+ return { val: finalVal, stable: isStable };
85
+ }
86
+
87
+ function cleanBuffer(bufferArray, windowMs) {
88
+ const now = Date.now();
89
+ while (bufferArray.length > 0 && (now - bufferArray[0].time) > windowMs) bufferArray.shift();
90
+ }
91
+
92
+ // ==========================================================================
93
+ // 4. GESTIONE DATI IN INGRESSO (SIGNALK / SIMULATORE)
94
+ // ==========================================================================
95
+ function processIncomingData(path, val) {
96
+ const now = Date.now();
97
+ store.timestamps[path] = now;
98
+ store.raw[path] = val;
99
+
100
+ if (path === "navigation.headingTrue") { store.smoothBuf.hdg.push({ val: val, time: now }); store.longBuf.hdg.push({ val: val, time: now }); }
101
+ if (path === "navigation.courseOverGroundTrue") { store.smoothBuf.cog.push({ val: val, time: now }); store.longBuf.cog.push({ val: val, time: now }); }
102
+ if (path === "environment.wind.angleApparent") { store.smoothBuf.awa.push({ val: val, time: now }); store.longBuf.awa.push({ val: val, time: now }); }
103
+ if (path === "environment.wind.angleTrueWater") { store.smoothBuf.twa.push({ val: val, time: now }); store.longBuf.twa.push({ val: val, time: now }); }
104
+
105
+ // Calcolo TWD in locale
106
+ if (path === "environment.wind.angleTrueWater" || path === "navigation.headingTrue") {
107
+ if (store.raw["navigation.headingTrue"] !== undefined && store.raw["environment.wind.angleTrueWater"] !== undefined) {
108
+ let twdRad = (store.raw["navigation.headingTrue"] + store.raw["environment.wind.angleTrueWater"]) % (2 * Math.PI);
109
+ if (twdRad < 0) twdRad += (2 * Math.PI);
110
+ store.raw["environment.wind.directionTrue"] = twdRad;
111
+ store.timestamps["environment.wind.directionTrue"] = now;
112
+ }
113
+ }
114
+
115
+ if (store.raw["environment.wind.directionTrue"] !== undefined) {
116
+ store.smoothBuf.twd.push({ val: store.raw["environment.wind.directionTrue"], time: now });
117
+ store.longBuf.twd.push({ val: store.raw["environment.wind.directionTrue"], time: now });
118
+ }
119
+
120
+ // Calcolo Leeway in locale
121
+ if (path === "environment.wind.angleTrueWater" || path === "environment.wind.speedTrue" || path === "navigation.speedThroughWater") {
122
+ if (store.raw["environment.wind.angleTrueWater"] !== undefined && store.raw["environment.wind.speedTrue"] !== undefined && store.raw["navigation.speedThroughWater"] !== undefined) {
123
+ const twsKts = msToKts(store.raw["environment.wind.speedTrue"]);
124
+ const stwKts = msToKts(store.raw["navigation.speedThroughWater"]);
125
+ const stwSafe = Math.max(stwKts, 0.1);
126
+ let leewayDeg = - (12 * twsKts / (stwSafe * stwSafe)) * Math.sin(store.raw["environment.wind.angleTrueWater"]);
127
+ leewayDeg = Math.max(-20, Math.min(20, leewayDeg));
128
+ store.raw["navigation.leewayAngle"] = degToRad(leewayDeg);
129
+ store.timestamps["navigation.leewayAngle"] = now;
130
+ store.smoothBuf.leeway.push({ val: degToRad(leewayDeg), time: now });
131
+ }
132
+ } else if (path === "navigation.leewayAngle") {
133
+ store.smoothBuf.leeway.push({ val: val, time: now });
134
+ }
135
+ }
136
+
137
+ // ==========================================================================
138
+ // 5. MOTORE DI RENDERING PRINCIPALE (UI LOOP)
139
+ // ==========================================================================
140
+ function startDisplayLoop() {
141
+ if (renderInterval) clearInterval(renderInterval);
142
+
143
+ renderInterval = setInterval(() => {
144
+ const now = Date.now();
145
+
146
+ // --- WATCHDOG TIMEOUT ---
147
+ const pathsToWatch = {
148
+ "navigation.speedThroughWater": ui.stw, "navigation.speedOverGround": ui.sog,
149
+ "navigation.headingTrue": ui.hdg, "navigation.courseOverGroundTrue": ui.cog,
150
+ "environment.wind.speedApparent": ui.awsSvg,
151
+ "environment.depth.belowTransducer": ui.depth, "environment.wind.speedTrue": ui.tws
152
+ };
153
+ for (let p in pathsToWatch) {
154
+ if (!store.timestamps[p] || (now - store.timestamps[p] > TIMEOUT_MS)) {
155
+ pathsToWatch[p][pathsToWatch[p] === ui.awsSvg ? 'textContent' : 'innerText'] = "---";
156
+ if (p === "environment.depth.belowTransducer") ui.depth.classList.remove('alarm-warning', 'alarm-danger');
157
+ delete store.raw[p];
158
+ }
159
+ }
160
+
161
+ // --- RENDER DATI ISTANTANEI ---
162
+ if (store.raw["navigation.speedThroughWater"] !== undefined) {
163
+ const stw = msToKts(store.raw["navigation.speedThroughWater"]); ui.stw.innerText = stw.toFixed(1); manageHistory('stw', stw);
164
+ }
165
+ if (store.raw["navigation.speedOverGround"] !== undefined) {
166
+ const sog = msToKts(store.raw["navigation.speedOverGround"]); ui.sog.innerText = sog.toFixed(1); manageHistory('sog', sog);
167
+ }
168
+ if (store.raw["environment.depth.belowTransducer"] !== undefined) {
169
+ const d = store.raw["environment.depth.belowTransducer"]; ui.depth.innerText = d.toFixed(1); checkDepthAlarm(d); manageHistory('depth', d);
170
+ }
171
+ if (store.raw["environment.wind.speedTrue"] !== undefined) {
172
+ const tws = msToKts(store.raw["environment.wind.speedTrue"]); ui.tws.innerText = tws.toFixed(1); manageHistory('tws', tws);
173
+ }
174
+ if (store.raw["environment.wind.speedApparent"] !== undefined) {
175
+ ui.awsSvg.textContent = msToKts(store.raw["environment.wind.speedApparent"]).toFixed(1);
176
+ }
177
+
178
+ // --- RENDER QUADRANTE VENTO (SMOOTHING 2s) ---
179
+ const smoothLeewayObj = getCircularAverageFromBuffer(store.smoothBuf.leeway, SMOOTH_WINDOW_MS, true);
180
+ const smoothLeeway = smoothLeewayObj ? smoothLeewayObj.val : null;
181
+
182
+ const smoothAwaObj = getCircularAverageFromBuffer(store.smoothBuf.awa, SMOOTH_WINDOW_MS, true);
183
+ if (smoothAwaObj !== null) { curAwaRot = getShortestRotation(curAwaRot, smoothAwaObj.val); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
184
+
185
+ const smoothTwaObj = getCircularAverageFromBuffer(store.smoothBuf.twa, SMOOTH_WINDOW_MS, true);
186
+ if (smoothTwaObj !== null) { curTwaRot = getShortestRotation(curTwaRot, smoothTwaObj.val); ui.twa.setAttribute('transform', `rotate(${curTwaRot}, 200, 200)`); }
187
+
188
+ if (smoothLeeway !== null) {
189
+ updateLeewayDisplay(smoothLeeway);
190
+ curTrackRot = getShortestRotation(curTrackRot, smoothLeeway); ui.track.setAttribute('transform', `rotate(${curTrackRot}, 200, 200)`);
191
+ }
192
+
193
+ // --- RENDER MEDIE LUNGHE E TACK (Ogni 3s su buffer di 60s) ---
194
+ if (now - lastAvgUIUpdate > 3000) {
195
+ let hdgObj = getCircularAverageFromBuffer(store.longBuf.hdg, LONG_AVG_WINDOW_MS, false);
196
+ let cogObj = getCircularAverageFromBuffer(store.longBuf.cog, LONG_AVG_WINDOW_MS, false);
197
+ let awObj = getCircularAverageFromBuffer(store.longBuf.awa, LONG_AVG_WINDOW_MS, true);
198
+ let twObj = getCircularAverageFromBuffer(store.longBuf.twa, LONG_AVG_WINDOW_MS, true);
199
+ let twdObj = getCircularAverageFromBuffer(store.longBuf.twd, LONG_AVG_WINDOW_MS, false);
200
+
201
+ // Lettura velocità per soppressione allarmi stabilità all'ormeggio
202
+ let currentSogKts = 0;
203
+ if (store.histories.sog && store.histories.sog.length > 0) {
204
+ currentSogKts = store.histories.sog[store.histories.sog.length - 1];
205
+ } else if (store.raw["navigation.speedOverGround"] !== undefined) {
206
+ currentSogKts = msToKts(store.raw["navigation.speedOverGround"]);
207
+ }
208
+
209
+ const updateMeanUI = (el, obj) => {
210
+ if (!obj) {
211
+ el.innerHTML = `---&deg;`;
212
+ el.classList.remove('unstable-data');
213
+ } else {
214
+ el.innerHTML = `${obj.val.toString().padStart(3, '0')}&deg;`;
215
+ let isEffectivelyStable = obj.stable || (currentSogKts < MIN_SPEED_FOR_STABILITY_KTS);
216
+ if (isEffectivelyStable) el.classList.remove('unstable-data');
217
+ else el.classList.add('unstable-data');
218
+ }
219
+ };
220
+
221
+ updateMeanUI(ui.hdg, hdgObj);
222
+ updateMeanUI(ui.cog, cogObj);
223
+ updateMeanUI(ui.awaAvg, awObj);
224
+ updateMeanUI(ui.twaAvg, twObj);
225
+ updateMeanUI(ui.twdAvg, twdObj);
226
+
227
+ // Calcolo TACK (Mure Opposte)
228
+ if (hdgObj && twObj && hdgObj.val !== null && twObj.val !== null) {
229
+ let tackAngleOffset = twObj.val * 2;
230
+ let newHdg = (hdgObj.val - tackAngleOffset + 360) % 360;
231
+ ui.tackHdg.innerHTML = `${Math.round(newHdg).toString().padStart(3, '0')}&deg;`;
232
+
233
+ if (cogObj && cogObj.val !== null) {
234
+ let newCog = (cogObj.val - tackAngleOffset + 360) % 360;
235
+ ui.tackCog.innerHTML = `${Math.round(newCog).toString().padStart(3, '0')}&deg;`;
236
+ } else {
237
+ ui.tackCog.innerHTML = `---&deg;`;
238
+ }
239
+
240
+ // Lampeggio TACK con esenzione per barca ferma applicata
241
+ let tackMathStable = hdgObj.stable && twObj.stable && (!cogObj || cogObj.stable);
242
+ let tackEffectivelyStable = tackMathStable || (currentSogKts < MIN_SPEED_FOR_STABILITY_KTS);
243
+
244
+ if (tackEffectivelyStable) {
245
+ ui.tackHdg.classList.remove('unstable-data');
246
+ ui.tackCog.classList.remove('unstable-data');
247
+ } else {
248
+ ui.tackHdg.classList.add('unstable-data');
249
+ ui.tackCog.classList.add('unstable-data');
250
+ }
251
+ } else {
252
+ ui.tackHdg.innerHTML = `---&deg;`;
253
+ ui.tackCog.innerHTML = `---&deg;`;
254
+ ui.tackHdg.classList.remove('unstable-data');
255
+ ui.tackCog.classList.remove('unstable-data');
256
+ }
257
+
258
+ if (twdObj !== null) {
259
+ curTwdRoseRot = getShortestRotation(curTwdRoseRot, twdObj.val);
260
+ ui.twdArrow.setAttribute('transform', `rotate(${curTwdRoseRot}, 20, 20)`);
261
+ }
262
+ lastAvgUIUpdate = now;
263
+ }
264
+
265
+ // Pulizia ciclica dei buffer per evitare memory leak
266
+ cleanBuffer(store.smoothBuf.hdg, SMOOTH_WINDOW_MS); cleanBuffer(store.smoothBuf.cog, SMOOTH_WINDOW_MS);
267
+ cleanBuffer(store.smoothBuf.awa, SMOOTH_WINDOW_MS); cleanBuffer(store.smoothBuf.twa, SMOOTH_WINDOW_MS);
268
+ cleanBuffer(store.smoothBuf.twd, SMOOTH_WINDOW_MS); cleanBuffer(store.smoothBuf.leeway, SMOOTH_WINDOW_MS);
269
+ cleanBuffer(store.longBuf.awa, LONG_AVG_WINDOW_MS); cleanBuffer(store.longBuf.twa, LONG_AVG_WINDOW_MS);
270
+ cleanBuffer(store.longBuf.twd, LONG_AVG_WINDOW_MS); cleanBuffer(store.longBuf.hdg, LONG_AVG_WINDOW_MS);
271
+ cleanBuffer(store.longBuf.cog, LONG_AVG_WINDOW_MS);
272
+
273
+ }, RENDER_INTERVAL_MS);
274
+ }
275
+
276
+ // ==========================================================================
277
+ // 6. ALLARMI AUDIO
278
+ // ==========================================================================
279
+ document.addEventListener('click', () => { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }, { once: true });
280
+ function playBingBing() {
281
+ if (!audioCtx) return; const n = Date.now(); if (n - lastAlarmTime < 3000) return; lastAlarmTime = n;
282
+ function b(f, s) {
283
+ const o = audioCtx.createOscillator(); const g = audioCtx.createGain();
284
+ o.connect(g); g.connect(audioCtx.destination); o.frequency.value = f;
285
+ g.gain.setValueAtTime(0.1, s); g.gain.exponentialRampToValueAtTime(0.01, s + 0.4);
286
+ o.start(s); o.stop(s + 0.5);
287
+ }
288
+ b(880, audioCtx.currentTime); b(880, audioCtx.currentTime + 0.6);
289
+ }
290
+ function checkDepthAlarm(m) {
291
+ ui.depth.classList.remove('alarm-warning', 'alarm-danger');
292
+ if (m < ALARM_DANGER_DEPTH) { ui.depth.classList.add('alarm-danger'); playBingBing(); }
293
+ else if (m < ALARM_WARNING_DEPTH) ui.depth.classList.add('alarm-warning');
294
+ }
295
+
296
+ // ==========================================================================
297
+ // 7. CONNESSIONE SIGNALK (SAFARI FRIENDLY)
298
+ // ==========================================================================
299
+ let isConnecting = false;
300
+
301
+ function connect() {
302
+ // Se siamo in simulazione o se c'è già un tentativo in corso, ci fermiamo
303
+ if (simulationMode || isConnecting) return;
304
+
305
+ // Se il socket esiste ed è già aperto o in fase di apertura, non facciamo nulla
306
+ if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
307
+ return;
308
+ }
309
+
310
+ isConnecting = true;
311
+
312
+ try {
313
+ socket = new WebSocket(`ws://${SIGNALK_SERVER_IP}/signalk/v1/stream?subscribe=all`);
314
+
315
+ socket.onopen = () => {
316
+ isConnecting = false;
317
+ ui.status.className = "online";
318
+ ui.status.innerText = "ONLINE";
319
+ };
320
+
321
+ socket.onmessage = (e) => {
322
+ if (simulationMode) return;
323
+ const d = JSON.parse(e.data);
324
+ if (d.updates) d.updates.forEach(u => u.values && u.values.forEach(v => processIncomingData(v.path, v.value)));
325
+ };
326
+
327
+ socket.onerror = (err) => {
328
+ // Non forziamo la chiusura qui. Lasciamo che Safari gestisca l'errore
329
+ // in modo nativo e faccia scattare onclose da solo.
330
+ isConnecting = false;
331
+ };
332
+
333
+ socket.onclose = () => {
334
+ isConnecting = false;
335
+ if (!simulationMode) {
336
+ ui.status.className = "offline";
337
+ ui.status.innerText = "OFFLINE";
338
+ // Attendiamo ben 5 secondi prima di riprovare, per non infastidire Safari
339
+ setTimeout(connect, 5000);
340
+ }
341
+ };
342
+ } catch (e) {
343
+ isConnecting = false;
344
+ setTimeout(connect, 5000);
345
+ }
346
+ }
347
+
348
+
349
+ // ==========================================================================
350
+ // 8. SIMULATORE / MOTORE FISICO
351
+ // ==========================================================================
352
+ const physicsEngine = {
353
+ time: 0,
354
+ config: { hdgStart: Math.random() * 360, hdgDir: Math.random() > 0.5 ? 1 : -1, twdBase: Math.random() * 360, rotSpeed: 360 / 600 },
355
+ getPolars: function(twaDeg, twsKts) {
356
+ let eff = 0; const aT = Math.abs(twaDeg);
357
+ if (aT < 30) eff = 0.1; else if (aT < 45) eff = 0.75; else if (aT < 90) eff = 1.0; else if (aT < 150) eff = 0.85; else eff = 0.65;
358
+ return Math.min(Math.sqrt(twsKts) * 2.2 * eff, 11.0);
359
+ },
360
+ step: function() {
361
+ this.time++;
362
+ const hdgDeg = (this.config.hdgStart + (this.time * this.config.rotSpeed * this.config.hdgDir) + 360) % 360;
363
+ const twdDeg = (this.config.twdBase + Math.sin(this.time / 20) * 15 + 360) % 360;
364
+ const twsKts = 14 + Math.sin(this.time / 40) * 6;
365
+ const depthM = 20 + Math.sin(this.time / 25) * 18;
366
+ let twaDeg = (twdDeg - hdgDeg + 360) % 360; if (twaDeg > 180) twaDeg -= 360;
367
+ const stwKts = this.getPolars(twaDeg, twsKts);
368
+ const stwMs = ktsToMs(stwKts), twsMs = ktsToMs(twsKts), twaRad = degToRad(twaDeg);
369
+ const vAx = twsMs * Math.sin(twaRad), vAy = twsMs * Math.cos(twaRad) + stwMs;
370
+ const awsMs = Math.sqrt(vAx*vAx + vAy*vAy), awaRad = Math.atan2(vAx, vAy);
371
+
372
+ return {
373
+ "navigation.headingTrue": degToRad(hdgDeg),
374
+ "navigation.courseOverGroundTrue": degToRad((hdgDeg + 4) % 360), // COG fittizio simulato
375
+ "navigation.speedThroughWater": stwMs,
376
+ "navigation.speedOverGround": stwMs * 1.05,
377
+ "environment.wind.speedTrue": twsMs,
378
+ "environment.wind.directionTrue": degToRad(twdDeg),
379
+ "environment.wind.angleTrueWater": twaRad,
380
+ "environment.wind.speedApparent": awsMs,
381
+ "environment.wind.angleApparent": awaRad,
382
+ "environment.depth.belowTransducer": depthM
383
+ };
384
+ }
385
+ };
386
+
387
+ let dC = 0, lC = 0;
388
+ ui.depth.closest('.data-box').addEventListener('click', () => {
389
+ const n = Date.now(); if (n - lC < 500) dC++; else dC = 1; lC = n;
390
+ if (dC === 3) {
391
+ simulationMode = !simulationMode;
392
+ if (simulationMode) {
393
+ if (socket) socket.close(); ui.status.innerText = "SIM ATTIVO";
394
+ if(simInterval) clearInterval(simInterval);
395
+ simInterval = setInterval(() => { const simulatedData = physicsEngine.step(); for (let path in simulatedData) processIncomingData(path, simulatedData[path]); }, 200);
396
+ } else location.reload();
397
+ dC = 0;
398
+ }
399
+ });
400
+
401
+ // ==========================================================================
402
+ // 9. FUNZIONI UI GRAFICHE (Sparklines, Leeway, Ticks)
403
+ // ==========================================================================
404
+ function updateLeewayDisplay(deg) {
405
+ const c = 125, px = 125/20; let w = Math.min(Math.abs(deg)*px, 125);
406
+ ui.leewayMask.setAttribute('x', deg >= 0 ? c : c - w); ui.leewayMask.setAttribute('width', w);
407
+ ui.leewayVal.textContent = `LEEWAY: ${deg.toFixed(1)}°`;
408
+ }
409
+
410
+ function manageHistory(t, v) {
411
+ const n = Date.now(), i = simulationMode ? SIM_SAMPLE_INTERVAL : REAL_SAMPLE_INTERVAL;
412
+ if (n - store.lastUpdates[t] > i || store.histories[t].length === 0) {
413
+ store.histories[t].push(v); if (store.histories[t].length > MAX_HISTORY_SAMPLES) store.histories[t].shift(); store.lastUpdates[t] = n;
414
+ }
415
+ const m = Math.max(...store.histories[t], 1); const c = getDynamicScale(t, m);
416
+ updateScaleLabels(t, c.scale); drawGraph(store.histories[t], t + '-graph', c.scale, c.gridLines);
417
+ }
418
+
419
+ function getDynamicScale(t, m) {
420
+ if (t === 'stw' || t === 'sog') return { scale: 12, gridLines: [3, 6, 9] };
421
+ const s = { tws: [10, 25, 45, 60], depth: [10, 20, 50, 200] };
422
+ const av = s[t] || [10, 20, 50]; let sel = av[av.length - 1];
423
+ for (let x of av) if (m <= x) { sel = x; break; }
424
+ return { scale: sel, gridLines: [sel/4, sel/2, (sel/4)*3] };
425
+ }
426
+
427
+ function updateScaleLabels(t, s) { const el = document.getElementById(t + '-scale'); if (el) el.innerHTML = `<span>${s}</span><span>${s/2}</span><span>0</span>`; }
428
+
429
+ function drawGraph(d, id, s, gl) {
430
+ const svg = document.getElementById(id); if (!svg || d.length < 2) return;
431
+ const w = 200, h = 40; let gH = "";
432
+ gl.forEach(v => { const y = h - (v / s) * h; gH += `<line x1="0" y1="${y}" x2="${w}" y2="${y}" stroke="rgba(255,255,255,0.08)" stroke-width="0.5" />`; });
433
+ let p = ""; d.forEach((v, i) => { const x = (i/(MAX_HISTORY_SAMPLES - 1))*w; const y = h - (Math.min(v, s)/s)*h; p += `${i===0?'M':'L'} ${x} ${y} `; });
434
+ const aP = p + ` L ${((d.length-1)/(MAX_HISTORY_SAMPLES - 1))*w} ${h} L 0 ${h} Z`;
435
+ const clrs = { 'stw-graph': { s: '#2ecc71', f: 'rgba(46, 204, 113, 0.15)' }, 'sog-graph': { s: '#f39c12', f: 'rgba(243, 156, 18, 0.15)' }, 'depth-graph': { s: '#3498db', f: 'rgba(52, 152, 219, 0.15)' }, 'tws-graph': { s: '#f1c40f', f: 'rgba(241, 196, 15, 0.15)' } };
436
+ svg.innerHTML = `${gH}<path d="${aP}" fill="${clrs[id].f}" stroke="none" /><path d="${p}" fill="none" stroke="${clrs[id].s}" stroke-width="1.5" />`;
437
+ }
438
+
439
+ function generateTicks() {
440
+ const c = document.getElementById('ticks');
441
+ for (let i = 0; i < 360; i += 10) {
442
+ const l = document.createElementNS("http://www.w3.org/2000/svg", "line");
443
+ const m = i % 30 === 0; l.setAttribute("x1", "200"); l.setAttribute("y1", "40");
444
+ l.setAttribute("x2", "200"); l.setAttribute("y2", (m ? 60 : 50));
445
+ l.setAttribute("stroke", m ? "#fff" : "#666");
446
+ l.setAttribute("stroke-width", m ? "2" : "1");
447
+ l.setAttribute("transform", `rotate(${i}, 200, 200)`);
448
+ c.appendChild(l);
449
+ }
450
+ }
451
+
452
+ // ==========================================================================
453
+ // 10. INIZIALIZZAZIONE (Attesa caricamento DOM per Safari)
454
+ // ==========================================================================
455
+ generateTicks();
456
+ startDisplayLoop();
457
+
458
+ // Attendiamo che il browser abbia finito di renderizzare la grafica
459
+ // prima di aprire il socket, evita blocchi su iPad/iPhone.
460
+ window.addEventListener('load', () => {
461
+ setTimeout(connect, 500);
462
+ });
package/app.js.txt ADDED
@@ -0,0 +1,311 @@
1
+ const SIGNALK_SERVER_IP = "192.168.111.240:3000";
2
+ const ALARM_DANGER_DEPTH = 2.5;
3
+ const ALARM_WARNING_DEPTH = 5.0;
4
+
5
+ const SMOOTH_WINDOW_MS = 2000;
6
+ const LONG_AVG_WINDOW_MS = 60000;
7
+ const RENDER_INTERVAL_MS = 1000;
8
+ const TIMEOUT_MS = 5000;
9
+
10
+ const MAX_HISTORY_SAMPLES = 60;
11
+ const REAL_SAMPLE_INTERVAL = 5000;
12
+ const SIM_SAMPLE_INTERVAL = 1000;
13
+
14
+ let simulationMode = false;
15
+ let socket;
16
+ let renderInterval = null;
17
+ let simInterval = null;
18
+ let lastAvgUIUpdate = 0;
19
+ let audioCtx = null, lastAlarmTime = 0;
20
+
21
+ let curAwaRot = 0, curTwaRot = 0, curTrackRot = 0, curTwdRoseRot = 0;
22
+
23
+ const store = {
24
+ raw: {},
25
+ timestamps: {},
26
+ smoothBuf: { hdg: [], awa: [], twa: [], twd: [], leeway: [] },
27
+ longBuf: { awa: [], twa: [], twd: [] },
28
+ histories: { stw: [], sog: [], depth: [], tws: [] },
29
+ lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0 }
30
+ };
31
+
32
+ const ui = {
33
+ stw: document.getElementById('stw'), sog: document.getElementById('sog'),
34
+ hdg: document.getElementById('hdg'), cog: document.getElementById('cog'),
35
+ awsSvg: document.getElementById('aws-val-svg'), awa: document.getElementById('awa-pointer'),
36
+ twa: document.getElementById('twa-pointer'), track: document.getElementById('track-pointer'),
37
+ tws: document.getElementById('tws'), depth: document.getElementById('depth'),
38
+ twaAvg: document.getElementById('twa-avg'), awaAvg: document.getElementById('awa-avg'),
39
+ twdAvg: document.getElementById('twd-avg'), twdArrow: document.getElementById('twd-arrow'),
40
+ leewayMask: document.getElementById('leeway-mask-rect'), leewayVal: document.getElementById('leeway-val'),
41
+ status: document.getElementById('status')
42
+ };
43
+
44
+ function radToDeg(rad) { return rad * (180 / Math.PI); }
45
+ function degToRad(deg) { return deg * (Math.PI / 180); }
46
+ function msToKts(ms) { return ms * 1.94384; }
47
+ function ktsToMs(kts) { return kts / 1.94384; }
48
+
49
+ function getShortestRotation(curr, target) {
50
+ let diff = (target - curr) % 360;
51
+ if (diff > 180) diff -= 360; else if (diff < -180) diff += 360;
52
+ return curr + diff;
53
+ }
54
+
55
+ function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false) {
56
+ const now = Date.now();
57
+ const validData = bufferArray.filter(item => (now - item.time) <= windowMs);
58
+ if (validData.length === 0) return null;
59
+ let sumSin = 0, sumCos = 0;
60
+ validData.forEach(item => { sumSin += Math.sin(item.val); sumCos += Math.cos(item.val); });
61
+ let avgRad = Math.atan2(sumSin, sumCos);
62
+ let avgDeg = Math.round(radToDeg(avgRad));
63
+ return signed ? avgDeg : (avgDeg + 360) % 360;
64
+ }
65
+
66
+ function cleanBuffer(bufferArray, windowMs) {
67
+ const now = Date.now();
68
+ while (bufferArray.length > 0 && (now - bufferArray[0].time) > windowMs) bufferArray.shift();
69
+ }
70
+
71
+ function processIncomingData(path, val) {
72
+ const now = Date.now();
73
+ store.timestamps[path] = now;
74
+ store.raw[path] = val;
75
+
76
+ if (path === "navigation.headingMagnetic") store.smoothBuf.hdg.push({ val: val, time: now });
77
+ if (path === "environment.wind.angleApparent") { store.smoothBuf.awa.push({ val: val, time: now }); store.longBuf.awa.push({ val: val, time: now }); }
78
+ if (path === "environment.wind.angleTrueWater") { store.smoothBuf.twa.push({ val: val, time: now }); store.longBuf.twa.push({ val: val, time: now }); }
79
+
80
+ if (path === "environment.wind.angleTrueWater" || path === "navigation.headingMagnetic") {
81
+ if (store.raw["navigation.headingMagnetic"] !== undefined && store.raw["environment.wind.angleTrueWater"] !== undefined) {
82
+ let twdRad = (store.raw["navigation.headingMagnetic"] + store.raw["environment.wind.angleTrueWater"]) % (2 * Math.PI);
83
+ if (twdRad < 0) twdRad += (2 * Math.PI);
84
+ store.raw["environment.wind.directionTrue"] = twdRad;
85
+ store.timestamps["environment.wind.directionTrue"] = now;
86
+ }
87
+ }
88
+
89
+ if (store.raw["environment.wind.directionTrue"] !== undefined) {
90
+ store.smoothBuf.twd.push({ val: store.raw["environment.wind.directionTrue"], time: now });
91
+ store.longBuf.twd.push({ val: store.raw["environment.wind.directionTrue"], time: now });
92
+ }
93
+
94
+ if (path === "environment.wind.angleTrueWater" || path === "environment.wind.speedTrue" || path === "navigation.speedThroughWater") {
95
+ if (store.raw["environment.wind.angleTrueWater"] !== undefined && store.raw["environment.wind.speedTrue"] !== undefined && store.raw["navigation.speedThroughWater"] !== undefined) {
96
+ const twsKts = msToKts(store.raw["environment.wind.speedTrue"]);
97
+ const stwKts = msToKts(store.raw["navigation.speedThroughWater"]);
98
+ const stwSafe = Math.max(stwKts, 0.1);
99
+ let leewayDeg = - (12 * twsKts / (stwSafe * stwSafe)) * Math.sin(store.raw["environment.wind.angleTrueWater"]);
100
+ leewayDeg = Math.max(-20, Math.min(20, leewayDeg));
101
+ store.raw["navigation.leewayAngle"] = degToRad(leewayDeg);
102
+ store.timestamps["navigation.leewayAngle"] = now;
103
+ store.smoothBuf.leeway.push({ val: degToRad(leewayDeg), time: now });
104
+ }
105
+ } else if (path === "navigation.leewayAngle") {
106
+ store.smoothBuf.leeway.push({ val: val, time: now });
107
+ }
108
+ }
109
+
110
+ // --- RENDER ENGINE PRINCIPALE (1Hz) ---
111
+ function startDisplayLoop() {
112
+ if (renderInterval) clearInterval(renderInterval);
113
+
114
+ renderInterval = setInterval(() => {
115
+ const now = Date.now();
116
+
117
+ // 1. Watchdog Timeout
118
+ const pathsToWatch = {
119
+ "navigation.speedThroughWater": ui.stw, "navigation.speedOverGround": ui.sog,
120
+ "navigation.headingMagnetic": ui.hdg, "environment.wind.speedApparent": ui.awsSvg,
121
+ "environment.depth.belowTransducer": ui.depth, "environment.wind.speedTrue": ui.tws
122
+ };
123
+ for (let p in pathsToWatch) {
124
+ if (!store.timestamps[p] || (now - store.timestamps[p] > TIMEOUT_MS)) {
125
+ pathsToWatch[p][pathsToWatch[p] === ui.awsSvg ? 'textContent' : 'innerText'] = "---";
126
+ if (p === "environment.depth.belowTransducer") ui.depth.classList.remove('alarm-warning', 'alarm-danger');
127
+ delete store.raw[p];
128
+ }
129
+ }
130
+
131
+ // 2. Render Istantanei
132
+ if (store.raw["navigation.speedThroughWater"] !== undefined) {
133
+ const stw = msToKts(store.raw["navigation.speedThroughWater"]); ui.stw.innerText = stw.toFixed(1); manageHistory('stw', stw);
134
+ }
135
+ if (store.raw["navigation.speedOverGround"] !== undefined) {
136
+ const sog = msToKts(store.raw["navigation.speedOverGround"]); ui.sog.innerText = sog.toFixed(1); manageHistory('sog', sog);
137
+ }
138
+ if (store.raw["environment.depth.belowTransducer"] !== undefined) {
139
+ const d = store.raw["environment.depth.belowTransducer"]; ui.depth.innerText = d.toFixed(1); checkDepthAlarm(d); manageHistory('depth', d);
140
+ }
141
+ if (store.raw["environment.wind.speedTrue"] !== undefined) {
142
+ const tws = msToKts(store.raw["environment.wind.speedTrue"]); ui.tws.innerText = tws.toFixed(1); manageHistory('tws', tws);
143
+ }
144
+ if (store.raw["environment.wind.speedApparent"] !== undefined) ui.awsSvg.textContent = msToKts(store.raw["environment.wind.speedApparent"]).toFixed(1);
145
+
146
+ // 3. Render Smussati (2s)
147
+ const smoothHdg = getCircularAverageFromBuffer(store.smoothBuf.hdg, SMOOTH_WINDOW_MS, false);
148
+ const smoothLeeway = getCircularAverageFromBuffer(store.smoothBuf.leeway, SMOOTH_WINDOW_MS, true);
149
+
150
+ if (smoothHdg !== null) {
151
+ ui.hdg.innerHTML = `${Math.round(smoothHdg).toString().padStart(3, '0')}&deg;`; // Aggiunto gradi in chiaro
152
+ if (smoothLeeway !== null) {
153
+ let cog = (smoothHdg + smoothLeeway + 360) % 360;
154
+ ui.cog.innerHTML = `${Math.round(cog).toString().padStart(3, '0')}&deg;`;
155
+ }
156
+ }
157
+
158
+ const smoothAwa = getCircularAverageFromBuffer(store.smoothBuf.awa, SMOOTH_WINDOW_MS, true);
159
+ if (smoothAwa !== null) { curAwaRot = getShortestRotation(curAwaRot, smoothAwa); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
160
+
161
+ const smoothTwa = getCircularAverageFromBuffer(store.smoothBuf.twa, SMOOTH_WINDOW_MS, true);
162
+ if (smoothTwa !== null) { curTwaRot = getShortestRotation(curTwaRot, smoothTwa); ui.twa.setAttribute('transform', `rotate(${curTwaRot}, 200, 200)`); }
163
+
164
+ if (smoothLeeway !== null) {
165
+ updateLeewayDisplay(smoothLeeway);
166
+ curTrackRot = getShortestRotation(curTrackRot, smoothLeeway); ui.track.setAttribute('transform', `rotate(${curTrackRot}, 200, 200)`);
167
+ }
168
+
169
+ // 4. Render Medie Lunghe (60s)
170
+ if (now - lastAvgUIUpdate > 3000) {
171
+ let awA = getCircularAverageFromBuffer(store.longBuf.awa, LONG_AVG_WINDOW_MS, true);
172
+ let twA = getCircularAverageFromBuffer(store.longBuf.twa, LONG_AVG_WINDOW_MS, true);
173
+ let twD = getCircularAverageFromBuffer(store.longBuf.twd, LONG_AVG_WINDOW_MS, false);
174
+
175
+ ui.awaAvg.innerHTML = awA === null ? `---&deg;` : `${awA.toString().padStart(3, '0')}&deg;`;
176
+ ui.twaAvg.innerHTML = twA === null ? `---&deg;` : `${twA.toString().padStart(3, '0')}&deg;`;
177
+ ui.twdAvg.innerHTML = twD === null ? `---&deg;` : `${twD.toString().padStart(3, '0')}&deg;`;
178
+
179
+ if (twD !== null) { curTwdRoseRot = getShortestRotation(curTwdRoseRot, twD); ui.twdArrow.setAttribute('transform', `rotate(${curTwdRoseRot}, 20, 20)`); }
180
+ lastAvgUIUpdate = now;
181
+ }
182
+
183
+ cleanBuffer(store.smoothBuf.hdg, SMOOTH_WINDOW_MS); cleanBuffer(store.smoothBuf.awa, SMOOTH_WINDOW_MS);
184
+ cleanBuffer(store.smoothBuf.twa, SMOOTH_WINDOW_MS); cleanBuffer(store.smoothBuf.twd, SMOOTH_WINDOW_MS);
185
+ cleanBuffer(store.smoothBuf.leeway, SMOOTH_WINDOW_MS); cleanBuffer(store.longBuf.awa, LONG_AVG_WINDOW_MS);
186
+ cleanBuffer(store.longBuf.twa, LONG_AVG_WINDOW_MS); cleanBuffer(store.longBuf.twd, LONG_AVG_WINDOW_MS);
187
+
188
+ }, RENDER_INTERVAL_MS);
189
+ }
190
+
191
+ document.addEventListener('click', () => { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }, { once: true });
192
+ function playBingBing() {
193
+ if (!audioCtx) return; const n = Date.now(); if (n - lastAlarmTime < 3000) return; lastAlarmTime = n;
194
+ function b(f, s) {
195
+ const o = audioCtx.createOscillator(); const g = audioCtx.createGain();
196
+ o.connect(g); g.connect(audioCtx.destination); o.frequency.value = f;
197
+ g.gain.setValueAtTime(0.1, s); g.gain.exponentialRampToValueAtTime(0.01, s + 0.4);
198
+ o.start(s); o.stop(s + 0.5);
199
+ }
200
+ b(880, audioCtx.currentTime); b(880, audioCtx.currentTime + 0.6);
201
+ }
202
+ function checkDepthAlarm(m) {
203
+ ui.depth.classList.remove('alarm-warning', 'alarm-danger');
204
+ if (m < ALARM_DANGER_DEPTH) { ui.depth.classList.add('alarm-danger'); playBingBing(); }
205
+ else if (m < ALARM_WARNING_DEPTH) ui.depth.classList.add('alarm-warning');
206
+ }
207
+
208
+ function connect() {
209
+ if (simulationMode) return;
210
+ socket = new WebSocket(`ws://${SIGNALK_SERVER_IP}/signalk/v1/stream?subscribe=all`);
211
+ socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; };
212
+ socket.onmessage = (e) => {
213
+ if (simulationMode) return;
214
+ const d = JSON.parse(e.data);
215
+ if (d.updates) d.updates.forEach(u => u.values && u.values.forEach(v => processIncomingData(v.path, v.value)));
216
+ };
217
+ socket.onclose = () => !simulationMode && setTimeout(connect, 5000);
218
+ }
219
+
220
+ const physicsEngine = {
221
+ time: 0,
222
+ config: { hdgStart: Math.random() * 360, hdgDir: Math.random() > 0.5 ? 1 : -1, twdBase: Math.random() * 360, rotSpeed: 360 / 600 },
223
+ getPolars: function(twaDeg, twsKts) {
224
+ let eff = 0; const aT = Math.abs(twaDeg);
225
+ if (aT < 30) eff = 0.1; else if (aT < 45) eff = 0.75; else if (aT < 90) eff = 1.0; else if (aT < 150) eff = 0.85; else eff = 0.65;
226
+ return Math.min(Math.sqrt(twsKts) * 2.2 * eff, 11.0);
227
+ },
228
+ step: function() {
229
+ this.time++;
230
+ const hdgDeg = (this.config.hdgStart + (this.time * this.config.rotSpeed * this.config.hdgDir) + 360) % 360;
231
+ const twdDeg = (this.config.twdBase + Math.sin(this.time / 20) * 15 + 360) % 360;
232
+ const twsKts = 14 + Math.sin(this.time / 40) * 6;
233
+ const depthM = 20 + Math.sin(this.time / 25) * 18;
234
+ let twaDeg = (twdDeg - hdgDeg + 360) % 360; if (twaDeg > 180) twaDeg -= 360;
235
+ const stwKts = this.getPolars(twaDeg, twsKts);
236
+ const stwMs = ktsToMs(stwKts), twsMs = ktsToMs(twsKts), twaRad = degToRad(twaDeg);
237
+ const vAx = twsMs * Math.sin(twaRad), vAy = twsMs * Math.cos(twaRad) + stwMs;
238
+ const awsMs = Math.sqrt(vAx*vAx + vAy*vAy), awaRad = Math.atan2(vAx, vAy);
239
+
240
+ return {
241
+ "navigation.headingMagnetic": degToRad(hdgDeg),
242
+ "navigation.speedThroughWater": stwMs, "navigation.speedOverGround": stwMs * 1.05,
243
+ "environment.wind.speedTrue": twsMs, "environment.wind.directionTrue": degToRad(twdDeg),
244
+ "environment.wind.angleTrueWater": twaRad, "environment.wind.speedApparent": awsMs,
245
+ "environment.wind.angleApparent": awaRad, "environment.depth.belowTransducer": depthM
246
+ };
247
+ }
248
+ };
249
+
250
+ let dC = 0, lC = 0;
251
+ ui.depth.closest('.data-box').addEventListener('click', () => {
252
+ const n = Date.now(); if (n - lC < 500) dC++; else dC = 1; lC = n;
253
+ if (dC === 3) {
254
+ simulationMode = !simulationMode;
255
+ if (simulationMode) {
256
+ if (socket) socket.close(); ui.status.innerText = "SIM ENGINE ATTIVO";
257
+ if(simInterval) clearInterval(simInterval);
258
+ simInterval = setInterval(() => { const simulatedData = physicsEngine.step(); for (let path in simulatedData) processIncomingData(path, simulatedData[path]); }, 200);
259
+ } else location.reload();
260
+ dC = 0;
261
+ }
262
+ });
263
+
264
+ function updateLeewayDisplay(deg) {
265
+ const c = 125, px = 125/20; let w = Math.min(Math.abs(deg)*px, 125);
266
+ ui.leewayMask.setAttribute('x', deg >= 0 ? c : c - w); ui.leewayMask.setAttribute('width', w);
267
+ ui.leewayVal.textContent = `LEEWAY: ${deg.toFixed(1)}°`;
268
+ }
269
+
270
+ function manageHistory(t, v) {
271
+ const n = Date.now(), i = simulationMode ? SIM_SAMPLE_INTERVAL : REAL_SAMPLE_INTERVAL;
272
+ if (n - store.lastUpdates[t] > i || store.histories[t].length === 0) {
273
+ store.histories[t].push(v); if (store.histories[t].length > MAX_HISTORY_SAMPLES) store.histories[t].shift(); store.lastUpdates[t] = n;
274
+ }
275
+ const m = Math.max(...store.histories[t], 1); const c = getDynamicScale(t, m);
276
+ updateScaleLabels(t, c.scale); drawGraph(store.histories[t], t + '-graph', c.scale, c.gridLines);
277
+ }
278
+
279
+ function getDynamicScale(t, m) {
280
+ if (t === 'stw' || t === 'sog') return { scale: 12, gridLines: [3, 6, 9] };
281
+ const s = { tws: [10, 25, 45, 60], depth: [10, 20, 50, 200] };
282
+ const av = s[t] || [10, 20, 50]; let sel = av[av.length - 1];
283
+ for (let x of av) if (m <= x) { sel = x; break; }
284
+ return { scale: sel, gridLines: [sel/4, sel/2, (sel/4)*3] };
285
+ }
286
+
287
+ function updateScaleLabels(t, s) { const el = document.getElementById(t + '-scale'); if (el) el.innerHTML = `<span>${s}</span><span>${s/2}</span><span>0</span>`; }
288
+
289
+ function drawGraph(d, id, s, gl) {
290
+ const svg = document.getElementById(id); if (!svg || d.length < 2) return;
291
+ const w = 200, h = 40; let gH = "";
292
+ gl.forEach(v => { const y = h - (v / s) * h; gH += `<line x1="0" y1="${y}" x2="${w}" y2="${y}" stroke="rgba(255,255,255,0.08)" stroke-width="0.5" />`; });
293
+ let p = ""; d.forEach((v, i) => { const x = (i/(MAX_HISTORY_SAMPLES - 1))*w; const y = h - (Math.min(v, s)/s)*h; p += `${i===0?'M':'L'} ${x} ${y} `; });
294
+ const aP = p + ` L ${((d.length-1)/(MAX_HISTORY_SAMPLES - 1))*w} ${h} L 0 ${h} Z`;
295
+ const clrs = { 'stw-graph': { s: '#2ecc71', f: 'rgba(46, 204, 113, 0.15)' }, 'sog-graph': { s: '#f39c12', f: 'rgba(243, 156, 18, 0.15)' }, 'depth-graph': { s: '#3498db', f: 'rgba(52, 152, 219, 0.15)' }, 'tws-graph': { s: '#f1c40f', f: 'rgba(241, 196, 15, 0.15)' } };
296
+ svg.innerHTML = `${gH}<path d="${aP}" fill="${clrs[id].f}" stroke="none" /><path d="${p}" fill="none" stroke="${clrs[id].s}" stroke-width="1.5" />`;
297
+ }
298
+
299
+ function generateTicks() {
300
+ const c = document.getElementById('ticks');
301
+ for (let i = 0; i < 360; i += 10) {
302
+ const l = document.createElementNS("http://www.w3.org/2000/svg", "line");
303
+ const m = i % 30 === 0; l.setAttribute("x1", "200"); l.setAttribute("y1", "40");
304
+ l.setAttribute("x2", "200"); l.setAttribute("y2", (m ? 60 : 50));
305
+ l.setAttribute("stroke", m ? "#fff" : "#666");
306
+ l.setAttribute("stroke-width", m ? "2" : "1");
307
+ l.setAttribute("transform", `rotate(${i}, 200, 200)`);
308
+ c.appendChild(l);
309
+ }
310
+ }
311
+ generateTicks(); startDisplayLoop(); connect();
package/index.html ADDED
@@ -0,0 +1,222 @@
1
+ <!DOCTYPE html>
2
+ <html lang="it">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <meta name="apple-mobile-web-app-capable" content="yes">
7
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8
+ <link rel="manifest" href="manifest.json">
9
+ <title>Sailing Dashboard Pro - Final Details</title>
10
+ <link rel="stylesheet" href="style.css">
11
+ </head>
12
+ <body>
13
+
14
+ <!-- Etichetta di stato connessione SignalK -->
15
+ <div id="status" class="offline">OFFLINE</div>
16
+
17
+ <div class="main-container">
18
+
19
+ <!-- ======================================================= -->
20
+ <!-- COLONNA SINISTRA: Dati di navigazione e rotta -->
21
+ <!-- ======================================================= -->
22
+ <div class="side-panel">
23
+
24
+ <!-- STW con Grafico Dinamico (preserveAspectRatio="none" lo adatta in altezza) -->
25
+ <div class="data-box">
26
+ <div class="label-row"><span class="label">STW</span><span class="unit">kts</span></div>
27
+ <span class="value" id="stw">0.0</span>
28
+ <div class="graph-wrapper">
29
+ <svg class="sparkline" id="stw-graph" viewBox="0 0 200 40" preserveAspectRatio="none"></svg>
30
+ <div class="scale-labels right" id="stw-scale"></div>
31
+ </div>
32
+ </div>
33
+
34
+ <!-- SOG con Grafico Dinamico -->
35
+ <div class="data-box">
36
+ <div class="label-row"><span class="label">SOG</span><span class="unit">kts</span></div>
37
+ <span class="value" id="sog">0.0</span>
38
+ <div class="graph-wrapper">
39
+ <svg class="sparkline" id="sog-graph" viewBox="0 0 200 40" preserveAspectRatio="none"></svg>
40
+ <div class="scale-labels right" id="sog-scale"></div>
41
+ </div>
42
+ </div>
43
+
44
+ <!-- Dati Singoli (value-large gestisce font dinamico e allineamento in basso) -->
45
+ <div class="data-box">
46
+ <div class="label-row"><span class="label">HEADING MEAN</span></div>
47
+ <span class="value value-large" id="hdg">000&deg;</span>
48
+ </div>
49
+
50
+ <div class="data-box">
51
+ <div class="label-row"><span class="label">COG MEAN</span></div>
52
+ <span class="value value-large" id="cog">000&deg;</span>
53
+ </div>
54
+
55
+ <!-- TACK: Previsione Prua e COG sulle mure opposte (Layout a doppia colonna) -->
56
+ <div class="data-box">
57
+ <div class="label-row"><span class="label">TACK</span></div>
58
+ <div class="dual-value-container">
59
+ <div class="dual-value-col">
60
+ <span class="dual-label">HDG</span>
61
+ <span class="value dual-val" id="tack-hdg">---&deg;</span>
62
+ </div>
63
+ <div class="dual-value-col right-col">
64
+ <span class="dual-label">COG</span>
65
+ <span class="value dual-val" id="tack-cog">---&deg;</span>
66
+ </div>
67
+ </div>
68
+ </div>
69
+
70
+ </div>
71
+
72
+ <!-- ======================================================= -->
73
+ <!-- CENTRO: Strumento principale del vento e scarroccio -->
74
+ <!-- ======================================================= -->
75
+ <div class="center-panel">
76
+ <svg id="wind-gauge" viewBox="30 20 340 440" preserveAspectRatio="xMidYMid meet">
77
+ <defs>
78
+ <linearGradient id="axiom-grad" x1="0%" y1="0%" x2="0%" y2="100%">
79
+ <stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
80
+ <stop offset="100%" style="stop-color:#888888;stop-opacity:1" />
81
+ </linearGradient>
82
+ <linearGradient id="leeway-grad" x1="0%" y1="0%" x2="100%" y2="0%">
83
+ <stop offset="0%" style="stop-color:#ff0000;stop-opacity:1" />
84
+ <stop offset="25%" style="stop-color:#ff8800;stop-opacity:1" />
85
+ <stop offset="50%" style="stop-color:#00ff00;stop-opacity:1" />
86
+ <stop offset="75%" style="stop-color:#ff8800;stop-opacity:1" />
87
+ <stop offset="100%" style="stop-color:#ff0000;stop-opacity:1" />
88
+ </linearGradient>
89
+ <clipPath id="leeway-clip">
90
+ <rect id="leeway-mask-rect" x="125" y="0" width="0" height="12" rx="2" />
91
+ </clipPath>
92
+ </defs>
93
+
94
+ <!-- Sfondo Quadrante -->
95
+ <circle cx="200" cy="200" r="160" fill="#050505" />
96
+ <circle cx="200" cy="200" r="125" fill="#121212" />
97
+
98
+ <!-- Settori Colorati: Rosso (sx), Verde (dx), Arancio (poppa) -->
99
+ <path d="M 82.0 101.0 A 154 154 0 0 1 142.3 57.2" fill="none" stroke="#ff0000" stroke-width="12" opacity="1"/>
100
+ <path d="M 257.7 57.2 A 154 154 0 0 1 318.0 101.0" fill="none" stroke="#00ff00" stroke-width="12" opacity="1"/>
101
+ <path d="M 265.1 339.6 A 154 154 0 0 1 134.9 339.6" fill="none" stroke="#ff8800" stroke-width="12" opacity="1"/>
102
+
103
+ <!-- Tacche e Testi -->
104
+ <g id="ticks"></g>
105
+ <g id="tick-labels" fill="#bbb" text-anchor="middle" dominant-baseline="hanging" font-family="Arial" font-weight="bold">
106
+ <text font-size="16" transform="translate(200, 65)">0</text>
107
+ <text font-size="16" transform="translate(335, 200) rotate(90)">90</text>
108
+ <text font-size="16" transform="translate(65, 200) rotate(-90)">90</text>
109
+ <text font-size="16" transform="translate(200, 335) rotate(180)">180</text>
110
+ <text font-size="11" transform="translate(267.5, 83) rotate(30)">30</text>
111
+ <text font-size="11" transform="translate(317, 132.5) rotate(60)">60</text>
112
+ <text font-size="11" transform="translate(317, 267.5) rotate(120)">120</text>
113
+ <text font-size="11" transform="translate(267.5, 317) rotate(150)">150</text>
114
+ <text font-size="11" transform="translate(132.5, 83) rotate(-30)">30</text>
115
+ <text font-size="11" transform="translate(83, 132.5) rotate(-60)">60</text>
116
+ <text font-size="11" transform="translate(83, 267.5) rotate(-120)">120</text>
117
+ <text font-size="11" transform="translate(132.5, 317) rotate(-150)">150</text>
118
+ </g>
119
+
120
+ <!-- Puntatori Dinamici -->
121
+ <g id="track-pointer" transform="rotate(0, 200, 200)"><path d="M200,42 L194,58 L206,58 Z" fill="#007aff" stroke="#fff" stroke-width="0.5" /></g>
122
+ <path id="boat-icon" d="M200,150 Q165,185 170,250 Q165,190 200,173 Q235,190 230,250 Q235,185 200,150 Z" fill="url(#axiom-grad)" />
123
+ <g id="aws-display-group" transform="translate(200, 265)">
124
+ <text x="0" y="0" fill="#777" font-size="10" font-weight="bold" text-anchor="middle" text-transform="uppercase">Apparent Wind kts</text>
125
+ <text id="aws-val-svg" x="0" y="42" fill="#fff" font-size="52" font-weight="bold" text-anchor="middle">0.0</text>
126
+ </g>
127
+ <g id="awa-pointer" transform="rotate(0, 200, 200)" opacity="0.85">
128
+ <path d="M200,80 L211,95 L200,145 L189,95 Z" fill="#ff8c00" stroke="#000" stroke-width="1" />
129
+ <text x="200" y="102" fill="#000" font-size="11" font-weight="900" text-anchor="middle" font-family="Arial Black">A</text>
130
+ </g>
131
+ <g id="twa-pointer" transform="rotate(0, 200, 200)" opacity="0.85">
132
+ <path d="M200,90 L206,98 L200,125 L194,98 Z" fill="#ffff00" stroke="#000" stroke-width="0.8" />
133
+ <text x="200" y="104" fill="#000" font-size="8" font-weight="900" text-anchor="middle" font-family="Arial Black">T</text>
134
+ </g>
135
+
136
+ <!-- Barra del Leeway (Scarroccio) -->
137
+ <g transform="translate(75, 410)">
138
+ <text x="125" y="-12" id="leeway-val" fill="#aaa" font-size="11" text-anchor="middle" font-weight="bold">LEEWAY: 0.0&deg;</text>
139
+ <rect x="0" y="0" width="250" height="12" fill="#222" rx="3" />
140
+ <rect x="0" y="0" width="250" height="12" fill="url(#leeway-grad)" clip-path="url(#leeway-clip)" rx="3" />
141
+
142
+ <g stroke="#555" stroke-width="1">
143
+ <line x1="0" y1="-2" x2="0" y2="14" />
144
+ <line x1="31.25" y1="2" x2="31.25" y2="10" />
145
+ <line x1="62.5" y1="2" x2="62.5" y2="10" />
146
+ <line x1="93.75" y1="3" x2="93.75" y2="9" />
147
+ <line x1="125" y1="-2" x2="125" y2="14" />
148
+ <line x1="156.25" y1="3" x2="156.25" y2="9" />
149
+ <line x1="187.5" y1="2" x2="187.5" y2="10" />
150
+ <line x1="218.75" y1="2" x2="218.75" y2="10" />
151
+ <line x1="250" y1="-2" x2="250" y2="14" />
152
+ </g>
153
+ <g fill="#555" font-size="8" text-anchor="middle" font-weight="bold">
154
+ <text x="0" y="24">-20&deg;</text><text x="62.5" y="24">-10</text><text x="125" y="24">0&deg;</text><text x="187.5" y="24">10</text><text x="250" y="24">20&deg;</text>
155
+ </g>
156
+ </g>
157
+ </svg>
158
+ </div>
159
+
160
+ <!-- ======================================================= -->
161
+ <!-- COLONNA DESTRA: Profondità e Dati Vento -->
162
+ <!-- ======================================================= -->
163
+ <div class="side-panel right-panel">
164
+
165
+ <!-- Profondità con Grafico Dinamico -->
166
+ <div class="data-box">
167
+ <div class="label-row"><span class="unit">m</span><span class="label">DEPTH</span></div>
168
+ <span class="value" id="depth">--.-</span>
169
+ <div class="graph-wrapper">
170
+ <div class="scale-labels left" id="depth-scale"></div>
171
+ <svg class="sparkline" id="depth-graph" viewBox="0 0 200 40" preserveAspectRatio="none"></svg>
172
+ </div>
173
+ </div>
174
+
175
+ <!-- TWS con Grafico Dinamico -->
176
+ <div class="data-box">
177
+ <div class="label-row"><span class="unit">kts</span><span class="label">TWS</span></div>
178
+ <span class="value" id="tws">0.0</span>
179
+ <div class="graph-wrapper">
180
+ <div class="scale-labels left" id="tws-scale"></div>
181
+ <svg class="sparkline" id="tws-graph" viewBox="0 0 200 40" preserveAspectRatio="none"></svg>
182
+ </div>
183
+ </div>
184
+
185
+ <!-- Dati Vento Singoli (Font dinamico) -->
186
+ <div class="data-box">
187
+ <div class="label-row"><span class="label">TWA MEAN</span></div>
188
+ <span class="value value-large" id="twa-avg">---&deg;</span>
189
+ </div>
190
+
191
+ <div class="data-box">
192
+ <div class="label-row"><span class="label">AWA MEAN</span></div>
193
+ <span class="value value-large" id="awa-avg">---&deg;</span>
194
+ </div>
195
+
196
+ <!-- TWD Mean con Mini Bussola integrata -->
197
+ <div class="data-box">
198
+ <div class="label-row"><span class="label">TWD MEAN</span></div>
199
+ <div class="value-with-compass">
200
+ <svg class="mini-compass" viewBox="0 0 40 40">
201
+ <circle cx="20" cy="20" r="18" fill="none" stroke="#333" stroke-width="2"/>
202
+ <g stroke="#555" stroke-width="1">
203
+ <line x1="20" y1="2" x2="20" y2="6"/>
204
+ <line x1="38" y1="20" x2="34" y2="20"/>
205
+ <line x1="20" y1="38" x2="20" y2="34"/>
206
+ <line x1="2" y1="20" x2="6" y2="20"/>
207
+ </g>
208
+ <g id="twd-arrow" transform="rotate(0, 20, 20)">
209
+ <path d="M20,5 L17,12 L23,12 Z" fill="#ffff00" />
210
+ <line x1="20" y1="5" x2="20" y2="20" stroke="#ffff00" stroke-width="1" />
211
+ </g>
212
+ </svg>
213
+ <span class="value value-large" id="twd-avg">---&deg;</span>
214
+ </div>
215
+ </div>
216
+
217
+ </div>
218
+ </div>
219
+
220
+ <script src="app.js"></script>
221
+ </body>
222
+ </html>
package/manifest.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "short_name": "WindDash",
3
+ "name": "Signal K Wind Dashboard",
4
+ "icons": [
5
+ {
6
+ "src": "https://cdn-icons-png.flaticon.com/512/959/959711.png",
7
+ "type": "image/png",
8
+ "sizes": "512x512"
9
+ }
10
+ ],
11
+ "start_url": "./index.html",
12
+ "background_color": "#000000",
13
+ "display": "standalone",
14
+ "scope": "./",
15
+ "theme_color": "#000000",
16
+ "orientation": "landscape"
17
+ }
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@sailingrotevista/rotevista-dash",
3
+ "version": "1.0.0",
4
+ "description": "Dashboard pubblica per Bavaria 39 Rotevista",
5
+ "main": "index.html",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "keywords": [
10
+ "signalk-webapp"
11
+ ],
12
+ "author": "sailingrotevista",
13
+ "license": "MIT",
14
+ "signalk": {
15
+ "displayName": "Rotevista Dash"
16
+ }
17
+ }
package/style.css ADDED
@@ -0,0 +1,269 @@
1
+ /* ==========================================================================
2
+ 1. BASE E STRUTTURA GENERALE
3
+ ========================================================================== */
4
+ body {
5
+ background-color: #000;
6
+ color: #fff;
7
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
8
+ margin: 0;
9
+ padding: 0;
10
+ height: 100vh;
11
+ width: 100vw;
12
+ display: flex;
13
+ overflow: hidden;
14
+ }
15
+
16
+ .main-container {
17
+ display: flex;
18
+ width: 100%;
19
+ height: 100%;
20
+ padding: 5px;
21
+ box-sizing: border-box;
22
+ align-items: stretch; /* Estende le colonne per tutta l'altezza */
23
+ }
24
+
25
+ /* ==========================================================================
26
+ 2. PANNELLI LATERALI E DATA-BOX (Riquadri)
27
+ ========================================================================== */
28
+ .side-panel {
29
+ flex: 1;
30
+ display: flex;
31
+ flex-direction: column;
32
+ justify-content: flex-start;
33
+ background: rgba(255, 255, 255, 0.02);
34
+ border-radius: 10px;
35
+ z-index: 10;
36
+ }
37
+
38
+ .right-panel {
39
+ align-items: flex-end;
40
+ text-align: right;
41
+ }
42
+
43
+ .data-box {
44
+ position: relative; /* Necessario per posizionare gli elementi assoluti interni */
45
+ border-bottom: 1px solid #222;
46
+ padding: 8px 8px;
47
+ display: flex;
48
+ flex-direction: column;
49
+ min-height: 82px;
50
+ width: 100%;
51
+ box-sizing: border-box;
52
+ flex: 1; /* Distribuisce lo spazio equamente in altezza */
53
+ }
54
+
55
+ /* ==========================================================================
56
+ 3. TIPOGRAFIA ED ETICHETTE
57
+ ========================================================================== */
58
+ .label-row {
59
+ display: flex;
60
+ justify-content: space-between;
61
+ align-items: baseline;
62
+ margin-bottom: 2px;
63
+ width: 100%;
64
+ }
65
+
66
+ .label {
67
+ color: #666;
68
+ font-size: 0.6rem;
69
+ font-weight: bold;
70
+ text-transform: uppercase;
71
+ }
72
+
73
+ .unit {
74
+ color: #888;
75
+ font-size: 0.55rem;
76
+ font-weight: bold;
77
+ }
78
+
79
+ /* Valore Numerico Base (usato per i riquadri con grafici) */
80
+ .value {
81
+ color: #fff;
82
+ font-size: 2.4rem;
83
+ font-weight: 600;
84
+ line-height: 0.9;
85
+ letter-spacing: -1px;
86
+ transition: color 0.3s ease;
87
+ padding-bottom: 5px;
88
+ }
89
+
90
+ /* Valore Numerico Dinamico (usato per i riquadri SENZA grafici) */
91
+ .value-large {
92
+ margin-top: auto; /* Spinge il numero verso il basso */
93
+ /*
94
+ clamp(min, preferito, max)
95
+ Minimo: 2.4rem (come i grafici)
96
+ Preferito: 7.5vh (scala molto in base all'altezza schermo)
97
+ Massimo: 4.5rem (font molto grande ma sicuro per non sbordare)
98
+ */
99
+ font-size: clamp(2.4rem, 7.5vh, 4.5rem);
100
+ line-height: 0.85; /* Evita che il font grande sposti il layout */
101
+ }
102
+
103
+ /* Etichetta periodo media (es. per future implementazioni) */
104
+ .period-label {
105
+ position: absolute;
106
+ bottom: 4px;
107
+ left: 8px;
108
+ font-size: 0.5rem;
109
+ color: #444;
110
+ font-weight: bold;
111
+ text-transform: uppercase;
112
+ }
113
+ .right-panel .period-label { left: auto; right: 8px; }
114
+
115
+ /* ==========================================================================
116
+ 4. LAYOUT SPECIALI (TACK e TWD COMPASS)
117
+ ========================================================================== */
118
+ /* Layout a doppia colonna per il riquadro TACK */
119
+ .dual-value-container {
120
+ display: flex;
121
+ justify-content: space-between;
122
+ align-items: flex-end;
123
+ width: 100%;
124
+ margin-top: auto;
125
+ padding-bottom: 5px;
126
+ }
127
+
128
+ .dual-value-col {
129
+ display: flex;
130
+ flex-direction: column;
131
+ justify-content: space-between; /* Spinge l'etichetta su e il numero giù */
132
+ width: 48%; /* Lascia un piccolo margine al centro */
133
+ height: 100%; /* Prende tutta l'altezza disponibile nel contenitore */
134
+ }
135
+
136
+ .dual-value-col.right-col {
137
+ align-items: flex-end;
138
+ text-align: right;
139
+ }
140
+
141
+ .dual-label {
142
+ color: #666;
143
+ font-size: 0.55rem;
144
+ font-weight: bold;
145
+ text-transform: uppercase;
146
+ margin-bottom: auto; /* Mantiene l'etichetta schiacciata verso l'alto */
147
+ }
148
+
149
+ .value.dual-val {
150
+ font-size: 1.8rem; /* Font leggermente più piccolo per farne stare due */
151
+ padding-bottom: 0;
152
+ }
153
+
154
+ /* Layout Bussolina TWD */
155
+ .value-with-compass {
156
+ display: flex;
157
+ justify-content: space-between;
158
+ align-items: center;
159
+ width: 100%;
160
+ margin-top: auto;
161
+ }
162
+
163
+ .mini-compass {
164
+ width: 38px;
165
+ height: 38px;
166
+ background: rgba(0,0,0,0.2);
167
+ border-radius: 50%;
168
+ flex-shrink: 0;
169
+ }
170
+
171
+ .value-with-compass .value-large {
172
+ margin-top: 0; /* Annulla il margin-top perché gestito dal padre flex */
173
+ }
174
+
175
+ /* ==========================================================================
176
+ 5. GRAFICI STORICI (SPARKLINES)
177
+ ========================================================================== */
178
+ .graph-wrapper {
179
+ position: relative;
180
+ width: 100%;
181
+ flex-grow: 1; /* Il contenitore si espande in altezza */
182
+ min-height: 20px;
183
+ margin-top: 4px;
184
+ }
185
+
186
+ .sparkline {
187
+ width: 100%;
188
+ height: 100%;
189
+ background: rgba(255, 255, 255, 0.03);
190
+ border-radius: 4px;
191
+ }
192
+
193
+ .scale-labels {
194
+ position: absolute;
195
+ top: 0;
196
+ height: 100%;
197
+ display: flex;
198
+ flex-direction: column;
199
+ justify-content: space-between;
200
+ font-size: 8px;
201
+ color: #555;
202
+ padding: 1px 5px;
203
+ font-weight: bold;
204
+ }
205
+ .scale-labels.right { right: 0; text-align: right; }
206
+ .scale-labels.left { left: 0; text-align: left; }
207
+
208
+ /* Colori specifici delle linee dei grafici */
209
+ #stw-graph { stroke: #2ecc71; fill: rgba(46, 204, 113, 0.15); }
210
+ #sog-graph { stroke: #f39c12; fill: rgba(243, 156, 18, 0.15); }
211
+ #depth-graph { stroke: #3498db; fill: rgba(52, 152, 219, 0.15); }
212
+ #tws-graph { stroke: #f1c40f; fill: rgba(241, 196, 15, 0.15); }
213
+
214
+ /* ==========================================================================
215
+ 6. PANNELLO CENTRALE (STRUMENTO VENTO SVG)
216
+ ========================================================================== */
217
+ .center-panel {
218
+ flex: 3.5;
219
+ display: flex;
220
+ flex-direction: column;
221
+ justify-content: flex-start;
222
+ align-items: center;
223
+ height: 100%;
224
+ padding: 0;
225
+ margin: 0;
226
+ }
227
+
228
+ #wind-gauge {
229
+ width: 100%;
230
+ height: auto;
231
+ max-height: 95vh;
232
+ object-fit: contain;
233
+ }
234
+
235
+ /* ==========================================================================
236
+ 7. STATI, ALLARMI E ANIMAZIONI
237
+ ========================================================================== */
238
+ /* Stato Connessione */
239
+ #status {
240
+ position: absolute;
241
+ top: 5px;
242
+ right: 15px;
243
+ font-size: 0.5rem;
244
+ text-transform: uppercase;
245
+ z-index: 1000;
246
+ }
247
+ .online { color: #2ecc71; opacity: 0.5; }
248
+ .offline { color: #e74c3c; font-weight: bold; }
249
+
250
+ /* Animazione fluidità lancette SVG */
251
+ #awa-pointer, #twa-pointer, #track-pointer, #twd-arrow {
252
+ transition: all 0.6s cubic-bezier(0.1, 0.7, 0.1, 1);
253
+ }
254
+ #leeway-mask-rect { transition: none; } /* Deve essere istantaneo */
255
+
256
+ /* Allarmi Profondità */
257
+ .alarm-warning { color: #f1c40f !important; }
258
+ .alarm-danger { color: #e74c3c !important; font-weight: 900; animation: blink-unstable 1s infinite; }
259
+
260
+ /* Lampeggio dati instabili / Ricalcolo buffer */
261
+ @keyframes blink-unstable {
262
+ 0% { opacity: 1; }
263
+ 50% { opacity: 0.3; }
264
+ 100% { opacity: 1; }
265
+ }
266
+ .unstable-data {
267
+ animation: blink-unstable 1.5s infinite ease-in-out;
268
+ color: #f39c12 !important; /* Arancione per evidenziare il ricalcolo */
269
+ }