@sailingrotevista/rotevista-dash 2.0.19 → 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 +17 -9
  3. package/package.json +1 -1
  4. package/style.css +109 -103
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
@@ -158,16 +158,24 @@
158
158
  <text id="aws-val-svg" x="0" y="42" fill="#fff" font-size="52" font-weight="bold" text-anchor="middle">0.0</text>
159
159
  </g>
160
160
 
161
- <g id="awa-pointer" transform="rotate(0, 200, 200)" opacity="0.85">
162
- <path d="M200,80 L211,95 L200,145 L189,95 Z" fill="#ff8c00" stroke="#000" stroke-width="1" />
163
- <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>
164
167
  </g>
165
-
166
- <g id="twa-pointer" transform="rotate(0, 200, 200)" opacity="0.85">
167
- <path d="M200,90 L206,98 L200,125 L194,98 Z" fill="#ffff00" stroke="#000" stroke-width="0.8" />
168
- <text x="200" y="104" fill="#000" font-size="8" font-weight="900" text-anchor="middle" font-family="Arial Black">T</text>
169
- <circle id="trend-gauge-cw" cx="215" cy="110" r="4" fill="#ffffff" opacity="0.3" />
170
- <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" />
171
179
  </g>
172
180
 
173
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.19",
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,12 +10,16 @@ body {
10
10
  height: 100vh;
11
11
  width: 100vw;
12
12
  overflow: hidden;
13
+ /* Inibisce i gesti di sistema per favorire Long Press e Swipe della dashboard */
13
14
  -webkit-touch-callout: none;
14
15
  -webkit-user-select: none;
15
16
  user-select: none;
16
17
  touch-action: none;
17
18
  }
18
19
 
20
+ /* ==========================================================================
21
+ 2. LAYOUT PRINCIPALE (LIQUID GRID)
22
+ ========================================================================== */
19
23
  .main-container {
20
24
  display: grid;
21
25
  width: 100%;
@@ -23,16 +27,13 @@ body {
23
27
  padding: 5px;
24
28
  box-sizing: border-box;
25
29
  gap: 8px;
26
- /* Rapporto Standard: Lati 1fr, Centro 1.5fr (Bilanciato) */
30
+ /* Rapporto Standard: Lati flessibili, Centro bilanciato a 1.5fr */
27
31
  grid-template-columns: minmax(180px, 1fr) minmax(auto, 1.5fr) minmax(180px, 1fr);
28
32
  grid-template-rows: 100%;
29
33
  transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
30
34
  justify-content: stretch;
31
35
  }
32
36
 
33
- /* ==========================================================================
34
- 2. PANNELLI E DATA-BOX
35
- ========================================================================== */
36
37
  .side-panel {
37
38
  display: flex;
38
39
  flex-direction: column;
@@ -40,13 +41,14 @@ body {
40
41
  border-radius: 12px;
41
42
  height: 100%;
42
43
  overflow: hidden;
43
- gap: 2px;
44
+ gap: 2px; /* Massimizza lo spazio per i 5 box verticali */
44
45
  }
45
46
 
46
47
  .left-panel { grid-column: 1; }
48
+
47
49
  .center-panel {
48
50
  grid-column: 2;
49
- position: relative;
51
+ position: relative; /* Indispensabile per posizionare l'etichetta STATUS in alto a dx */
50
52
  display: flex;
51
53
  flex-direction: column;
52
54
  align-items: center;
@@ -54,12 +56,16 @@ body {
54
56
  height: 100%;
55
57
  overflow: hidden;
56
58
  }
59
+
57
60
  .right-panel {
58
61
  grid-column: 3;
59
62
  align-items: flex-end;
60
63
  text-align: right;
61
64
  }
62
65
 
66
+ /* ==========================================================================
67
+ 3. DATA-BOX E TIPOGRAFIA (UNITA' ELASTICHE CQH/CLAMP)
68
+ ========================================================================== */
63
69
  .data-box {
64
70
  position: relative;
65
71
  border-bottom: 1px solid #222;
@@ -68,78 +74,22 @@ body {
68
74
  flex-direction: column;
69
75
  width: 100%;
70
76
  box-sizing: border-box;
71
- container-type: size;
72
- flex: 1 1 0px;
77
+ container-type: size; /* Permette l'uso di cqh per i font */
78
+ flex: 1 1 0px; /* Distribuzione equa dello spazio verticale disponibile */
73
79
  min-height: 0;
74
80
  overflow: hidden;
75
81
  transition: background-color 0.2s ease;
76
82
  }
77
83
 
84
+ /* Allineamento speculare dei testi tra colonna SX e DX */
78
85
  .left-panel .data-box { align-items: flex-start; text-align: left; }
79
86
  .right-panel .data-box { align-items: flex-end; text-align: right; }
80
87
 
81
- /* ==========================================================================
82
- 3. TACTICAL FOCUS MODE (DUAL SCREEN 60/40)
83
- ========================================================================== */
84
-
85
- /* Nasconde i pannelli non attivi */
86
- .focus-active .side-panel:not(.has-focus) { display: none !important; }
87
-
88
- /* Split 60/40: Grafico a Sinistra */
89
- .focus-active.focus-side-left {
90
- grid-template-columns: 3fr 2fr !important;
91
- }
92
- .focus-active.focus-side-left .side-panel.has-focus { grid-column: 1 !important; }
93
- .focus-active.focus-side-left .center-panel {
94
- grid-column: 2 !important;
95
- justify-content: center !important;
96
- align-items: center !important;
97
- }
98
-
99
- /* Split 60/40: Grafico a Destra */
100
- .focus-active.focus-side-right {
101
- grid-template-columns: 2fr 3fr !important;
102
- }
103
- .focus-active.focus-side-right .center-panel {
104
- grid-column: 1 !important;
105
- justify-content: center !important;
106
- align-items: center !important;
107
- }
108
- .focus-active.focus-side-right .side-panel.has-focus { grid-column: 2 !important; }
109
-
110
- /* Espansione Box Focalizzato */
111
- .focus-active .has-focus .data-box:not(.is-focused) { display: none !important; }
112
- .focus-active .has-focus .data-box.is-focused {
113
- height: 100vh !important;
114
- border: none;
115
- background: rgba(255, 255, 255, 0.05);
116
- padding: 2vw 3vw !important;
117
- display: flex;
118
- flex-direction: column;
119
- }
120
-
121
- /* Tipografia Massiccia Focus Mode */
122
- .focus-active .is-focused .value { font-size: clamp(4rem, 25cqh, 10rem) !important; margin-top: 15px; }
123
- .focus-active .is-focused .scale-labels { font-size: clamp(14px, 1.8vw, 22px) !important; min-width: 50px !important; }
124
- .focus-active .is-focused .label-row .label { font-size: 2rem !important; }
125
- .focus-active .is-focused .label-row .unit { font-size: 2rem !important; }
126
-
127
- .focus-active .is-focused .sparkline path { stroke-width: 2.5px !important; }
128
-
129
- /* Protezione Wind Gauge nel 40% di spazio */
130
- .focus-active .center-panel svg#wind-gauge {
131
- max-width: 95% !important;
132
- max-height: 85vh !important;
133
- }
134
-
135
- /* ==========================================================================
136
- 4. TIPOGRAFIA DINAMICA E ELASTICA
137
- ========================================================================== */
138
88
  .label-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2px; width: 100%; }
139
-
140
89
  .label { color: #666; font-size: 0.65rem; font-weight: bold; text-transform: uppercase; }
141
90
  .unit { color: #888; font-size: 0.6rem; font-weight: bold; }
142
91
 
92
+ /* Font dinamico per i valori: massimo 22% dell'altezza del box */
143
93
  .value {
144
94
  color: #fff;
145
95
  font-size: clamp(1.2rem, 22cqh, 3rem);
@@ -149,33 +99,65 @@ body {
149
99
  padding-bottom: 5px;
150
100
  }
151
101
 
152
- .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
+
153
108
  .value-large { font-size: clamp(1.5rem, 35cqh, 4.5rem); line-height: 0.85; }
154
109
 
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 */
155
121
  .dual-value-container { display: flex; justify-content: space-between; width: 100%; }
156
122
  .dual-value-col { display: flex; flex-direction: column; width: 48%; }
157
123
  .dual-label { color: #666; font-size: 0.55rem; font-weight: bold; text-transform: uppercase; margin-bottom: 2px; }
158
124
  .value.dual-val { font-size: clamp(1rem, 22cqh, 2rem); padding-bottom: 0; line-height: 0.9; }
159
125
 
160
126
  /* ==========================================================================
161
- 5. WIDGETS (BUSSOLA TWD)
127
+ 4. TACTICAL FOCUS MODE (DUAL SCREEN 60/40)
162
128
  ========================================================================== */
163
- .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; }
164
130
 
165
- .mini-compass {
166
- width: min(80cqh, 42cqw);
167
- height: min(80cqh, 42cqw);
168
- aspect-ratio: 1 / 1;
169
- flex-shrink: 0;
170
- background: #000;
171
- border-radius: 50%;
172
- border: 1.5px solid #333;
173
- box-shadow: inset 0 0 10px rgba(0,0,0,0.8);
174
- 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;
175
149
  }
176
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
+
177
159
  /* ==========================================================================
178
- 6. GRAFICI E SCALE
160
+ 5. GRAFICI, SCALE E HERCULES MODE
179
161
  ========================================================================== */
180
162
  .graph-wrapper {
181
163
  position: relative; width: 100%; flex-grow: 1; min-height: 0; margin-top: 4px;
@@ -200,18 +182,10 @@ body {
200
182
  .right-panel .scale-labels { order: 1; text-align: right; padding-right: 4px; }
201
183
  .right-panel .sparkline { order: 2; }
202
184
 
203
- #stw-graph { stroke: #2ecc71; fill: rgba(46, 204, 113, 0.12); }
204
- #sog-graph { stroke: #f39c12; fill: rgba(243, 156, 18, 0.12); }
205
- #depth-graph { stroke: #3498db; fill: rgba(52, 152, 219, 0.12); }
206
- #tws-graph { stroke: #ffffff; fill: rgba(255, 255, 255, 0.08); }
207
-
208
- /* ==========================================================================
209
- 7. HERCULES MODE
210
- ========================================================================== */
185
+ /* Hercules Mode Visuals */
211
186
  .line-hercules { filter: drop-shadow(0 0 5px #ff0000); stroke-width: 1.8px !important; }
212
187
  .box-hercules { background: rgba(255, 0, 0, 0.08) !important; }
213
188
  .box-hercules .scale-labels { color: #ff8888; }
214
-
215
189
  .box-hercules .unit::before, .box-hercules .unit::after, .box-hercules .label::before {
216
190
  font-size: 7px; color: #ff4444; font-weight: 900; letter-spacing: 1px; text-transform: uppercase;
217
191
  }
@@ -219,8 +193,14 @@ body {
219
193
  .right-panel .box-hercules .unit::after { content: " HERCULES"; }
220
194
  .right-panel .box-hercules .label:only-child::after { content: " HERCULES"; }
221
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
+
222
202
  /* ==========================================================================
223
- 8. STATUS E ANIMAZIONI
203
+ 6. WIDGETS E STATUS
224
204
  ========================================================================== */
225
205
  #status {
226
206
  position: absolute; top: 10px; right: 10px;
@@ -232,19 +212,28 @@ body {
232
212
  .online { color: #2ecc71; opacity: 0.5; }
233
213
  .offline { color: #e74c3c; font-weight: bold; }
234
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
+
235
221
  #awa-pointer, #twa-pointer, #track-pointer, #twd-arrow, #twd-boat-wrap {
236
222
  transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
237
223
  }
238
224
 
225
+ #wind-gauge { width: 100%; height: 100%; max-height: 100%; object-fit: contain; }
226
+
227
+ /* ==========================================================================
228
+ 7. STATI DI ALLARME E INSTABILITÀ
229
+ ========================================================================== */
239
230
  .unstable-data { animation: blink-unstable 1.5s infinite ease-in-out; color: #f39c12 !important; }
240
231
  .alarm-warning { color: #f1c40f !important; }
241
232
  .alarm-danger { color: #e74c3c !important; font-weight: 900; animation: blink-unstable 1s infinite; }
242
233
  @keyframes blink-unstable { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
243
234
 
244
- #wind-gauge { width: 100%; height: 100%; max-height: 100%; object-fit: contain; }
245
-
246
235
  /* ==========================================================================
247
- 9. RESPONSIVE (PORTRAIT)
236
+ 8. RESPONSIVE (PORTRAIT MODE)
248
237
  ========================================================================== */
249
238
  @media (max-aspect-ratio: 0.9 / 1) {
250
239
  .main-container { grid-template-columns: 1fr 1fr !important; grid-template-rows: 45vh calc(55vh - 8px) !important; }
@@ -258,7 +247,7 @@ body {
258
247
  }
259
248
 
260
249
  /* ==========================================================================
261
- 10. NIGHT MODE (TACTICAL RED)
250
+ 9. NIGHT MODE (TACTICAL RED)
262
251
  ========================================================================== */
263
252
  body.night-mode { background-color: #000 !important; color: #ff0000 !important; }
264
253
  .night-mode .side-panel { background: rgba(20, 0, 0, 0.4); border: 1px solid #330000; }
@@ -266,6 +255,7 @@ body.night-mode { background-color: #000 !important; color: #ff0000 !important;
266
255
  .night-mode .label, .night-mode .unit, .night-mode .dual-label { color: #800000 !important; }
267
256
  .night-mode .value, .night-mode .value-large { color: #ff3333 !important; text-shadow: 0 0 8px rgba(255, 0, 0, 0.4); }
268
257
 
258
+ /* Grafici Night Mode: Solo linea, no riempimento */
269
259
  .night-mode .graph-wrapper { background: rgba(30, 0, 0, 0.3) !important; }
270
260
  .night-mode .sparkline path:first-of-type { display: none !important; }
271
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)); }
@@ -273,6 +263,11 @@ body.night-mode { background-color: #000 !important; color: #ff0000 !important;
273
263
  .night-mode #tws-graph line:not([stroke*="rgba"]) { stroke: #ff3333 !important; stroke-width: 1.8px !important; }
274
264
  .night-mode .scale-labels { color: #660000 !important; }
275
265
 
266
+ /* Hercules & special in Night */
267
+ .night-mode .box-hercules { background: rgba(60, 0, 0, 0.2) !important; }
268
+ .night-mode .line-hercules { filter: drop-shadow(0 0 6px #ff0000) !important; }
269
+
270
+ /* Wind Gauge Night */
276
271
  .night-mode #wind-gauge circle { stroke: #330000; }
277
272
  .night-mode #ticks line { stroke: #4d0000 !important; }
278
273
  .night-mode #tick-labels { fill: #800000 !important; }
@@ -281,23 +276,22 @@ body.night-mode { background-color: #000 !important; color: #ff0000 !important;
281
276
  .night-mode #aws-display-group text { fill: #ff3333 !important; }
282
277
  .night-mode #center-glow feDropShadow { flood-color: #ff0000 !important; flood-opacity: 0.6 !important; }
283
278
 
284
- /* Settori Vento Night */
279
+ /* Settori Vento Night (Destra tratteggiata) */
285
280
  .night-mode #wind-gauge path[stroke="#ff0000"] { stroke: #660000 !important; opacity: 0.8; }
286
281
  .night-mode #wind-gauge path[stroke="#00ff00"] { stroke: #660000 !important; stroke-dasharray: 4, 3; opacity: 0.8; }
287
282
  .night-mode #wind-gauge path[stroke="#ff8800"] { stroke: #330000 !important; stroke-width: 8; }
288
283
 
289
- /* Lancette Night */
290
- .night-mode #awa-pointer path { fill: #ff0000; stroke: #000; }
291
- .night-mode #twa-pointer path { fill: #800000; stroke: #000; }
292
- .night-mode #track-pointer path { fill: #ff0000; stroke: #fff; stroke-width: 0.5; }
293
-
294
- /* Leeway Night */
284
+ /* Leeway & Pointer Night */
295
285
  .night-mode rect[fill="url(#leeway-grad)"] { fill: url(#leeway-night-grad) !important; }
296
286
  .night-mode #leeway-val { fill: #ff3333 !important; }
297
287
  .night-mode g[stroke="#555"] line { stroke: #4d0000 !important; }
298
288
  .night-mode g[fill="#555"] text { fill: #660000 !important; }
299
289
  .night-mode rect[fill="#222"] { fill: #0a0000 !important; stroke: #200000; }
300
290
 
291
+ .night-mode #awa-pointer path { fill: #ff0000; stroke: #000; }
292
+ .night-mode #twa-pointer path { fill: #800000; stroke: #000; }
293
+ .night-mode #track-pointer path { fill: #ff0000; stroke: #fff; stroke-width: 0.5; }
294
+
301
295
  /* Bussola TWD Night */
302
296
  .night-mode .mini-compass { border-color: #330000; background: #000; }
303
297
  .night-mode .mini-compass text { fill: #800000 !important; }
@@ -309,9 +303,21 @@ body.night-mode { background-color: #000 !important; color: #ff0000 !important;
309
303
  ========================================================================== */
310
304
  @keyframes blink-trend { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
311
305
 
306
+ /* Stato base dei pallini di trend: discreti al 30% */
312
307
  #trend-dot-cw, #trend-dot-ccw, #trend-gauge-cw, #trend-gauge-ccw {
313
308
  opacity: 0.3;
314
309
  transition: opacity 0.3s ease;
315
310
  }
316
311
 
317
- .is-trending { a
312
+ /* Quando attivi: brillano al 100% e lampeggiano */
313
+ .is-trending {
314
+ opacity: 1 !important;
315
+ animation: blink-trend 1s infinite !important;
316
+ }
317
+
318
+ /* Allarme Strambata: Rosso fisso con bagliore neon massimo */
319
+ .is-gybing {
320
+ opacity: 1 !important;
321
+ animation: none !important;
322
+ filter: drop-shadow(0 0 8px #ff0000) !important;
323
+ }