@sailingrotevista/rotevista-dash 2.0.18 → 2.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/app.js +78 -66
  2. package/index.html +29 -9
  3. package/package.json +1 -1
  4. package/style.css +116 -107
package/app.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // ==========================================================================
2
- // 1. CONFIGURAZIONE E DEFAULT (Sincronizzato con il Plugin SignalK)
2
+ // 1. CONFIGURAZIONE E DEFAULT
3
3
  // ==========================================================================
4
4
  let CONFIG = {
5
5
  alarms: { depthDanger: 2.5, depthWarning: 5.0 },
@@ -22,7 +22,7 @@ const SIM_SAMPLE_INTERVAL = 1000;
22
22
  // 2. STATO GLOBALE E RIFERIMENTI UI
23
23
  // ==========================================================================
24
24
  let simulationMode = false;
25
- let displayModeSog = 'SOG'; // 'SOG' o 'VMG'
25
+ let displayModeSog = 'SOG';
26
26
  let socket, renderInterval, simInterval;
27
27
  let lastAvgUIUpdate = 0, audioCtx = null, lastAlarmTime = 0;
28
28
  let curAwaRot = 0, curTwaRot = 0, curTrackRot = 0, curTwdRoseRot = 0;
@@ -66,7 +66,7 @@ const ui = {
66
66
  };
67
67
 
68
68
  // ==========================================================================
69
- // 3. UTILITIES DI SISTEMA (MATEMATICA E BUFFER)
69
+ // 3. UTILITIES (MATEMATICA, BUFFER, AUDIO)
70
70
  // ==========================================================================
71
71
  function radToDeg(rad) { return rad * (180 / Math.PI); }
72
72
  function degToRad(deg) { return deg * (Math.PI / 180); }
@@ -74,17 +74,11 @@ function msToKts(ms) { return ms * 1.94384; }
74
74
  function ktsToMs(kts) { return kts / 1.94384; }
75
75
  function getShortestRotation(curr, target) { let diff = (target - curr) % 360; if (diff > 180) diff -= 360; else if (diff < -180) diff += 360; return curr + diff; }
76
76
 
77
- /**
78
- * Gestione sicura dei buffer per prevenire memory leak (O(1) complexity).
79
- */
80
77
  function safePush(buffer, val, time, maxLen = 200) {
81
78
  buffer.push({ val: val, time: time });
82
79
  if (buffer.length > maxLen) { buffer.shift(); }
83
80
  }
84
81
 
85
- /**
86
- * Calcola medie circolari restituendo Radianti.
87
- */
88
82
  function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false) {
89
83
  const now = Date.now();
90
84
  const validData = bufferArray.filter(item => (now - item.time) <= windowMs);
@@ -97,14 +91,35 @@ function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false) {
97
91
  return { val: signed ? avgRad : (avgRad + 2 * Math.PI) % (2 * Math.PI), stable: isStable };
98
92
  }
99
93
 
94
+ function playBingBing() {
95
+ if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
96
+ const n = Date.now(); if (n - lastAlarmTime < 3000) return; lastAlarmTime = n;
97
+ function b(f, s) { const o = audioCtx.createOscillator(); const g = audioCtx.createGain(); o.connect(g); g.connect(audioCtx.destination); o.frequency.value = f; g.gain.setValueAtTime(0.1, s); g.gain.exponentialRampToValueAtTime(0.01, s + 0.4); o.start(s); o.stop(s + 0.5); }
98
+ b(880, audioCtx.currentTime); b(880, audioCtx.currentTime + 0.6);
99
+ }
100
+
101
+ function playGybeAlarm() {
102
+ if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
103
+ const n = Date.now(); if (n - lastAlarmTime < 2000) return; lastAlarmTime = n;
104
+ function note(f, s, d) { const o = audioCtx.createOscillator(); const g = audioCtx.createGain(); o.connect(g); g.connect(audioCtx.destination); o.type = 'square'; o.frequency.value = f; g.gain.setValueAtTime(0.05, s); g.gain.exponentialRampToValueAtTime(0.001, s + d); o.start(s); o.stop(s + d); }
105
+ for (let i = 0; i < 4; i++) { note(1800, audioCtx.currentTime + (i * 0.15), 0.1); note(1200, audioCtx.currentTime + (i * 0.15) + 0.07, 0.1); }
106
+ }
107
+
108
+ function checkDepthAlarm(m) {
109
+ ui.depth.classList.remove('alarm-warning', 'alarm-danger');
110
+ if (m < CONFIG.alarms.depthDanger) { ui.depth.classList.add('alarm-danger'); playBingBing(); }
111
+ else if (m < CONFIG.alarms.depthWarning) ui.depth.classList.add('alarm-warning');
112
+ }
113
+
114
+ function updateLeewayDisplay(deg) {
115
+ const c = 125, px = 125/20; let w = Math.min(Math.abs(deg)*px, 125);
116
+ ui.leewayMask.setAttribute('x', deg >= 0 ? c : c - w); ui.leewayMask.setAttribute('width', w);
117
+ ui.leewayVal.textContent = `LEEWAY: ${deg.toFixed(1)}°`;
118
+ }
119
+
100
120
  // ==========================================================================
101
- // 4. LOGICA FISICA E CALCOLI
121
+ // 4. MOTORE DI CALCOLO VENTO E DATA ROUTING
102
122
  // ==========================================================================
103
-
104
- /**
105
- * Calcola il Vento Reale partendo dagli Apparenti.
106
- * Logica Acqua (Vele) vs Terra (Meteo).
107
- */
108
123
  function computeTrueWind() {
109
124
  const aws = store.raw["environment.wind.speedApparent"];
110
125
  let awa = store.raw["environment.wind.angleApparent"];
@@ -114,21 +129,27 @@ function computeTrueWind() {
114
129
  if (aws === undefined || awa === undefined) return;
115
130
  if (awa > Math.PI) awa -= 2 * Math.PI;
116
131
 
117
- // Vento Reale su ACQUA (TWA/TWS)
118
132
  const tw_water_x = aws * Math.cos(awa) - stw, tw_water_y = aws * Math.sin(awa);
119
- const tws_water = Math.sqrt(tw_water_x * tw_water_x + tw_water_y * tw_water_y), twa_water = Math.atan2(tw_water_y, tw_water_x);
133
+ const tws_water = Math.sqrt(tw_water_x * tw_water_x + tw_water_y * tw_water_y);
120
134
 
121
- // Vento Reale su TERRA (TWD)
122
135
  const drift_angle = (cog - hdg + Math.PI * 3) % (2 * Math.PI) - Math.PI;
123
136
  const sog_vec_x = sog * Math.cos(drift_angle), sog_vec_y = sog * Math.sin(drift_angle);
124
137
  const tw_ground_x = aws * Math.cos(awa) - sog_vec_x, tw_ground_y = aws * Math.sin(awa) - sog_vec_y;
125
- let twd_ground = (hdg + Math.atan2(tw_ground_y, tw_ground_x) + 2 * Math.PI) % (2 * Math.PI);
126
-
138
+ const tws_ground = Math.sqrt(tw_ground_x * tw_ground_x + tw_ground_y * tw_ground_y);
139
+
127
140
  const now = Date.now();
128
- store.raw["environment.wind.speedTrue"] = tws_water; store.raw["environment.wind.angleTrueWater"] = twa_water; store.raw["environment.wind.directionTrue"] = twd_ground;
141
+ store.raw["environment.wind.speedTrue"] = tws_water;
129
142
 
130
- safePush(store.smoothBuf.twa, twa_water, now); safePush(store.longBuf.twa, twa_water, now);
131
- safePush(store.smoothBuf.twd, twd_ground, now); safePush(store.longBuf.twd, twd_ground, now);
143
+ if (tws_water > 0.05) {
144
+ const twa_water = Math.atan2(tw_water_y, tw_water_x);
145
+ store.raw["environment.wind.angleTrueWater"] = twa_water;
146
+ safePush(store.smoothBuf.twa, twa_water, now); safePush(store.longBuf.twa, twa_water, now);
147
+ }
148
+ if (tws_ground > 0.05) {
149
+ let twd_ground = (hdg + Math.atan2(tw_ground_y, tw_ground_x) + 2 * Math.PI) % (2 * Math.PI);
150
+ store.raw["environment.wind.directionTrue"] = twd_ground;
151
+ safePush(store.smoothBuf.twd, twd_ground, now); safePush(store.longBuf.twd, twd_ground, now);
152
+ }
132
153
  }
133
154
 
134
155
  function processIncomingData(path, val) {
@@ -136,7 +157,6 @@ function processIncomingData(path, val) {
136
157
  if (path === "navigation.position") store.raw["navigation.position"] = val;
137
158
  if (path === "environment.wind.angleApparent") { safePush(store.smoothBuf.awa, val, now); safePush(store.longBuf.awa, val, now); }
138
159
 
139
- // Dirty flag + Debounce 100ms
140
160
  const twPaths = ["environment.wind.speedApparent", "environment.wind.angleApparent", "navigation.speedThroughWater", "navigation.speedOverGround", "navigation.headingTrue", "navigation.courseOverGroundTrue"];
141
161
  if (twPaths.includes(path)) twDirty = true;
142
162
  if (twDirty && (now - lastTWCompute > 100)) { computeTrueWind(); lastTWCompute = now; twDirty = false; }
@@ -146,14 +166,7 @@ function processIncomingData(path, val) {
146
166
  }
147
167
 
148
168
  // ==========================================================================
149
- // 5. AUDIO E ALLARMI
150
- // ==========================================================================
151
- function playBingBing() { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const n = Date.now(); if (n - lastAlarmTime < 3000) return; lastAlarmTime = n; function b(f, s) { const o = audioCtx.createOscillator(); const g = audioCtx.createGain(); o.connect(g); g.connect(audioCtx.destination); o.frequency.value = f; g.gain.setValueAtTime(0.1, s); g.gain.exponentialRampToValueAtTime(0.01, s + 0.4); o.start(s); o.stop(s + 0.5); } b(880, audioCtx.currentTime); b(880, audioCtx.currentTime + 0.6); }
152
- function playGybeAlarm() { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const n = Date.now(); if (n - lastAlarmTime < 2000) return; lastAlarmTime = n; function note(f, s, d) { const o = audioCtx.createOscillator(); const g = audioCtx.createGain(); o.connect(g); g.connect(audioCtx.destination); o.type = 'square'; o.frequency.value = f; g.gain.setValueAtTime(0.05, s); g.gain.exponentialRampToValueAtTime(0.001, s + d); o.start(s); o.stop(s + d); } for (let i = 0; i < 4; i++) { note(1800, audioCtx.currentTime + (i * 0.15), 0.1); note(1200, audioCtx.currentTime + (i * 0.15) + 0.07, 0.1); } }
153
- function checkDepthAlarm(m) { ui.depth.classList.remove('alarm-warning', 'alarm-danger'); if (m < CONFIG.alarms.depthDanger) { ui.depth.classList.add('alarm-danger'); playBingBing(); } else if (m < CONFIG.alarms.depthWarning) ui.depth.classList.add('alarm-warning'); }
154
-
155
- // ==========================================================================
156
- // 6. TREND VENTO E TATTICA
169
+ // 5. TREND VENTO E SICUREZZA
157
170
  // ==========================================================================
158
171
  function updateWindTrend() {
159
172
  const now = Date.now();
@@ -167,20 +180,18 @@ function updateWindTrend() {
167
180
  if (lastShortAvgVal === null) { lastShortAvgVal = shortAvgDeg; lastInstantTwa = instantTwaDeg; return; }
168
181
  const dt = (now - lastTrendTime) / 1000; lastTrendTime = now;
169
182
 
170
- // Gybe Safety
171
183
  const gybeDetected = (Math.abs(instantTwaDeg) > 155 && Math.sign(instantTwaDeg) !== Math.sign(lastInstantTwa));
172
184
  lastInstantTwa = instantTwaDeg;
173
- if (gybeDetected && isNavigating && (now - lastGybeAlarmTime > 5000)) { lastGybeAlarmTime = now; playGybeAlarm(); }
174
-
185
+
175
186
  const compassDots = { cw: document.getElementById('trend-dot-cw'), ccw: document.getElementById('trend-dot-ccw') };
176
187
  const gaugeDots = { cw: document.getElementById('trend-gauge-cw'), ccw: document.getElementById('trend-gauge-ccw') };
177
188
 
189
+ if (gybeDetected && isNavigating && (now - lastGybeAlarmTime > 5000)) { lastGybeAlarmTime = now; playGybeAlarm(); }
178
190
  if (now - lastGybeAlarmTime < 4000 && isNavigating) {
179
191
  [compassDots.cw, compassDots.ccw, gaugeDots.cw, gaugeDots.ccw].forEach(el => { if (el) { el.classList.add('is-gybing'); el.classList.remove('is-trending'); el.setAttribute('fill', '#ff0000'); }});
180
192
  return;
181
193
  }
182
194
 
183
- // Trend calculation con Clamping e Alpha adattivo
184
195
  let diff = (shortAvgDeg - lastShortAvgVal + 540) % 360 - 180; lastShortAvgVal = shortAvgDeg;
185
196
  if (dt > 0) {
186
197
  let rate = Math.max(-10, Math.min(10, diff / dt));
@@ -211,10 +222,8 @@ function updateWindTrend() {
211
222
  }
212
223
 
213
224
  // ==========================================================================
214
- // 7. RENDERING ENGINE (TIERED RENDERING)
225
+ // 6. RENDERING ENGINE (TIERED)
215
226
  // ==========================================================================
216
- function updateLeewayDisplay(deg) { const c = 125, px = 125/20; let w = Math.min(Math.abs(deg)*px, 125); ui.leewayMask.setAttribute('x', deg >= 0 ? c : c - w); ui.leewayMask.setAttribute('width', w); ui.leewayVal.textContent = `LEEWAY: ${deg.toFixed(1)}°`; }
217
-
218
227
  function refreshGraph(t) {
219
228
  const type = (t === 'vmg') ? 'sog' : t;
220
229
  const data = store.histories[t]; if (!data || data.length < 2) return;
@@ -229,13 +238,13 @@ function startDisplayLoop() {
229
238
  renderInterval = setInterval(() => {
230
239
  const now = Date.now(); tick++;
231
240
 
232
- // --- LIVE TIER (1s) ---
233
- const pathsToWatch = { "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 };
234
- for (let p in pathsToWatch) { if (!store.timestamps[p] || (now - store.timestamps[p] > TIMEOUT_MS)) { if (pathsToWatch[p] === ui.awsSvg) ui.awsSvg.textContent = "---"; else pathsToWatch[p].innerText = "---"; delete store.raw[p]; } }
235
-
236
241
  const stwKts = msToKts(store.raw["navigation.speedThroughWater"] || 0), sogKts = msToKts(store.raw["navigation.speedOverGround"] || 0);
237
242
  isNavigating = stwKts > CONFIG.averages.minSpeed || sogKts > CONFIG.averages.minSpeed;
238
243
 
244
+ // LIVE TIER (1s)
245
+ const pathsToWatch = { "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 };
246
+ for (let p in pathsToWatch) { if (!store.timestamps[p] || (now - store.timestamps[p] > TIMEOUT_MS)) { if (pathsToWatch[p] === ui.awsSvg) ui.awsSvg.textContent = "---"; else pathsToWatch[p].innerText = "---"; delete store.raw[p]; } }
247
+
239
248
  if (store.raw["navigation.speedThroughWater"] !== undefined) { ui.stw.innerText = stwKts.toFixed(1); manageHistory('stw', stwKts); }
240
249
  if (store.raw["navigation.speedOverGround"] !== undefined) {
241
250
  const twaRad = store.raw["environment.wind.angleTrueWater"], vmgKts = (twaRad !== undefined) ? Math.abs(stwKts * Math.cos(twaRad)) : 0;
@@ -244,36 +253,32 @@ function startDisplayLoop() {
244
253
  else { ui.sog.innerText = sogKts.toFixed(1); ui.sog.style.color = (sogKts-stwKts > 0.3) ? "#2ecc71" : (sogKts-stwKts < -0.3 ? "#e74c3c" : "#fff"); document.getElementById('sog-vmg-label').textContent = 'SOG'; }
245
254
  }
246
255
  if (store.raw["environment.depth.belowTransducer"] !== undefined) { const d = store.raw["environment.depth.belowTransducer"]; ui.depth.innerText = d.toFixed(1); checkDepthAlarm(d); manageHistory('depth', d); }
247
- if (store.raw["environment.wind.speedTrue"] !== undefined) { const twsKts = msToKts(store.raw["environment.wind.speedTrue"]); ui.tws.innerText = twsKts.toFixed(1); ui.tws.style.color = (twsKts >= CONFIG.graphs.reef2) ? "#e74c3c" : (twsKts >= CONFIG.graphs.reef1 ? "#e67e22" : "#fff"); manageHistory('tws', twsKts); }
256
+ if (store.raw["environment.wind.speedTrue"] !== undefined) { ui.tws.innerText = msToKts(store.raw["environment.wind.speedTrue"]).toFixed(1); ui.tws.style.color = (msToKts(store.raw["environment.wind.speedTrue"]) >= CONFIG.graphs.reef2) ? "#e74c3c" : (msToKts(store.raw["environment.wind.speedTrue"]) >= CONFIG.graphs.reef1 ? "#e67e22" : "#fff"); manageHistory('tws', msToKts(store.raw["environment.wind.speedTrue"])); }
248
257
  if (store.raw["environment.wind.speedApparent"] !== undefined) ui.awsSvg.textContent = msToKts(store.raw["environment.wind.speedApparent"]).toFixed(1);
249
258
 
250
- const smAwa = getCircularAverageFromBuffer(store.smoothBuf.awa, CONFIG.averages.smoothWindow, true);
259
+ const smAwa = getCircularAverageFromBuffer(store.smoothBuf.awa, 2000, true);
251
260
  if (smAwa) { curAwaRot = getShortestRotation(curAwaRot, radToDeg(smAwa.val)); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
252
- const smTwa = getCircularAverageFromBuffer(store.smoothBuf.twa, CONFIG.averages.smoothWindow, true);
261
+ const smTwa = getCircularAverageFromBuffer(store.smoothBuf.twa, 2000, true);
253
262
  if (smTwa) { curTwaRot = getShortestRotation(curTwaRot, radToDeg(smTwa.val)); ui.twa.setAttribute('transform', `rotate(${curTwaRot}, 200, 200)`); }
254
263
 
255
264
  if (store.raw["navigation.courseOverGroundTrue"] !== undefined && store.raw["navigation.headingTrue"] !== undefined) {
256
265
  let driftDeg = radToDeg((store.raw["navigation.courseOverGroundTrue"] - store.raw["navigation.headingTrue"] + Math.PI * 3) % (Math.PI * 2) - Math.PI);
257
266
  if (sogKts < CONFIG.averages.minSpeed) smoothedLeeway = 0; else smoothedLeeway = (smoothedLeeway * 0.9) + (driftDeg * 0.1);
258
267
  curTrackRot = getShortestRotation(curTrackRot, smoothedLeeway); ui.track.setAttribute('transform', `rotate(${curTrackRot}, 200, 200)`);
259
- const isContaminated = Math.abs(sogKts - stwKts) > 0.5 && Math.abs(smoothedLeeway) > 7; ui.leewayVal.style.color = isContaminated ? "#f39c12" : "#fff";
268
+ ui.leewayVal.style.color = (Math.abs(sogKts - stwKts) > 0.5 && Math.abs(smoothedLeeway) > 7) ? "#f39c12" : "#fff";
260
269
  updateLeewayDisplay(Math.max(-20, Math.min(20, smoothedLeeway)));
261
270
  }
262
-
263
- updateWindTrend(); // Fast update
264
271
 
265
- // --- HEAVY TIER (2s) ---
266
- if (tick % 2 === 0) {
267
- refreshGraph('stw'); refreshGraph(displayModeSog === 'VMG' ? 'vmg' : 'sog'); refreshGraph('depth'); refreshGraph('tws');
268
- }
272
+ updateWindTrend();
273
+
274
+ // HEAVY TIER (2s)
275
+ if (tick % 2 === 0) { refreshGraph('stw'); refreshGraph(displayModeSog === 'VMG' ? 'vmg' : 'sog'); refreshGraph('depth'); refreshGraph('tws'); }
269
276
 
270
- // --- SLOW TIER (3s) ---
277
+ // SLOW TIER (3s)
271
278
  if (tick % 3 === 0) {
272
- let hObj = getCircularAverageFromBuffer(store.longBuf.hdg, CONFIG.averages.longWindow, false),
273
- cObj = getCircularAverageFromBuffer(store.longBuf.cog, CONFIG.averages.longWindow, false),
274
- awObj = getCircularAverageFromBuffer(store.longBuf.awa, CONFIG.averages.longWindow, true),
275
- twObj = getCircularAverageFromBuffer(store.longBuf.twa, CONFIG.averages.longWindow, true),
276
- twdObj = getCircularAverageFromBuffer(store.longBuf.twd, CONFIG.averages.longWindow, false);
279
+ let hObj = getCircularAverageFromBuffer(store.longBuf.hdg, 60000, false), cObj = getCircularAverageFromBuffer(store.longBuf.cog, 60000, false),
280
+ awObj = getCircularAverageFromBuffer(store.longBuf.awa, 60000, true), twObj = getCircularAverageFromBuffer(store.longBuf.twa, 60000, true),
281
+ twdObj = getCircularAverageFromBuffer(store.longBuf.twd, 60000, false);
277
282
 
278
283
  const upUI = (el, obj, isCompass = false) => {
279
284
  if (!obj || obj.val === null) { el.innerHTML = "---&deg;"; el.classList.remove('unstable-data'); }
@@ -283,11 +288,12 @@ function startDisplayLoop() {
283
288
  }
284
289
  };
285
290
  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);
286
- if (hObj && twObj && hObj.val !== null) {
287
- const tackHdgDeg = radToDeg((hObj.val - (twObj.val * 2) + Math.PI * 2) % (Math.PI * 2));
291
+
292
+ if (hObj && twObj) {
293
+ const tackHdgDeg = radToDeg((hObj.val - twObj.val * 2 + Math.PI * 2) % (Math.PI * 2));
288
294
  ui.tackHdg.innerHTML = `${Math.round((tackHdgDeg + 360) % 360).toString().padStart(3, '0')}&deg;`;
289
295
  if (cObj) {
290
- const tackCogDeg = radToDeg((cObj.val - (twObj.val * 2) + Math.PI * 2) % (Math.PI * 2));
296
+ const tackCogDeg = radToDeg((cObj.val - twObj.val * 2 + Math.PI * 2) % (Math.PI * 2));
291
297
  ui.tackCog.innerHTML = `${Math.round((tackCogDeg + 360) % 360).toString().padStart(3, '0')}&deg;`;
292
298
  }
293
299
  }
@@ -296,13 +302,14 @@ function startDisplayLoop() {
296
302
  curWindCompassRot = getShortestRotation(curWindCompassRot, radToDeg(smTwd.val)); ui.twdArrow.setAttribute('transform', `rotate(${curWindCompassRot}, 20, 20)`);
297
303
  curBoatCompassRot = getShortestRotation(curBoatCompassRot, radToDeg(smHdg.val)); ui.twdBoat.setAttribute('transform', `rotate(${curBoatCompassRot}, 20, 20)`);
298
304
  }
305
+ lastAvgUIUpdate = now;
299
306
  }
300
307
  if (tick % 60 === 0) tick = 0;
301
308
  }, RENDER_INTERVAL_MS);
302
309
  }
303
310
 
304
311
  // ==========================================================================
305
- // 8. INTERAZIONI E RETE
312
+ // 7. CONFIGURAZIONE E GRAFICI UTILS
306
313
  // ==========================================================================
307
314
  async function fetchServerConfig() {
308
315
  if (!window.location.protocol.includes("http")) return;
@@ -314,7 +321,7 @@ async function fetchServerConfig() {
314
321
  if (response.ok) {
315
322
  const data = await response.json();
316
323
  const actual = data.configuration || data;
317
- if (actual && typeof actual === 'object') {
324
+ if (actual) {
318
325
  const parseNumbers = (obj) => { for (let k in obj) { if (typeof obj[k] === 'object') parseNumbers(obj[k]); else if (!isNaN(obj[k]) && obj[k] !== "") obj[k] = parseFloat(obj[k]); } };
319
326
  parseNumbers(actual);
320
327
  if (actual.alarms) CONFIG.alarms = { ...CONFIG.alarms, ...actual.alarms };
@@ -359,6 +366,9 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
359
366
  svg.innerHTML = isTws ? `${grids}<path d="${pD} L ${((d.length-1)/(CONFIG.graphs.samples-1))*w} ${h} L 0 ${h} Z" fill="rgba(255,255,255,0.08)" stroke="none" />${cS}` : `${grids}<path d="${pD} L ${((d.length-1)/(CONFIG.graphs.samples-1))*w} ${h} L 0 ${h} Z" fill="${colorKey}22" stroke="none" /><path d="${pD}" class="${isHercules?'line-hercules':''}" fill="none" stroke="${colorKey}" />`;
360
367
  }
361
368
 
369
+ // ==========================================================================
370
+ // 8. INTERAZIONI E RETE
371
+ // ==========================================================================
362
372
  function toggleFocusMode(type, element) {
363
373
  const container = document.querySelector('.main-container'); const parentPanel = element.closest('.side-panel'); const isLeft = parentPanel.classList.contains('left-panel');
364
374
  isFocusActive = !isFocusActive;
@@ -401,7 +411,7 @@ ui.depth.closest('.data-box').addEventListener('click', (function() { let dC = 0
401
411
 
402
412
  function startDynamicSimulation() {
403
413
  ui.status.innerText = "SIM ATTIVO";
404
- let sim = { hdg: 45, tws: 12, twd: 45, depth: 12, stw: 5, leeway: 0, currentSpeed: 1.5, currentDir: 90, startTime: Date.now() };
414
+ let sim = { hdg: 45, tws: 12, twd: 90, depth: 12, stw: 5, leeway: 0, currentSpeed: 1.5, currentDir: 90, startTime: Date.now() };
405
415
  simInterval = setInterval(() => {
406
416
  const elapsed = (Date.now() - sim.startTime) / 1000;
407
417
  if (elapsed > 120 && elapsed < 121) sim.twd = Math.random() * 360;
@@ -411,9 +421,11 @@ function startDynamicSimulation() {
411
421
  let twaRel = (sim.twd - sim.hdg + 360) % 360; if (twaRel > 180) twaRel -= 360;
412
422
  let targetStw = 3 + (4 * Math.sin((Math.abs(twaRel) - 45) * Math.PI / 125)); sim.stw += (Math.max(3, Math.min(8, targetStw)) - sim.stw) * 0.05;
413
423
  const rawLeeway = Math.sin(degToRad(twaRel)) * 4; sim.leeway += (rawLeeway - sim.leeway) * 0.05;
424
+
414
425
  const bX = sim.stw * Math.sin(degToRad(sim.hdg + sim.leeway)), bY = sim.stw * Math.cos(degToRad(sim.hdg + sim.leeway));
415
426
  const cX = sim.currentSpeed * Math.sin(degToRad(sim.currentDir)), cY = sim.currentSpeed * Math.cos(degToRad(sim.currentDir));
416
427
  const sog = Math.sqrt(Math.pow(bX + cX, 2) + Math.pow(bY + cY, 2)), cog = (radToDeg(Math.atan2(bX + cX, bY + cY)) + 360) % 360;
428
+
417
429
  const twaRad = degToRad(twaRel), aws = Math.sqrt(Math.pow(sim.stw, 2) + Math.pow(sim.tws, 2) + 2 * sim.stw * sim.tws * Math.cos(twaRad));
418
430
  const awa = Math.atan2(sim.tws * Math.sin(twaRad), sim.stw + sim.tws * Math.cos(twaRad));
419
431
  processIncomingData("environment.wind.speedApparent", ktsToMs(aws)); processIncomingData("environment.wind.angleApparent", awa);
@@ -424,7 +436,7 @@ function startDynamicSimulation() {
424
436
  }
425
437
 
426
438
  // ==========================================================================
427
- // 11. INIT
439
+ // 10. INIT
428
440
  // ==========================================================================
429
441
  window.addEventListener('contextmenu', e => e.preventDefault(), true);
430
442
  (function genTicks() { const c = document.getElementById('ticks'); if (c) { for (let i = 0; i < 360; i += 10) { const l = document.createElementNS("http://www.w3.org/2000/svg", "line"); const m = i % 30 === 0; l.setAttribute("x1", "200"); l.setAttribute("y1", "40"); l.setAttribute("x2", "200"); l.setAttribute("y2", (m ? 60 : 50)); l.setAttribute("stroke", m ? "#fff" : "#666"); l.setAttribute("stroke-width", m ? "2" : "1"); l.setAttribute("transform", `rotate(${i}, 200, 200)`); c.appendChild(l); } } })();
package/index.html CHANGED
@@ -123,10 +123,22 @@
123
123
  <g id="ticks"></g>
124
124
 
125
125
  <g id="tick-labels" fill="#bbb" text-anchor="middle" dominant-baseline="hanging" font-family="Arial" font-weight="bold">
126
+ <!-- Etichette Principali -->
126
127
  <text font-size="16" transform="translate(200, 65)">0</text>
127
128
  <text font-size="16" transform="translate(335, 200) rotate(90)">90</text>
128
129
  <text font-size="16" transform="translate(65, 200) rotate(-90)">90</text>
129
130
  <text font-size="16" transform="translate(200, 335) rotate(180)">180</text>
131
+
132
+ <!-- Etichette Intermedie (30-150 Gradi) -->
133
+ <text font-size="11" transform="translate(267.5, 83) rotate(30)">30</text>
134
+ <text font-size="11" transform="translate(317, 132.5) rotate(60)">60</text>
135
+ <text font-size="11" transform="translate(317, 267.5) rotate(120)">120</text>
136
+ <text font-size="11" transform="translate(267.5, 317) rotate(150)">150</text>
137
+
138
+ <text font-size="11" transform="translate(132.5, 83) rotate(-30)">30</text>
139
+ <text font-size="11" transform="translate(83, 132.5) rotate(-60)">60</text>
140
+ <text font-size="11" transform="translate(83, 267.5) rotate(-120)">120</text>
141
+ <text font-size="11" transform="translate(132.5, 317) rotate(-150)">150</text>
130
142
  </g>
131
143
 
132
144
  <g id="track-pointer" transform="rotate(0, 200, 200)">
@@ -146,16 +158,24 @@
146
158
  <text id="aws-val-svg" x="0" y="42" fill="#fff" font-size="52" font-weight="bold" text-anchor="middle">0.0</text>
147
159
  </g>
148
160
 
149
- <g id="awa-pointer" transform="rotate(0, 200, 200)" opacity="0.85">
150
- <path d="M200,80 L211,95 L200,145 L189,95 Z" fill="#ff8c00" stroke="#000" stroke-width="1" />
151
- <text x="200" y="102" fill="#000" font-size="11" font-weight="900" text-anchor="middle" font-family="Arial Black">A</text>
161
+ <!-- Lancetta Apparente (AWA) - Versione allungata -->
162
+ <g id="awa-pointer" transform="rotate(0, 200, 200)" opacity="0.9">
163
+ <!-- Ho portato 75 -> 70 (fuori) e 145 -> 155 (dentro) -->
164
+ <path d="M 200,70 L 213,95 L 200,145 L 187,95 Z"
165
+ fill="#ff8c00" stroke="#000" stroke-width="1" />
166
+ <text x="200" y="90" fill="#000" font-size="10" font-weight="900" text-anchor="middle" font-family="Arial Black">A</text>
152
167
  </g>
153
-
154
- <g id="twa-pointer" transform="rotate(0, 200, 200)" opacity="0.85">
155
- <path d="M200,90 L206,98 L200,125 L194,98 Z" fill="#ffff00" stroke="#000" stroke-width="0.8" />
156
- <text x="200" y="104" fill="#000" font-size="8" font-weight="900" text-anchor="middle" font-family="Arial Black">T</text>
157
- <circle id="trend-gauge-cw" cx="215" cy="110" r="4" fill="#ffffff" opacity="0.3" />
158
- <circle id="trend-gauge-ccw" cx="185" cy="110" r="4" fill="#ffffff" opacity="0.3" />
168
+
169
+ <!-- Lancetta Reale (TWA) - FORMA A GOCCIA (Secondaria/Piccola) -->
170
+ <g id="twa-pointer" transform="rotate(0, 200, 200)" opacity="0.9">
171
+ <!-- Goccia piccola: inizia a y=92, punta a y=128 -->
172
+ <path d="M 200,92 A 8,8 0 0 1 208,100 C 208,108 200,128 200,128 C 200,128 192,108 192,100 A 8,8 0 0 1 200,92 Z"
173
+ fill="#ffff00" stroke="#000" stroke-width="0.8" />
174
+ <text x="200" y="106" fill="#000" font-size="9" font-weight="900" text-anchor="middle" font-family="Arial Black">T</text>
175
+
176
+ <!-- Pallini Trend (Sempre agganciati alla rotazione del TWA) -->
177
+ <circle id="trend-gauge-cw" cx="215" cy="110" r="4" fill="#ffffff" />
178
+ <circle id="trend-gauge-ccw" cx="185" cy="110" r="4" fill="#ffffff" />
159
179
  </g>
160
180
 
161
181
  <g transform="translate(75, 395)">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sailingrotevista/rotevista-dash",
3
- "version": "2.0.18",
3
+ "version": "2.0.20",
4
4
  "description": "Public Wind Dashboard with navigation and course aids",
5
5
  "main": "index.js",
6
6
  "publishConfig": {
package/style.css CHANGED
@@ -1,5 +1,5 @@
1
1
  /* ==========================================================================
2
- 1. BASE E STRUTTURA GENERALE (LIQUID GRID LAYOUT)
2
+ 1. BASE E RESET DI SISTEMA
3
3
  ========================================================================== */
4
4
  body {
5
5
  background-color: #000;
@@ -10,33 +10,30 @@ body {
10
10
  height: 100vh;
11
11
  width: 100vw;
12
12
  overflow: hidden;
13
- /* BLOCCO TOTALE GESTI DI SISTEMA: Fondamentale per il Long Press su mobile */
13
+ /* Inibisce i gesti di sistema per favorire Long Press e Swipe della dashboard */
14
14
  -webkit-touch-callout: none;
15
15
  -webkit-user-select: none;
16
16
  user-select: none;
17
17
  touch-action: none;
18
18
  }
19
19
 
20
+ /* ==========================================================================
21
+ 2. LAYOUT PRINCIPALE (LIQUID GRID)
22
+ ========================================================================== */
20
23
  .main-container {
21
24
  display: grid;
22
25
  width: 100%;
23
26
  height: 100%;
24
27
  padding: 5px;
25
28
  box-sizing: border-box;
26
- gap: 8px; /* Spazio costante tra tutti i blocchi della dashboard */
27
-
28
- /* LAYOUT LIQUIDO:
29
- I lati hanno un minimo vitale (180px) per proteggere i testi.
30
- Il centro (auto) si adatta millimetricamente al diametro dell'SVG. */
29
+ gap: 8px;
30
+ /* Rapporto Standard: Lati flessibili, Centro bilanciato a 1.5fr */
31
31
  grid-template-columns: minmax(180px, 1fr) minmax(auto, 1.5fr) minmax(180px, 1fr);
32
32
  grid-template-rows: 100%;
33
33
  transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
34
34
  justify-content: stretch;
35
35
  }
36
36
 
37
- /* ==========================================================================
38
- 2. PANNELLI E DATA-BOX (DISTRIBUZIONE FR)
39
- ========================================================================== */
40
37
  .side-panel {
41
38
  display: flex;
42
39
  flex-direction: column;
@@ -44,14 +41,14 @@ body {
44
41
  border-radius: 12px;
45
42
  height: 100%;
46
43
  overflow: hidden;
47
- gap: 2px;
44
+ gap: 2px; /* Massimizza lo spazio per i 5 box verticali */
48
45
  }
49
46
 
50
47
  .left-panel { grid-column: 1; }
51
48
 
52
49
  .center-panel {
53
50
  grid-column: 2;
54
- position: relative; /* Indispensabile per posizionare #status al suo interno */
51
+ position: relative; /* Indispensabile per posizionare l'etichetta STATUS in alto a dx */
55
52
  display: flex;
56
53
  flex-direction: column;
57
54
  align-items: center;
@@ -66,71 +63,33 @@ body {
66
63
  text-align: right;
67
64
  }
68
65
 
66
+ /* ==========================================================================
67
+ 3. DATA-BOX E TIPOGRAFIA (UNITA' ELASTICHE CQH/CLAMP)
68
+ ========================================================================== */
69
69
  .data-box {
70
70
  position: relative;
71
71
  border-bottom: 1px solid #222;
72
- padding: 2px 4px; /* Padding ottimizzato per il recupero spazio verticale */
72
+ padding: 2px 4px;
73
73
  display: flex;
74
74
  flex-direction: column;
75
75
  width: 100%;
76
76
  box-sizing: border-box;
77
- container-type: size; /* Per unità cqh */
78
- flex: 1 1 0px; /* Divide lo spazio equamente in 5 parti */
77
+ container-type: size; /* Permette l'uso di cqh per i font */
78
+ flex: 1 1 0px; /* Distribuzione equa dello spazio verticale disponibile */
79
79
  min-height: 0;
80
80
  overflow: hidden;
81
+ transition: background-color 0.2s ease;
81
82
  }
82
83
 
83
- /* Allineamento Speculare: SX a sinistra, DX a destra del box */
84
+ /* Allineamento speculare dei testi tra colonna SX e DX */
84
85
  .left-panel .data-box { align-items: flex-start; text-align: left; }
85
86
  .right-panel .data-box { align-items: flex-end; text-align: right; }
86
87
 
87
- /* ==========================================================================
88
- 3. TACTICAL FOCUS MODE (AUTO-EXPANDING SPLIT / DUAL SCREEN)
89
- ========================================================================== */
90
-
91
- /* Nasconde le colonne non focalizzate */
92
- .focus-active .side-panel:not(.has-focus) { display: none !important; }
93
-
94
- /* In Focus Mode Left: [Pannello Sinistro Largo | Centro Stretto] */
95
- .focus-active.focus-side-left {
96
- grid-template-columns: 2fr auto !important;
97
- }
98
- .focus-active.focus-side-left .side-panel.has-focus { grid-column: 1 !important; }
99
- .focus-active.focus-side-left .center-panel { grid-column: 2 !important; justify-content: flex-start; }
100
-
101
- /* In Focus Mode Right: [Centro Stretto | Pannello Destro Largo] */
102
- .focus-active.focus-side-right {
103
- grid-template-columns: auto 2fr !important;
104
- }
105
- .focus-active.focus-side-right .center-panel { grid-column: 1 !important; justify-content: flex-start; }
106
- .focus-active.focus-side-right .side-panel.has-focus { grid-column: 2 !important; }
107
-
108
- /* Styling del box in Focus Mode */
109
- .focus-active .has-focus .data-box:not(.is-focused) { display: none !important; }
110
- .focus-active .has-focus .data-box.is-focused {
111
- height: 100vh !important;
112
- border: none;
113
- background: rgba(255, 255, 255, 0.05);
114
- padding: 20px;
115
- }
116
-
117
- /* Tipografia massiccia in Focus Mode */
118
- .focus-active .is-focused .value { font-size: clamp(4rem, 25cqh, 4rem) !important; margin-top: 15px; }
119
- .focus-active .is-focused .scale-labels { font-size: 32px !important; min-width: 40px !important; line-height: 1.2; }
120
- .focus-active .is-focused .label-row .label { font-size: 2rem !important; }
121
- .focus-active .is-focused .label-row .unit { font-size: 2rem !important; }
122
-
123
- .focus-active .is-focused .sparkline path { stroke-width: 1px !important; }
124
-
125
- /* ==========================================================================
126
- 4. TIPOGRAFIA DINAMICA (ELASTICA CQH)
127
- ========================================================================== */
128
88
  .label-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2px; width: 100%; }
129
-
130
89
  .label { color: #666; font-size: 0.65rem; font-weight: bold; text-transform: uppercase; }
131
90
  .unit { color: #888; font-size: 0.6rem; font-weight: bold; }
132
91
 
133
- /* Numero standard (22% altezza box) */
92
+ /* Font dinamico per i valori: massimo 22% dell'altezza del box */
134
93
  .value {
135
94
  color: #fff;
136
95
  font-size: clamp(1.2rem, 22cqh, 3rem);
@@ -140,34 +99,65 @@ body {
140
99
  padding-bottom: 5px;
141
100
  }
142
101
 
143
- .value-large, .dual-value-container, .value-with-compass { margin-top: auto; margin-bottom: auto; }
102
+ /* Centratura verticale automatica per i box senza grafico */
103
+ .value-large, .dual-value-container, .value-with-compass {
104
+ margin-top: auto;
105
+ margin-bottom: auto;
106
+ }
107
+
144
108
  .value-large { font-size: clamp(1.5rem, 35cqh, 4.5rem); line-height: 0.85; }
145
109
 
146
- /* TACK Layout (Mure opposte) */
110
+ /* --- 5. BUSSOLA TWD (FIX ALLINEAMENTO) --- */
111
+ .value-with-compass {
112
+ display: flex;
113
+ justify-content: space-between; /* Bussola a SX, Valore a DX */
114
+ align-items: center;
115
+ width: 100%;
116
+ align-self: stretch; /* Impedisce al pannello DX di schiacciare il widget a destra */
117
+ gap: 5px;
118
+ }
119
+
120
+ /* TACK (Mure opposte) Layout */
147
121
  .dual-value-container { display: flex; justify-content: space-between; width: 100%; }
148
122
  .dual-value-col { display: flex; flex-direction: column; width: 48%; }
149
123
  .dual-label { color: #666; font-size: 0.55rem; font-weight: bold; text-transform: uppercase; margin-bottom: 2px; }
150
124
  .value.dual-val { font-size: clamp(1rem, 22cqh, 2rem); padding-bottom: 0; line-height: 0.9; }
151
125
 
152
126
  /* ==========================================================================
153
- 5. BUSSOLA TWD DINAMICA (WIDGET)
127
+ 4. TACTICAL FOCUS MODE (DUAL SCREEN 60/40)
154
128
  ========================================================================== */
155
- .value-with-compass { display: flex; justify-content: space-between; align-items: center; width: 100%; gap: 5px; }
129
+ .focus-active .side-panel:not(.has-focus) { display: none !important; }
156
130
 
157
- .mini-compass {
158
- width: min(80cqh, 42cqw);
159
- height: min(80cqh, 42cqw);
160
- aspect-ratio: 1 / 1;
161
- flex-shrink: 0;
162
- background: #000;
163
- border-radius: 50%;
164
- border: 1.5px solid #333;
165
- box-shadow: inset 0 0 10px rgba(0,0,0,0.8);
166
- transition: all 0.4s ease;
131
+ /* Split: 60% per il grafico scelto, 40% per il vento centrale */
132
+ .focus-active.focus-side-left { grid-template-columns: 3fr 2fr !important; }
133
+ .focus-active.focus-side-left .side-panel.has-focus { grid-column: 1 !important; }
134
+ .focus-active.focus-side-left .center-panel { grid-column: 2 !important; justify-content: center !important; align-items: center !important; }
135
+
136
+ .focus-active.focus-side-right { grid-template-columns: 2fr 3fr !important; }
137
+ .focus-active.focus-side-right .center-panel { grid-column: 1 !important; justify-content: center !important; align-items: center !important; }
138
+ .focus-active.focus-side-right .side-panel.has-focus { grid-column: 2 !important; }
139
+
140
+ /* Espansione box e tipografia massiccia in Focus Mode */
141
+ .focus-active .has-focus .data-box:not(.is-focused) { display: none !important; }
142
+ .focus-active .has-focus .data-box.is-focused {
143
+ height: 100vh !important;
144
+ border: none;
145
+ background: rgba(255, 255, 255, 0.05);
146
+ padding: 2vw 3vw !important;
147
+ display: flex;
148
+ flex-direction: column;
167
149
  }
168
150
 
151
+ .focus-active .is-focused .value { font-size: clamp(4rem, 25cqh, 10rem) !important; margin-top: 15px; }
152
+ .focus-active .is-focused .scale-labels { font-size: clamp(14px, 1.8vw, 22px) !important; min-width: 50px !important; }
153
+ .focus-active .is-focused .label-row .label { font-size: 2rem !important; }
154
+ .focus-active .is-focused .label-row .unit { font-size: 2rem !important; }
155
+ .focus-active .is-focused .sparkline path { stroke-width: 2.5px !important; }
156
+
157
+ .focus-active .center-panel svg#wind-gauge { max-width: 95% !important; max-height: 85vh !important; }
158
+
169
159
  /* ==========================================================================
170
- 6. GRAFICI E SCALE (OTTIMIZZAZIONE ORIZZONTALE)
160
+ 5. GRAFICI, SCALE E HERCULES MODE
171
161
  ========================================================================== */
172
162
  .graph-wrapper {
173
163
  position: relative; width: 100%; flex-grow: 1; min-height: 0; margin-top: 4px;
@@ -175,7 +165,6 @@ body {
175
165
  border-radius: 4px; gap: 0px !important;
176
166
  }
177
167
 
178
- /* Allargamento verso il centro */
179
168
  .left-panel .graph-wrapper { margin-right: -6px; }
180
169
  .right-panel .graph-wrapper { margin-left: -6px; }
181
170
 
@@ -188,25 +177,15 @@ body {
188
177
  height: 100%; line-height: 1; padding: 0; border: none !important;
189
178
  }
190
179
 
191
- /* Simmetria scale */
192
180
  .left-panel .scale-labels { order: 2; text-align: left; padding-left: 4px; }
193
181
  .left-panel .sparkline { order: 1; }
194
182
  .right-panel .scale-labels { order: 1; text-align: right; padding-right: 4px; }
195
183
  .right-panel .sparkline { order: 2; }
196
184
 
197
- /* Colori grafici */
198
- #stw-graph { stroke: #2ecc71; fill: rgba(46, 204, 113, 0.12); }
199
- #sog-graph { stroke: #f39c12; fill: rgba(243, 156, 18, 0.12); }
200
- #depth-graph { stroke: #3498db; fill: rgba(52, 152, 219, 0.12); }
201
- #tws-graph { stroke: #ffffff; fill: rgba(255, 255, 255, 0.08); }
202
-
203
- /* ==========================================================================
204
- 7. HERCULES MODE (ZOOM E VISUALS)
205
- ========================================================================== */
185
+ /* Hercules Mode Visuals */
206
186
  .line-hercules { filter: drop-shadow(0 0 5px #ff0000); stroke-width: 1.8px !important; }
207
187
  .box-hercules { background: rgba(255, 0, 0, 0.08) !important; }
208
188
  .box-hercules .scale-labels { color: #ff8888; }
209
-
210
189
  .box-hercules .unit::before, .box-hercules .unit::after, .box-hercules .label::before {
211
190
  font-size: 7px; color: #ff4444; font-weight: 900; letter-spacing: 1px; text-transform: uppercase;
212
191
  }
@@ -214,42 +193,51 @@ body {
214
193
  .right-panel .box-hercules .unit::after { content: " HERCULES"; }
215
194
  .right-panel .box-hercules .label:only-child::after { content: " HERCULES"; }
216
195
 
196
+ /* Colori standard grafici */
197
+ #stw-graph { stroke: #2ecc71; fill: rgba(46, 204, 113, 0.12); }
198
+ #sog-graph { stroke: #f39c12; fill: rgba(243, 156, 18, 0.12); }
199
+ #depth-graph { stroke: #3498db; fill: rgba(52, 152, 219, 0.12); }
200
+ #tws-graph { stroke: #ffffff; fill: rgba(255, 255, 255, 0.08); }
201
+
217
202
  /* ==========================================================================
218
- 8. STATI, STATUS E ANIMAZIONI
203
+ 6. WIDGETS E STATUS
219
204
  ========================================================================== */
220
205
  #status {
221
- position: absolute;
222
- top: 10px;
223
- right: 10px;
224
- font-size: 0.6rem;
225
- font-weight: 900;
226
- text-transform: uppercase;
227
- z-index: 1000;
228
- letter-spacing: 1px;
229
- background: rgba(0,0,0,0.4);
230
- padding: 2px 6px;
231
- border-radius: 4px;
206
+ position: absolute; top: 10px; right: 10px;
207
+ font-size: 0.6rem; font-weight: 900;
208
+ text-transform: uppercase; z-index: 1000;
209
+ letter-spacing: 1px; background: rgba(0,0,0,0.4);
210
+ padding: 2px 6px; border-radius: 4px;
232
211
  }
233
212
  .online { color: #2ecc71; opacity: 0.5; }
234
213
  .offline { color: #e74c3c; font-weight: bold; }
235
214
 
215
+ .mini-compass {
216
+ width: min(80cqh, 42cqw); height: min(80cqh, 42cqw); aspect-ratio: 1 / 1;
217
+ flex-shrink: 0; background: #000; border-radius: 50%; border: 1.5px solid #333;
218
+ box-shadow: inset 0 0 10px rgba(0,0,0,0.8); transition: all 0.4s ease;
219
+ }
220
+
236
221
  #awa-pointer, #twa-pointer, #track-pointer, #twd-arrow, #twd-boat-wrap {
237
222
  transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
238
223
  }
239
224
 
225
+ #wind-gauge { width: 100%; height: 100%; max-height: 100%; object-fit: contain; }
226
+
227
+ /* ==========================================================================
228
+ 7. STATI DI ALLARME E INSTABILITÀ
229
+ ========================================================================== */
240
230
  .unstable-data { animation: blink-unstable 1.5s infinite ease-in-out; color: #f39c12 !important; }
241
231
  .alarm-warning { color: #f1c40f !important; }
242
232
  .alarm-danger { color: #e74c3c !important; font-weight: 900; animation: blink-unstable 1s infinite; }
243
233
  @keyframes blink-unstable { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
244
234
 
245
- #wind-gauge { width: 100%; height: 100%; max-height: 100%; object-fit: contain; }
246
-
247
235
  /* ==========================================================================
248
- 9. RESPONSIVE (PORTRAIT MODE)
236
+ 8. RESPONSIVE (PORTRAIT MODE)
249
237
  ========================================================================== */
250
238
  @media (max-aspect-ratio: 0.9 / 1) {
251
239
  .main-container { grid-template-columns: 1fr 1fr !important; grid-template-rows: 45vh calc(55vh - 8px) !important; }
252
- .center-panel { grid-row: 1 !important; grid-column: 1 / span 2 !important; padding: 5px 0; }
240
+ .center-panel { grid-row: 1 !important; grid-column: 1 / span 2 !important; }
253
241
  .left-panel { grid-row: 2 !important; grid-column: 1 !important; }
254
242
  .right-panel { grid-row: 2 !important; grid-column: 2 !important; }
255
243
  .main-container.focus-active { display: flex !important; flex-direction: column !important; }
@@ -259,7 +247,7 @@ body {
259
247
  }
260
248
 
261
249
  /* ==========================================================================
262
- 10. NIGHT MODE (RED ON BLACK - TACTICAL)
250
+ 9. NIGHT MODE (TACTICAL RED)
263
251
  ========================================================================== */
264
252
  body.night-mode { background-color: #000 !important; color: #ff0000 !important; }
265
253
  .night-mode .side-panel { background: rgba(20, 0, 0, 0.4); border: 1px solid #330000; }
@@ -267,6 +255,7 @@ body.night-mode { background-color: #000 !important; color: #ff0000 !important;
267
255
  .night-mode .label, .night-mode .unit, .night-mode .dual-label { color: #800000 !important; }
268
256
  .night-mode .value, .night-mode .value-large { color: #ff3333 !important; text-shadow: 0 0 8px rgba(255, 0, 0, 0.4); }
269
257
 
258
+ /* Grafici Night Mode: Solo linea, no riempimento */
270
259
  .night-mode .graph-wrapper { background: rgba(30, 0, 0, 0.3) !important; }
271
260
  .night-mode .sparkline path:first-of-type { display: none !important; }
272
261
  .night-mode .sparkline path { fill: none !important; stroke: #ff3333 !important; stroke-width: 1.8px !important; opacity: 1 !important; filter: drop-shadow(0 0 2px rgba(255, 0, 0, 0.4)); }
@@ -274,19 +263,34 @@ body.night-mode { background-color: #000 !important; color: #ff0000 !important;
274
263
  .night-mode #tws-graph line:not([stroke*="rgba"]) { stroke: #ff3333 !important; stroke-width: 1.8px !important; }
275
264
  .night-mode .scale-labels { color: #660000 !important; }
276
265
 
266
+ /* Hercules & special in Night */
277
267
  .night-mode .box-hercules { background: rgba(60, 0, 0, 0.2) !important; }
278
268
  .night-mode .line-hercules { filter: drop-shadow(0 0 6px #ff0000) !important; }
279
269
 
270
+ /* Wind Gauge Night */
280
271
  .night-mode #wind-gauge circle { stroke: #330000; }
281
272
  .night-mode #ticks line { stroke: #4d0000 !important; }
282
273
  .night-mode #tick-labels { fill: #800000 !important; }
283
274
  .night-mode #boat-icon { fill: #330000 !important; opacity: 0.6; }
284
275
  .night-mode #aws-val-svg { fill: #ff3333 !important; }
276
+ .night-mode #aws-display-group text { fill: #ff3333 !important; }
277
+ .night-mode #center-glow feDropShadow { flood-color: #ff0000 !important; flood-opacity: 0.6 !important; }
278
+
279
+ /* Settori Vento Night (Destra tratteggiata) */
280
+ .night-mode #wind-gauge path[stroke="#ff0000"] { stroke: #660000 !important; opacity: 0.8; }
281
+ .night-mode #wind-gauge path[stroke="#00ff00"] { stroke: #660000 !important; stroke-dasharray: 4, 3; opacity: 0.8; }
282
+ .night-mode #wind-gauge path[stroke="#ff8800"] { stroke: #330000 !important; stroke-width: 8; }
283
+
284
+ /* Leeway & Pointer Night */
285
+ .night-mode rect[fill="url(#leeway-grad)"] { fill: url(#leeway-night-grad) !important; }
286
+ .night-mode #leeway-val { fill: #ff3333 !important; }
287
+ .night-mode g[stroke="#555"] line { stroke: #4d0000 !important; }
288
+ .night-mode g[fill="#555"] text { fill: #660000 !important; }
289
+ .night-mode rect[fill="#222"] { fill: #0a0000 !important; stroke: #200000; }
290
+
285
291
  .night-mode #awa-pointer path { fill: #ff0000; stroke: #000; }
286
292
  .night-mode #twa-pointer path { fill: #800000; stroke: #000; }
287
293
  .night-mode #track-pointer path { fill: #ff0000; stroke: #fff; stroke-width: 0.5; }
288
- .night-mode #aws-display-group text { fill: #ff3333 !important; }
289
- .night-mode #center-glow feDropShadow { flood-color: #ff0000 !important; flood-opacity: 0.6 !important; }
290
294
 
291
295
  /* Bussola TWD Night */
292
296
  .night-mode .mini-compass { border-color: #330000; background: #000; }
@@ -299,14 +303,19 @@ body.night-mode { background-color: #000 !important; color: #ff0000 !important;
299
303
  ========================================================================== */
300
304
  @keyframes blink-trend { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
301
305
 
306
+ /* Stato base dei pallini di trend: discreti al 30% */
302
307
  #trend-dot-cw, #trend-dot-ccw, #trend-gauge-cw, #trend-gauge-ccw {
303
308
  opacity: 0.3;
304
309
  transition: opacity 0.3s ease;
305
310
  }
306
311
 
307
- .is-trending { animation: blink-trend 1s infinite !important; }
312
+ /* Quando attivi: brillano al 100% e lampeggiano */
313
+ .is-trending {
314
+ opacity: 1 !important;
315
+ animation: blink-trend 1s infinite !important;
316
+ }
308
317
 
309
- /* Allarme Strambata: Rosso fisso con bagliore neon */
318
+ /* Allarme Strambata: Rosso fisso con bagliore neon massimo */
310
319
  .is-gybing {
311
320
  opacity: 1 !important;
312
321
  animation: none !important;