@sailingrotevista/rotevista-dash 2.0.16 → 2.0.18

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 +183 -137
  2. package/index.html +35 -81
  3. package/package.json +1 -1
  4. package/style.css +76 -249
package/app.js CHANGED
@@ -1,11 +1,16 @@
1
1
  // ==========================================================================
2
- // 1. CONFIGURAZIONE E DEFAULT
2
+ // 1. CONFIGURAZIONE E DEFAULT (Sincronizzato con il Plugin SignalK)
3
3
  // ==========================================================================
4
4
  let CONFIG = {
5
5
  alarms: { depthDanger: 2.5, depthWarning: 5.0 },
6
6
  averages: { smoothWindow: 2000, longWindow: 60000, stabilityTolerance: 2000, stabilityThreshold: 0.90, minSpeed: 0.5 },
7
7
  graphs: { reef1: 15.0, reef2: 20.0, historyMinutes: 5, samples: 60 },
8
- scales: { stw: { stdMax: 12, hercSpan: 4, step: 2 }, sog: { stdMax: 12, hercSpan: 4, step: 2 }, tws: { stdMax: 25, hercSpan: 10, step: 5 }, depth: { stdMax: 20, hercSpan: 10, step: 10 } },
8
+ scales: {
9
+ stw: { stdMax: 12, hercSpan: 4, step: 2 },
10
+ sog: { stdMax: 12, hercSpan: 4, step: 2 },
11
+ tws: { stdMax: 25, hercSpan: 10, step: 5 },
12
+ depth: { stdMax: 20, hercSpan: 10, step: 10 }
13
+ },
9
14
  server: { fallbackIp: "192.168.111.240:3000" }
10
15
  };
11
16
 
@@ -17,18 +22,18 @@ const SIM_SAMPLE_INTERVAL = 1000;
17
22
  // 2. STATO GLOBALE E RIFERIMENTI UI
18
23
  // ==========================================================================
19
24
  let simulationMode = false;
25
+ let displayModeSog = 'SOG'; // 'SOG' o 'VMG'
20
26
  let socket, renderInterval, simInterval;
21
27
  let lastAvgUIUpdate = 0, audioCtx = null, lastAlarmTime = 0;
22
28
  let curAwaRot = 0, curTwaRot = 0, curTrackRot = 0, curTwdRoseRot = 0;
29
+ let curBoatCompassRot = 0, curWindCompassRot = 0;
23
30
 
24
- // Variabili di stato per filtri e logica tattica
25
- let smoothedLeeway = 0;
26
- let rotationTrend = 0;
27
- let lastShortAvgVal = null;
28
- let lastInstantTwa = null;
29
- let isNavigating = false;
31
+ let smoothedLeeway = 0, rotationTrend = 0;
32
+ let lastShortAvgVal = null, lastInstantTwa = null;
33
+ let lastTrendTime = Date.now(), lastGybeAlarmTime = 0, lastTWCompute = 0;
34
+ let twDirty = false, isNavigating = false, reconnectDelay = 1000;
30
35
 
31
- let pressTimer, isFocusActive = false, blockNextClick = false;
36
+ let pressTimer, isFocusActive = false;
32
37
 
33
38
  const graphModes = {
34
39
  stw: localStorage.getItem('mode_stw') || 'standard',
@@ -41,8 +46,8 @@ const store = {
41
46
  raw: {}, timestamps: {},
42
47
  smoothBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
43
48
  longBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
44
- histories: { stw: [], sog: [], depth: [], tws: [] },
45
- lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0 }
49
+ histories: { stw: [], sog: [], depth: [], tws: [], vmg: [] },
50
+ lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0, vmg: 0 }
46
51
  };
47
52
 
48
53
  const ui = {
@@ -61,7 +66,7 @@ const ui = {
61
66
  };
62
67
 
63
68
  // ==========================================================================
64
- // 3. MATEMATICA E UTILS
69
+ // 3. UTILITIES DI SISTEMA (MATEMATICA E BUFFER)
65
70
  // ==========================================================================
66
71
  function radToDeg(rad) { return rad * (180 / Math.PI); }
67
72
  function degToRad(deg) { return deg * (Math.PI / 180); }
@@ -69,93 +74,128 @@ function msToKts(ms) { return ms * 1.94384; }
69
74
  function ktsToMs(kts) { return kts / 1.94384; }
70
75
  function getShortestRotation(curr, target) { let diff = (target - curr) % 360; if (diff > 180) diff -= 360; else if (diff < -180) diff += 360; return curr + diff; }
71
76
 
77
+ /**
78
+ * Gestione sicura dei buffer per prevenire memory leak (O(1) complexity).
79
+ */
80
+ function safePush(buffer, val, time, maxLen = 200) {
81
+ buffer.push({ val: val, time: time });
82
+ if (buffer.length > maxLen) { buffer.shift(); }
83
+ }
84
+
85
+ /**
86
+ * Calcola medie circolari restituendo Radianti.
87
+ */
72
88
  function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false) {
73
- const now = Date.now(); const validData = bufferArray.filter(item => (now - item.time) <= windowMs);
89
+ const now = Date.now();
90
+ const validData = bufferArray.filter(item => (now - item.time) <= windowMs);
74
91
  if (validData.length === 0) return null;
75
- let sSin = 0, sCos = 0; validData.forEach(item => { sSin += Math.sin(item.val); sCos += Math.cos(item.val); });
92
+ let sSin = 0, sCos = 0;
93
+ validData.forEach(item => { sSin += Math.sin(item.val); sCos += Math.cos(item.val); });
76
94
  let R = Math.sqrt(sSin * sSin + sCos * sCos) / validData.length;
77
95
  let isStable = (validData.length > 2) && (validData[validData.length - 1].time - validData[0].time >= windowMs - CONFIG.averages.stabilityTolerance) && (R > CONFIG.averages.stabilityThreshold);
78
- let avgDeg = Math.round(radToDeg(Math.atan2(sSin, sCos)));
79
- return { val: signed ? avgDeg : (avgDeg + 360) % 360, stable: isStable };
96
+ let avgRad = Math.atan2(sSin, sCos);
97
+ return { val: signed ? avgRad : (avgRad + 2 * Math.PI) % (2 * Math.PI), stable: isStable };
80
98
  }
81
99
 
82
100
  // ==========================================================================
83
- // 4. LOGICA FISICA (VENTO E SICUREZZA)
101
+ // 4. LOGICA FISICA E CALCOLI
84
102
  // ==========================================================================
85
103
 
86
- function playBingBing() {
87
- if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
88
- const n = Date.now(); if (n - lastAlarmTime < 3000) return; lastAlarmTime = n;
89
- 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); }
90
- b(880, audioCtx.currentTime); b(880, audioCtx.currentTime + 0.6);
91
- }
92
-
93
- function playGybeAlarm() {
94
- if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
95
- const n = Date.now(); if (n - lastAlarmTime < 2000) return; lastAlarmTime = n;
96
- 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); }
97
- 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); }
98
- }
99
-
100
- function checkDepthAlarm(m) {
101
- ui.depth.classList.remove('alarm-warning', 'alarm-danger');
102
- if (m < CONFIG.alarms.depthDanger) { ui.depth.classList.add('alarm-danger'); playBingBing(); }
103
- else if (m < CONFIG.alarms.depthWarning) ui.depth.classList.add('alarm-warning');
104
- }
105
-
104
+ /**
105
+ * Calcola il Vento Reale partendo dagli Apparenti.
106
+ * Logica Acqua (Vele) vs Terra (Meteo).
107
+ */
106
108
  function computeTrueWind() {
107
109
  const aws = store.raw["environment.wind.speedApparent"];
108
- const awa = store.raw["environment.wind.angleApparent"];
109
- const stw = store.raw["navigation.speedThroughWater"] || 0;
110
- const sog = store.raw["navigation.speedOverGround"] || 0;
111
- const hdg = store.raw["navigation.headingTrue"] || 0;
112
- const cog = store.raw["navigation.courseOverGroundTrue"] || 0;
110
+ let awa = store.raw["environment.wind.angleApparent"];
111
+ const stw = store.raw["navigation.speedThroughWater"] || 0, sog = store.raw["navigation.speedOverGround"] || 0;
112
+ const hdg = store.raw["navigation.headingTrue"] || 0, cog = store.raw["navigation.courseOverGroundTrue"] || 0;
113
+
113
114
  if (aws === undefined || awa === undefined) return;
114
- const tw_water_x = aws * Math.cos(awa) - stw;
115
- const tw_water_y = aws * Math.sin(awa);
116
- const tws_water = Math.sqrt(tw_water_x * tw_water_x + tw_water_y * tw_water_y);
117
- const twa_water = Math.atan2(tw_water_y, tw_water_x);
118
- const drift_angle = cog - hdg;
119
- const sog_vec_x = sog * Math.cos(drift_angle);
120
- const sog_vec_y = sog * Math.sin(drift_angle);
121
- const tw_ground_x = aws * Math.cos(awa) - sog_vec_x;
122
- const tw_ground_y = aws * Math.sin(awa) - sog_vec_y;
123
- const twd_ground = (hdg + Math.atan2(tw_ground_y, tw_ground_x) + 2 * Math.PI) % (2 * Math.PI);
115
+ if (awa > Math.PI) awa -= 2 * Math.PI;
116
+
117
+ // Vento Reale su ACQUA (TWA/TWS)
118
+ 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);
120
+
121
+ // Vento Reale su TERRA (TWD)
122
+ const drift_angle = (cog - hdg + Math.PI * 3) % (2 * Math.PI) - Math.PI;
123
+ const sog_vec_x = sog * Math.cos(drift_angle), sog_vec_y = sog * Math.sin(drift_angle);
124
+ 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
+
124
127
  const now = Date.now();
125
- store.raw["environment.wind.speedTrue"] = tws_water;
126
- store.raw["environment.wind.angleTrueWater"] = twa_water;
127
- store.raw["environment.wind.directionTrue"] = twd_ground;
128
- store.smoothBuf.twa.push({ val: twa_water, time: now });
129
- store.longBuf.twa.push({ val: twa_water, time: now });
130
- store.smoothBuf.twd.push({ val: twd_ground, time: now });
131
- store.longBuf.twd.push({ val: twd_ground, time: now });
128
+ store.raw["environment.wind.speedTrue"] = tws_water; store.raw["environment.wind.angleTrueWater"] = twa_water; store.raw["environment.wind.directionTrue"] = twd_ground;
129
+
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);
132
132
  }
133
133
 
134
+ function processIncomingData(path, val) {
135
+ const now = Date.now(); store.timestamps[path] = now; store.raw[path] = val;
136
+ if (path === "navigation.position") store.raw["navigation.position"] = val;
137
+ if (path === "environment.wind.angleApparent") { safePush(store.smoothBuf.awa, val, now); safePush(store.longBuf.awa, val, now); }
138
+
139
+ // Dirty flag + Debounce 100ms
140
+ const twPaths = ["environment.wind.speedApparent", "environment.wind.angleApparent", "navigation.speedThroughWater", "navigation.speedOverGround", "navigation.headingTrue", "navigation.courseOverGroundTrue"];
141
+ if (twPaths.includes(path)) twDirty = true;
142
+ if (twDirty && (now - lastTWCompute > 100)) { computeTrueWind(); lastTWCompute = now; twDirty = false; }
143
+
144
+ if (path === "navigation.headingTrue") { safePush(store.smoothBuf.hdg, val, now); safePush(store.longBuf.hdg, val, now); }
145
+ if (path === "navigation.courseOverGroundTrue") { safePush(store.smoothBuf.cog, val, now); safePush(store.longBuf.cog, val, now); }
146
+ }
147
+
148
+ // ==========================================================================
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
157
+ // ==========================================================================
134
158
  function updateWindTrend() {
159
+ const now = Date.now();
135
160
  const twaAvgObj = getCircularAverageFromBuffer(store.longBuf.twa, 60000, true);
136
161
  const shortAvg = getCircularAverageFromBuffer(store.longBuf.twd, 5000, false);
137
162
  const instantTwaRad = store.raw["environment.wind.angleTrueWater"];
163
+
138
164
  if (!shortAvg || !twaAvgObj || instantTwaRad === undefined) return;
139
- const instantTwaDeg = radToDeg(instantTwaRad);
140
- if (lastShortAvgVal === null) { lastShortAvgVal = shortAvg.val; lastInstantTwa = instantTwaDeg; return; }
141
- const gybeDetected = (Math.abs(instantTwaDeg) > 150 && Math.abs(lastInstantTwa) > 150 && Math.sign(instantTwaDeg) !== Math.sign(lastInstantTwa));
165
+ const instantTwaDeg = radToDeg(instantTwaRad), shortAvgDeg = radToDeg(shortAvg.val), twaAvgDeg = radToDeg(twaAvgObj.val);
166
+
167
+ if (lastShortAvgVal === null) { lastShortAvgVal = shortAvgDeg; lastInstantTwa = instantTwaDeg; return; }
168
+ const dt = (now - lastTrendTime) / 1000; lastTrendTime = now;
169
+
170
+ // Gybe Safety
171
+ const gybeDetected = (Math.abs(instantTwaDeg) > 155 && Math.sign(instantTwaDeg) !== Math.sign(lastInstantTwa));
142
172
  lastInstantTwa = instantTwaDeg;
173
+ if (gybeDetected && isNavigating && (now - lastGybeAlarmTime > 5000)) { lastGybeAlarmTime = now; playGybeAlarm(); }
174
+
143
175
  const compassDots = { cw: document.getElementById('trend-dot-cw'), ccw: document.getElementById('trend-dot-ccw') };
144
176
  const gaugeDots = { cw: document.getElementById('trend-gauge-cw'), ccw: document.getElementById('trend-gauge-ccw') };
145
- if (gybeDetected && isNavigating) {
146
- playGybeAlarm();
177
+
178
+ if (now - lastGybeAlarmTime < 4000 && isNavigating) {
147
179
  [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'); }});
148
180
  return;
149
181
  }
150
- let diff = (shortAvg.val - lastShortAvgVal + 540) % 360 - 180;
151
- lastShortAvgVal = shortAvg.val;
152
- rotationTrend = (rotationTrend * 0.95) + (diff * 0.05);
182
+
183
+ // Trend calculation con Clamping e Alpha adattivo
184
+ let diff = (shortAvgDeg - lastShortAvgVal + 540) % 360 - 180; lastShortAvgVal = shortAvgDeg;
185
+ if (dt > 0) {
186
+ let rate = Math.max(-10, Math.min(10, diff / dt));
187
+ const alpha = Math.min(1, dt / 5);
188
+ rotationTrend = rotationTrend * (1 - alpha) + rate * alpha;
189
+ }
190
+
153
191
  if (Math.abs(rotationTrend) > 1.0) {
154
- const pos = store.raw["navigation.position"];
155
- const isSouthernHemisphere = pos && pos.latitude < 0;
156
- let meteoColor = (!isSouthernHemisphere) ? (rotationTrend < 0 ? "#00ff00" : "#ff0000") : (rotationTrend > 0 ? "#00ff00" : "#ff0000");
157
- let isLift = (twaAvgObj.val > 0) ? (rotationTrend > 0) : (rotationTrend < 0);
192
+ const pos = store.raw["navigation.position"], isSouth = pos && pos.latitude < 0;
193
+ let meteoColor = (!isSouth) ? (rotationTrend < 0 ? "#00ff00" : "#ff0000") : (rotationTrend > 0 ? "#00ff00" : "#ff0000");
194
+ let isLift = (twaAvgDeg > 0) ? (rotationTrend > 0) : (rotationTrend < 0);
195
+ const absTwa = Math.abs(twaAvgDeg);
196
+ if (absTwa >= 90 && absTwa < 160) isLift = !isLift; else if (absTwa >= 160) isLift = false;
158
197
  const tacticColor = isLift ? "#00ff00" : "#ff0000";
198
+
159
199
  if (rotationTrend > 0) {
160
200
  if (compassDots.cw) { compassDots.cw.classList.add('is-trending'); compassDots.cw.setAttribute('fill', meteoColor); }
161
201
  if (gaugeDots.cw) { gaugeDots.cw.classList.add('is-trending'); gaugeDots.cw.setAttribute('fill', tacticColor); }
@@ -171,60 +211,64 @@ function updateWindTrend() {
171
211
  }
172
212
 
173
213
  // ==========================================================================
174
- // 5. DATA ROUTING
175
- // ==========================================================================
176
- function processIncomingData(path, val) {
177
- const now = Date.now(); store.timestamps[path] = now; store.raw[path] = val;
178
- if (path === "navigation.position") store.raw["navigation.position"] = val;
179
- if (path === "environment.wind.angleApparent") { store.smoothBuf.awa.push({ val: val, time: now }); store.longBuf.awa.push({ val: val, time: now }); }
180
- if (path === "environment.wind.speedApparent" || path === "environment.wind.angleApparent" || path === "navigation.speedThroughWater") computeTrueWind();
181
- if (path === "navigation.headingTrue") { store.smoothBuf.hdg.push({ val: val, time: now }); store.longBuf.hdg.push({ val: val, time: now }); }
182
- if (path === "navigation.courseOverGroundTrue") { store.smoothBuf.cog.push({ val: val, time: now }); store.longBuf.cog.push({ val: val, time: now }); }
183
- }
184
-
185
- // ==========================================================================
186
- // 6. MOTORE RENDERING UI
214
+ // 7. RENDERING ENGINE (TIERED RENDERING)
187
215
  // ==========================================================================
188
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)}°`; }
189
217
 
218
+ function refreshGraph(t) {
219
+ const type = (t === 'vmg') ? 'sog' : t;
220
+ const data = store.histories[t]; if (!data || data.length < 2) return;
221
+ const mode = graphModes[type], cfg = calculateScale(type, data, mode);
222
+ const graphEl = document.getElementById(type + '-graph');
223
+ if (graphEl) { const box = graphEl.closest('.data-box'); if (box) box.classList.toggle('box-hercules', mode === 'hercules'); }
224
+ updateScaleLabels(type, cfg.min, cfg.max); drawGraph(data, type + '-graph', cfg.min, cfg.max, t === 'tws', mode === 'hercules');
225
+ }
226
+
190
227
  function startDisplayLoop() {
228
+ let tick = 0;
191
229
  renderInterval = setInterval(() => {
192
- const now = Date.now();
230
+ const now = Date.now(); tick++;
231
+
232
+ // --- LIVE TIER (1s) ---
193
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 };
194
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]; } }
195
235
 
196
- const stwKts = msToKts(store.raw["navigation.speedThroughWater"] || 0);
197
- const sogKts = msToKts(store.raw["navigation.speedOverGround"] || 0);
236
+ const stwKts = msToKts(store.raw["navigation.speedThroughWater"] || 0), sogKts = msToKts(store.raw["navigation.speedOverGround"] || 0);
198
237
  isNavigating = stwKts > CONFIG.averages.minSpeed || sogKts > CONFIG.averages.minSpeed;
199
238
 
200
239
  if (store.raw["navigation.speedThroughWater"] !== undefined) { ui.stw.innerText = stwKts.toFixed(1); manageHistory('stw', stwKts); }
201
240
  if (store.raw["navigation.speedOverGround"] !== undefined) {
202
- ui.sog.innerText = sogKts.toFixed(1);
203
- let sogColor = "#fff"; const currentEffect = sogKts - stwKts;
204
- if (currentEffect > 0.3) sogColor = "#2ecc71"; else if (currentEffect < -0.3) sogColor = "#e74c3c";
205
- ui.sog.style.color = sogColor; manageHistory('sog', sogKts);
241
+ const twaRad = store.raw["environment.wind.angleTrueWater"], vmgKts = (twaRad !== undefined) ? Math.abs(stwKts * Math.cos(twaRad)) : 0;
242
+ manageHistory('vmg', vmgKts); manageHistory('sog', sogKts);
243
+ if (displayModeSog === 'VMG') { ui.sog.innerText = vmgKts.toFixed(1); ui.sog.style.color = "#64ffda"; document.getElementById('sog-vmg-label').textContent = 'VMG'; }
244
+ 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'; }
206
245
  }
207
246
  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); }
208
- if (store.raw["environment.wind.speedTrue"] !== undefined) {
209
- const twsKts = msToKts(store.raw["environment.wind.speedTrue"]); ui.tws.innerText = twsKts.toFixed(1);
210
- ui.tws.style.color = (twsKts >= CONFIG.graphs.reef2) ? "#e74c3c" : (twsKts >= CONFIG.graphs.reef1 ? "#e67e22" : "#fff");
211
- manageHistory('tws', twsKts);
212
- }
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); }
213
248
  if (store.raw["environment.wind.speedApparent"] !== undefined) ui.awsSvg.textContent = msToKts(store.raw["environment.wind.speedApparent"]).toFixed(1);
214
249
 
215
250
  const smAwa = getCircularAverageFromBuffer(store.smoothBuf.awa, CONFIG.averages.smoothWindow, true);
216
- if (smAwa) { curAwaRot = getShortestRotation(curAwaRot, smAwa.val); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
251
+ if (smAwa) { curAwaRot = getShortestRotation(curAwaRot, radToDeg(smAwa.val)); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
217
252
  const smTwa = getCircularAverageFromBuffer(store.smoothBuf.twa, CONFIG.averages.smoothWindow, true);
218
- if (smTwa) { curTwaRot = getShortestRotation(curTwaRot, smTwa.val); ui.twa.setAttribute('transform', `rotate(${curTwaRot}, 200, 200)`); }
253
+ if (smTwa) { curTwaRot = getShortestRotation(curTwaRot, radToDeg(smTwa.val)); ui.twa.setAttribute('transform', `rotate(${curTwaRot}, 200, 200)`); }
219
254
 
220
255
  if (store.raw["navigation.courseOverGroundTrue"] !== undefined && store.raw["navigation.headingTrue"] !== undefined) {
221
- let drift = (radToDeg(store.raw["navigation.courseOverGroundTrue"]) - radToDeg(store.raw["navigation.headingTrue"]) + 540) % 360 - 180;
222
- if (sogKts < CONFIG.averages.minSpeed) smoothedLeeway = 0; else smoothedLeeway = (smoothedLeeway * 0.9) + (drift * 0.1);
256
+ let driftDeg = radToDeg((store.raw["navigation.courseOverGroundTrue"] - store.raw["navigation.headingTrue"] + Math.PI * 3) % (Math.PI * 2) - Math.PI);
257
+ if (sogKts < CONFIG.averages.minSpeed) smoothedLeeway = 0; else smoothedLeeway = (smoothedLeeway * 0.9) + (driftDeg * 0.1);
223
258
  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";
224
260
  updateLeewayDisplay(Math.max(-20, Math.min(20, smoothedLeeway)));
225
- } else updateLeewayDisplay(0);
261
+ }
262
+
263
+ updateWindTrend(); // Fast update
264
+
265
+ // --- HEAVY TIER (2s) ---
266
+ if (tick % 2 === 0) {
267
+ refreshGraph('stw'); refreshGraph(displayModeSog === 'VMG' ? 'vmg' : 'sog'); refreshGraph('depth'); refreshGraph('tws');
268
+ }
226
269
 
227
- if (now - lastAvgUIUpdate > 3000) {
270
+ // --- SLOW TIER (3s) ---
271
+ if (tick % 3 === 0) {
228
272
  let hObj = getCircularAverageFromBuffer(store.longBuf.hdg, CONFIG.averages.longWindow, false),
229
273
  cObj = getCircularAverageFromBuffer(store.longBuf.cog, CONFIG.averages.longWindow, false),
230
274
  awObj = getCircularAverageFromBuffer(store.longBuf.awa, CONFIG.averages.longWindow, true),
@@ -234,26 +278,31 @@ function startDisplayLoop() {
234
278
  const upUI = (el, obj, isCompass = false) => {
235
279
  if (!obj || obj.val === null) { el.innerHTML = "---&deg;"; el.classList.remove('unstable-data'); }
236
280
  else {
237
- let val = Math.round(obj.val); el.innerHTML = (isCompass ? ((val + 360) % 360).toString().padStart(3, '0') : val) + "&deg;";
281
+ let valDeg = Math.round(radToDeg(obj.val)); el.innerHTML = (isCompass ? ((valDeg + 360) % 360).toString().padStart(3, '0') : valDeg) + "&deg;";
238
282
  if (obj.stable || !isNavigating) el.classList.remove('unstable-data'); else el.classList.add('unstable-data');
239
283
  }
240
284
  };
241
285
  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);
242
286
  if (hObj && twObj && hObj.val !== null) {
243
- const tA = twObj.val * 2;
244
- ui.tackHdg.innerHTML = `${Math.round((hObj.val - tA + 360) % 360).toString().padStart(3, '0')}&deg;`;
245
- if (cObj) ui.tackCog.innerHTML = `${Math.round((cObj.val - tA + 360) % 360).toString().padStart(3, '0')}&deg;`;
287
+ const tackHdgDeg = radToDeg((hObj.val - (twObj.val * 2) + Math.PI * 2) % (Math.PI * 2));
288
+ ui.tackHdg.innerHTML = `${Math.round((tackHdgDeg + 360) % 360).toString().padStart(3, '0')}&deg;`;
289
+ if (cObj) {
290
+ const tackCogDeg = radToDeg((cObj.val - (twObj.val * 2) + Math.PI * 2) % (Math.PI * 2));
291
+ ui.tackCog.innerHTML = `${Math.round((tackCogDeg + 360) % 360).toString().padStart(3, '0')}&deg;`;
292
+ }
293
+ }
294
+ const smHdg = getCircularAverageFromBuffer(store.smoothBuf.hdg, 2000, false), smTwd = getCircularAverageFromBuffer(store.smoothBuf.twd, 2000, false);
295
+ if (smHdg && smTwd) {
296
+ curWindCompassRot = getShortestRotation(curWindCompassRot, radToDeg(smTwd.val)); ui.twdArrow.setAttribute('transform', `rotate(${curWindCompassRot}, 20, 20)`);
297
+ curBoatCompassRot = getShortestRotation(curBoatCompassRot, radToDeg(smHdg.val)); ui.twdBoat.setAttribute('transform', `rotate(${curBoatCompassRot}, 20, 20)`);
246
298
  }
247
- if (twdObj && hObj) { updateWindTrend(); curTwdRoseRot = getShortestRotation(curTwdRoseRot, twdObj.val); ui.twdArrow.setAttribute('transform', `rotate(${curTwdRoseRot}, 20, 20)`); ui.twdBoat.setAttribute('transform', `rotate(${hObj.val}, 20, 20)`); }
248
- lastAvgUIUpdate = now;
249
299
  }
250
- for (let b in store.smoothBuf) { while (store.smoothBuf[b].length > 0 && (now - store.smoothBuf[b][0].time) > CONFIG.averages.smoothWindow) store.smoothBuf[b].shift(); }
251
- for (let b in store.longBuf) { while (store.longBuf[b].length > 0 && (now - store.longBuf[b][0].time) > CONFIG.averages.longWindow) store.longBuf[b].shift(); }
300
+ if (tick % 60 === 0) tick = 0;
252
301
  }, RENDER_INTERVAL_MS);
253
302
  }
254
303
 
255
304
  // ==========================================================================
256
- // 7. CONFIG E GRAFICI
305
+ // 8. INTERAZIONI E RETE
257
306
  // ==========================================================================
258
307
  async function fetchServerConfig() {
259
308
  if (!window.location.protocol.includes("http")) return;
@@ -281,8 +330,6 @@ async function fetchServerConfig() {
281
330
  function manageHistory(t, v) {
282
331
  const n = Date.now(); const interval = simulationMode ? SIM_SAMPLE_INTERVAL : (CONFIG.graphs.historyMinutes * 60000) / CONFIG.graphs.samples;
283
332
  if (n - store.lastUpdates[t] > interval || store.histories[t].length === 0) { store.histories[t].push(v); if (store.histories[t].length > CONFIG.graphs.samples) store.histories[t].shift(); store.lastUpdates[t] = n; }
284
- const mode = graphModes[t], cfg = calculateScale(t, store.histories[t], mode);
285
- updateScaleLabels(t, cfg.min, cfg.max); drawGraph(store.histories[t], t + '-graph', cfg.min, cfg.max, t === 'tws', mode === 'hercules');
286
333
  }
287
334
 
288
335
  function calculateScale(type, data, mode) {
@@ -308,14 +355,10 @@ function drawGraph(d, id, min, max, isTws, isHercules) {
308
355
  }
309
356
  });
310
357
  const clrs = { 'stw-graph': '#2ecc71', 'sog-graph': '#f39c12', 'depth-graph': '#3498db', 'tws-graph': '#ffffff' };
311
- 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="${clrs[id]}22" stroke="none" /><path d="${pD}" class="${isHercules?'line-hercules':''}" fill="none" stroke="${clrs[id]}" />`;
358
+ const colorKey = id === 'sog-graph' && displayModeSog === 'VMG' ? '#64ffda' : clrs[id];
359
+ 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}" />`;
312
360
  }
313
361
 
314
- // ==========================================================================
315
- // 8. INTERAZIONI
316
- // ==========================================================================
317
- window.addEventListener('contextmenu', e => e.preventDefault(), true);
318
-
319
362
  function toggleFocusMode(type, element) {
320
363
  const container = document.querySelector('.main-container'); const parentPanel = element.closest('.side-panel'); const isLeft = parentPanel.classList.contains('left-panel');
321
364
  isFocusActive = !isFocusActive;
@@ -324,14 +367,16 @@ function toggleFocusMode(type, element) {
324
367
  }
325
368
 
326
369
  ['stw', 'sog', 'tws', 'depth'].forEach(type => {
327
- const el = document.getElementById(type + '-graph').closest('.data-box'); let lastTapTime = 0, tapTimeout;
328
- el.addEventListener('pointerdown', (e) => {
329
- if (e.cancelable) e.preventDefault(); const currentTime = new Date().getTime(); const tapDelay = currentTime - lastTapTime;
330
- pressTimer = setTimeout(() => { if (!isFocusActive) { toggleFocusMode(type, el); lastTapTime = 0; } }, 1000);
370
+ const el = document.getElementById(type + '-graph').closest('.data-box');
371
+ let lastTapTime = 0, tapTimeout, isLongPressActive = false;
372
+ el.addEventListener('pointerdown', (e) => { isLongPressActive = false; pressTimer = setTimeout(() => { if (!isFocusActive) { isLongPressActive = true; toggleFocusMode(type, el); lastTapTime = 0; } }, 1000); });
373
+ el.addEventListener('pointerup', (e) => {
374
+ clearTimeout(pressTimer); if (isLongPressActive) return;
375
+ const currentTime = new Date().getTime(), tapDelay = currentTime - lastTapTime;
331
376
  if (tapDelay < 300 && tapDelay > 0) { clearTimeout(tapTimeout); if (!isFocusActive) { graphModes[type] = graphModes[type] === 'standard' ? 'hercules' : 'standard'; localStorage.setItem('mode_' + type, graphModes[type]); } lastTapTime = 0; }
332
- else { lastTapTime = currentTime; tapTimeout = setTimeout(() => { if (isFocusActive && el.classList.contains('is-focused')) toggleFocusMode(type, el); }, 350); }
377
+ else { lastTapTime = currentTime; tapTimeout = setTimeout(() => { if (isFocusActive && el.classList.contains('is-focused')) toggleFocusMode(type, el); else if (!isFocusActive && type === 'sog') { displayModeSog = (displayModeSog === 'SOG') ? 'VMG' : 'SOG'; el.style.backgroundColor = "rgba(100, 255, 218, 0.1)"; setTimeout(() => el.style.backgroundColor = "", 150); } }, 250); }
333
378
  });
334
- el.addEventListener('pointerup', () => clearTimeout(pressTimer)); el.addEventListener('pointerleave', () => clearTimeout(pressTimer));
379
+ el.addEventListener('pointerleave', () => clearTimeout(pressTimer));
335
380
  });
336
381
 
337
382
  if (ui.hotspot) {
@@ -340,25 +385,23 @@ if (ui.hotspot) {
340
385
  ui.hotspot.addEventListener('pointerleave', () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } });
341
386
  }
342
387
 
343
- // ==========================================================================
344
- // 9. CONNESSIONE E SIMULATORE
345
- // ==========================================================================
346
388
  function connect() {
347
389
  if (simulationMode) return;
348
390
  let addr = (window.location.protocol.includes("http")) ? window.location.host : CONFIG.server.fallbackIp;
391
+ const protocol = (window.location.protocol === 'https:') ? 'wss' : 'ws';
349
392
  try {
350
- socket = new WebSocket(`ws://${addr}/signalk/v1/stream?subscribe=self`);
351
- socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; };
393
+ socket = new WebSocket(`${protocol}://${addr}/signalk/v1/stream?subscribe=self`);
394
+ socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; reconnectDelay = 1000; };
352
395
  socket.onmessage = (e) => { if (simulationMode) return; const d = JSON.parse(e.data); if (d.updates) d.updates.forEach(u => u.values && u.values.forEach(v => processIncomingData(v.path, v.value))); };
353
- socket.onclose = () => !simulationMode && setTimeout(connect, 5000);
354
- } catch (e) { setTimeout(connect, 5000); }
396
+ socket.onclose = () => { if (!simulationMode) { ui.status.className = "offline"; ui.status.innerText = "RECONNECTING..."; setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 1.5, 10000); } };
397
+ } catch (e) { setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 1.5, 10000); }
355
398
  }
356
399
 
357
400
  ui.depth.closest('.data-box').addEventListener('click', (function() { let dC = 0, lC = 0; return function() { const n = Date.now(); if (n - lC < 500) dC++; else dC = 1; lC = n; if (dC === 3) { simulationMode = !simulationMode; if (simulationMode) { if (socket) socket.close(); startDynamicSimulation(); } else location.reload(); dC = 0; } }; })());
358
401
 
359
402
  function startDynamicSimulation() {
360
403
  ui.status.innerText = "SIM ATTIVO";
361
- let sim = { hdg: 45, tws: 12, twd: 45, depth: 12, stw: 5, leeway: 0, startTime: Date.now() };
404
+ let sim = { hdg: 45, tws: 12, twd: 45, depth: 12, stw: 5, leeway: 0, currentSpeed: 1.5, currentDir: 90, startTime: Date.now() };
362
405
  simInterval = setInterval(() => {
363
406
  const elapsed = (Date.now() - sim.startTime) / 1000;
364
407
  if (elapsed > 120 && elapsed < 121) sim.twd = Math.random() * 360;
@@ -368,19 +411,22 @@ function startDynamicSimulation() {
368
411
  let twaRel = (sim.twd - sim.hdg + 360) % 360; if (twaRel > 180) twaRel -= 360;
369
412
  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;
370
413
  const rawLeeway = Math.sin(degToRad(twaRel)) * 4; sim.leeway += (rawLeeway - sim.leeway) * 0.05;
371
- const cog = (sim.hdg - sim.leeway + 360) % 360; const twaRad = degToRad(twaRel);
372
- const aws = Math.sqrt(Math.pow(sim.stw, 2) + Math.pow(sim.tws, 2) + 2 * sim.stw * sim.tws * Math.cos(twaRad));
414
+ const bX = sim.stw * Math.sin(degToRad(sim.hdg + sim.leeway)), bY = sim.stw * Math.cos(degToRad(sim.hdg + sim.leeway));
415
+ const cX = sim.currentSpeed * Math.sin(degToRad(sim.currentDir)), cY = sim.currentSpeed * Math.cos(degToRad(sim.currentDir));
416
+ const sog = Math.sqrt(Math.pow(bX + cX, 2) + Math.pow(bY + cY, 2)), cog = (radToDeg(Math.atan2(bX + cX, bY + cY)) + 360) % 360;
417
+ 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));
373
418
  const awa = Math.atan2(sim.tws * Math.sin(twaRad), sim.stw + sim.tws * Math.cos(twaRad));
374
419
  processIncomingData("environment.wind.speedApparent", ktsToMs(aws)); processIncomingData("environment.wind.angleApparent", awa);
375
420
  processIncomingData("environment.depth.belowTransducer", sim.depth); processIncomingData("navigation.headingTrue", degToRad(sim.hdg));
376
- processIncomingData("navigation.speedThroughWater", ktsToMs(sim.stw)); processIncomingData("navigation.speedOverGround", ktsToMs(sim.stw));
421
+ processIncomingData("navigation.speedThroughWater", ktsToMs(sim.stw)); processIncomingData("navigation.speedOverGround", ktsToMs(sog));
377
422
  processIncomingData("navigation.courseOverGroundTrue", degToRad(cog)); processIncomingData("navigation.position", { latitude: 45.0, longitude: 12.0 });
378
423
  }, 1000);
379
424
  }
380
425
 
381
426
  // ==========================================================================
382
- // 10. INIT
427
+ // 11. INIT
383
428
  // ==========================================================================
429
+ window.addEventListener('contextmenu', e => e.preventDefault(), true);
384
430
  (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); } } })();
385
431
  async function init() { await fetchServerConfig(); startDisplayLoop(); connect(); }
386
432
  window.addEventListener('load', init);
package/index.html CHANGED
@@ -11,9 +11,6 @@
11
11
  </head>
12
12
  <body>
13
13
 
14
- <!-- Etichetta stato connessione SignalK -->
15
- <div id="status" class="offline">OFFLINE</div>
16
-
17
14
  <div class="main-container">
18
15
 
19
16
  <!-- ======================================================= -->
@@ -34,10 +31,10 @@
34
31
  </div>
35
32
  </div>
36
33
 
37
- <!-- SOG: Velocità sul fondo (GPS) -->
34
+ <!-- SOG / VMG: Velocità sul fondo o verso il vento -->
38
35
  <div class="data-box">
39
36
  <div class="label-row">
40
- <span class="label">SOG</span>
37
+ <span class="label" id="sog-vmg-label">SOG</span>
41
38
  <span class="unit">kts</span>
42
39
  </div>
43
40
  <span class="value" id="sog">0.0</span>
@@ -83,15 +80,15 @@
83
80
  </div>
84
81
 
85
82
  <!-- ======================================================= -->
86
- <!-- CENTRO: Strumento Vento SVG (Ingrandito e Ottimizzato) -->
83
+ <!-- CENTRO: Strumento Vento SVG (Pannello di Controllo) -->
87
84
  <!-- ======================================================= -->
88
85
  <div class="center-panel">
89
- <!-- ViewBox ottimizzato per ingrandire il diametro (Zoom in) -->
86
+ <!-- Stato Connessione integrato nell'angolo del pannello centrale -->
87
+ <div id="status" class="offline">OFFLINE</div>
88
+
90
89
  <svg id="wind-gauge" viewBox="35 38 330 395" preserveAspectRatio="xMidYMid meet">
91
90
  <defs>
92
- <!-- Maschera per tagliare la barca esattamente sul bordo del cerchio r=50 -->
93
91
  <clipPath id="boat-clip"><circle cx="200" cy="200" r="50" /></clipPath>
94
- <!-- Gradienti e Maschere per i settori del vento -->
95
92
  <linearGradient id="axiom-grad" x1="0%" y1="0%" x2="0%" y2="100%">
96
93
  <stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
97
94
  <stop offset="100%" style="stop-color:#888888;stop-opacity:1" />
@@ -103,74 +100,52 @@
103
100
  <stop offset="75%" style="stop-color:#ff8800;stop-opacity:1" />
104
101
  <stop offset="100%" style="stop-color:#ff0000;stop-opacity:1" />
105
102
  </linearGradient>
106
- <!-- Gradiente Leeway per la Night Mode (Rosso cupo al centro, Rosso vivo ai lati) -->
107
103
  <linearGradient id="leeway-night-grad" x1="0%" y1="0%" x2="100%" y2="0%">
108
- <stop offset="0%" style="stop-color:#ff0000;stop-opacity:1" /> <!-- Estremo SX: Rosso vivo -->
109
- <stop offset="50%" style="stop-color:#330000;stop-opacity:1" /> <!-- Centro: Rosso cupo -->
110
- <stop offset="100%" style="stop-color:#ff0000;stop-opacity:1" /> <!-- Estremo DX: Rosso vivo -->
104
+ <stop offset="0%" style="stop-color:#ff0000;stop-opacity:1" />
105
+ <stop offset="50%" style="stop-color:#330000;stop-opacity:1" />
106
+ <stop offset="100%" style="stop-color:#ff0000;stop-opacity:1" />
111
107
  </linearGradient>
112
108
  <clipPath id="leeway-clip">
113
109
  <rect id="leeway-mask-rect" x="125" y="0" width="0" height="12" rx="2" />
114
110
  </clipPath>
115
-
116
- <!-- Filtro Glow per l'area di interazione (Hotspot) centrale -->
117
111
  <filter id="center-glow" x="-20%" y="-20%" width="140%" height="140%">
118
112
  <feDropShadow dx="0" dy="0" stdDeviation="8" flood-color="#aaaaaa" flood-opacity="0.5" />
119
113
  </filter>
120
114
  </defs>
121
115
 
122
- <!-- Sfondo circolare del quadrante -->
123
116
  <circle cx="200" cy="200" r="160" fill="#050505" />
124
117
  <circle cx="200" cy="200" r="125" fill="#121212" />
125
118
 
126
- <!-- Settori Vento (Rosso/Sinitra, Verde/Dritta, Arancio/Poppa) -->
127
119
  <path d="M 82.0 101.0 A 154 154 0 0 1 142.3 57.2" fill="none" stroke="#ff0000" stroke-width="12" opacity="1"/>
128
120
  <path d="M 257.7 57.2 A 154 154 0 0 1 318.0 101.0" fill="none" stroke="#00ff00" stroke-width="12" opacity="1"/>
129
121
  <path d="M 265.1 339.6 A 154 154 0 0 1 134.9 339.6" fill="none" stroke="#ff8800" stroke-width="12" opacity="1"/>
130
122
 
131
- <!-- Tacche Gradate (Generate dinamicamente da Javascript) -->
132
123
  <g id="ticks"></g>
133
124
 
134
- <!-- Etichette fisse dei Gradi -->
135
125
  <g id="tick-labels" fill="#bbb" text-anchor="middle" dominant-baseline="hanging" font-family="Arial" font-weight="bold">
136
126
  <text font-size="16" transform="translate(200, 65)">0</text>
137
127
  <text font-size="16" transform="translate(335, 200) rotate(90)">90</text>
138
128
  <text font-size="16" transform="translate(65, 200) rotate(-90)">90</text>
139
129
  <text font-size="16" transform="translate(200, 335) rotate(180)">180</text>
140
-
141
- <!-- Dettagli 30-150 Gradi -->
142
- <text font-size="11" transform="translate(267.5, 83) rotate(30)">30</text>
143
- <text font-size="11" transform="translate(317, 132.5) rotate(60)">60</text>
144
- <text font-size="11" transform="translate(317, 267.5) rotate(120)">120</text>
145
- <text font-size="11" transform="translate(267.5, 317) rotate(150)">150</text>
146
- <text font-size="11" transform="translate(132.5, 83) rotate(-30)">30</text>
147
- <text font-size="11" transform="translate(83, 132.5) rotate(-60)">60</text>
148
- <text font-size="11" transform="translate(83, 267.5) rotate(-120)">120</text>
149
- <text font-size="11" transform="translate(132.5, 317) rotate(-150)">150</text>
150
130
  </g>
151
131
 
152
- <!-- Puntatore Track / COG (Blu) -->
153
132
  <g id="track-pointer" transform="rotate(0, 200, 200)">
154
133
  <path d="M200,42 L194,58 L206,58 Z" fill="#007aff" stroke="#fff" stroke-width="0.5" />
155
134
  </g>
156
135
 
157
- <!-- Grande area sensibile al tocco (Hotspot) con Glow per Fullscreen -->
158
136
  <circle id="fullscreen-hotspot" cx="200" cy="200" r="55" fill="#181818" stroke="#333" stroke-width="1" filter="url(#center-glow)" cursor="pointer" />
159
137
 
160
- <!-- Icona Barca Centrale (Spinta Y+5 per centratura visiva perfetta) -->
161
138
  <path id="boat-icon" d="M200,150 Q165,185 170,250 Q165,190 200,173 Q235,190 230,250 Q235,185 200,150 Z"
162
139
  fill="url(#axiom-grad)"
163
140
  transform="translate(0, 5)"
164
141
  clip-path="url(#boat-clip)"
165
142
  style="pointer-events: none;" />
166
143
 
167
- <!-- Display Centrale Numerico: Vento Apparente -->
168
144
  <g id="aws-display-group" transform="translate(200, 265)">
169
145
  <text x="0" y="0" fill="#777" font-size="10" font-weight="bold" text-anchor="middle" text-transform="uppercase">Apparent Wind kts</text>
170
146
  <text id="aws-val-svg" x="0" y="42" fill="#fff" font-size="52" font-weight="bold" text-anchor="middle">0.0</text>
171
147
  </g>
172
148
 
173
- <!-- Lancette Dinamiche: AWA (Apparente) e TWA (Reale) -->
174
149
  <g id="awa-pointer" transform="rotate(0, 200, 200)" opacity="0.85">
175
150
  <path d="M200,80 L211,95 L200,145 L189,95 Z" fill="#ff8c00" stroke="#000" stroke-width="1" />
176
151
  <text x="200" y="102" fill="#000" font-size="11" font-weight="900" text-anchor="middle" font-family="Arial Black">A</text>
@@ -183,24 +158,25 @@
183
158
  <circle id="trend-gauge-ccw" cx="185" cy="110" r="4" fill="#ffffff" opacity="0.3" />
184
159
  </g>
185
160
 
186
- <!-- Barra LEEWAY / SCARROCCIO Inferiore -->
187
161
  <g transform="translate(75, 395)">
188
162
  <text x="125" y="-12" id="leeway-val" fill="#aaa" font-size="11" text-anchor="middle" font-weight="bold">LEEWAY: 0.0&deg;</text>
189
163
  <rect x="0" y="0" width="250" height="12" fill="#222" rx="3" />
190
164
  <rect x="0" y="0" width="250" height="12" fill="url(#leeway-grad)" clip-path="url(#leeway-clip)" rx="3" />
191
165
 
192
- <!-- Scala gradi Leeway -->
166
+ <!-- RIPRISTINO SCALA: Tacche ogni 5 gradi (31.25px per intervallo) -->
193
167
  <g stroke="#555" stroke-width="1">
194
- <line x1="0" y1="-2" x2="0" y2="14" />
195
- <line x1="31.25" y1="2" x2="31.25" y2="10" />
196
- <line x1="62.5" y1="2" x2="62.5" y2="10" />
197
- <line x1="93.75" y1="3" x2="93.75" y2="9" />
198
- <line x1="125" y1="-2" x2="125" y2="14" />
199
- <line x1="156.25" y1="3" x2="156.25" y2="9" />
200
- <line x1="187.5" y1="2" x2="187.5" y2="10" />
201
- <line x1="218.75" y1="2" x2="218.75" y2="10" />
202
- <line x1="250" y1="-2" x2="250" y2="14" />
168
+ <line x1="0" y1="-2" x2="0" y2="14" /> <!-- -20° -->
169
+ <line x1="31.25" y1="2" x2="31.25" y2="10" /> <!-- -15° -->
170
+ <line x1="62.5" y1="2" x2="62.5" y2="10" /> <!-- -10° -->
171
+ <line x1="93.75" y1="3" x2="93.75" y2="9" /> <!-- -5° -->
172
+ <line x1="125" y1="-2" x2="125" y2="14" /> <!-- 0° centrale -->
173
+ <line x1="156.25" y1="3" x2="156.25" y2="9" /> <!-- +5° -->
174
+ <line x1="187.5" y1="2" x2="187.5" y2="10" /> <!-- +10° -->
175
+ <line x1="218.75" y1="2" x2="218.75" y2="10" /> <!-- +15° -->
176
+ <line x1="250" y1="-2" x2="250" y2="14" /> <!-- +20° -->
203
177
  </g>
178
+
179
+ <!-- Etichette numeriche riallineate -->
204
180
  <g fill="#555" font-size="8" text-anchor="middle" font-weight="bold">
205
181
  <text x="0" y="24">-20&deg;</text>
206
182
  <text x="62.5" y="24">-10</text>
@@ -217,7 +193,6 @@
217
193
  <!-- ======================================================= -->
218
194
  <div class="side-panel right-panel">
219
195
 
220
- <!-- DEPTH: Profondità sotto il trasduttore -->
221
196
  <div class="data-box">
222
197
  <div class="label-row">
223
198
  <span class="unit">m</span>
@@ -230,7 +205,6 @@
230
205
  </div>
231
206
  </div>
232
207
 
233
- <!-- TWS: Velocità del vento reale -->
234
208
  <div class="data-box">
235
209
  <div class="label-row">
236
210
  <span class="unit">kts</span>
@@ -243,7 +217,6 @@
243
217
  </div>
244
218
  </div>
245
219
 
246
- <!-- TWA: Angolo del vento reale -->
247
220
  <div class="data-box">
248
221
  <div class="label-row">
249
222
  <span class="label">TWA (MEAN)</span>
@@ -251,7 +224,6 @@
251
224
  <span class="value value-large" id="twa-avg">---&deg;</span>
252
225
  </div>
253
226
 
254
- <!-- AWA: Angolo del vento apparente -->
255
227
  <div class="data-box">
256
228
  <div class="label-row">
257
229
  <span class="label">AWA (MEAN)</span>
@@ -259,48 +231,30 @@
259
231
  <span class="value value-large" id="awa-avg">---&deg;</span>
260
232
  </div>
261
233
 
262
- <!-- TWD: Direzione geografica reale del vento (con Bussola) -->
263
234
  <div class="data-box">
264
235
  <div class="label-row">
265
236
  <span class="label">TWD (MEAN)</span>
266
237
  </div>
267
238
  <div class="value-with-compass">
268
239
  <svg class="mini-compass" viewBox="0 0 40 40">
269
- <!-- Cerchio esterno fisso -->
270
- <circle cx="20" cy="20" r="18.5" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="1.2"/>
271
-
272
- <!-- Punti Cardinali Nord/Sud -->
273
- <text x="20" y="8" fill="#e74c3c" font-size="5.5" text-anchor="middle" font-weight="900">N</text>
274
- <text x="20" y="36" fill="rgba(255,255,255,0.3)" font-size="5.5" text-anchor="middle" font-weight="900">S</text>
275
-
276
- <!-- Tacche Orizzontali (Est / Ovest) -->
277
- <g stroke="rgba(255,255,255,0.2)" stroke-width="1">
278
- <line x1="2" y1="20" x2="5" y2="20" /> <!-- Ovest -->
279
- <line x1="35" y1="20" x2="38" y2="20" /> <!-- Est -->
280
- </g>
281
-
282
- <!-- 1. SAGOMA BARCA: Ruota con l'Heading (id: twd-boat-wrap) -->
283
- <g id="twd-boat-wrap" transform="rotate(0, 20, 20)">
284
- <path d="M 20,17 L 17,26 L 20,24.5 L 23,26 Z" fill="white" opacity="0.2" />
285
- </g>
286
-
287
- <g id="twd-arrow" transform="rotate(0, 20, 20)">
288
- <path id="twd-wind-chevron" d="M 17,5 L 20,7.5 L 23,5"
289
- fill="none"
290
- stroke="#ffff00"
291
- stroke-width="2.2"
292
- stroke-linecap="round"
293
- stroke-linejoin="round" />
294
- <circle id="trend-dot-cw" cx="24" cy="7.5" r="1.5" fill="#ffffff" opacity="0.3" />
295
- <circle id="trend-dot-ccw" cx="16" cy="7.5" r="1.5" fill="#ffffff" opacity="0.3" />
296
- </g>
297
- </svg>
240
+ <circle cx="20" cy="20" r="18.5" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="1.2"/>
241
+ <text x="20" y="8" fill="#e74c3c" font-size="5.5" text-anchor="middle" font-weight="900">N</text>
242
+ <text x="20" y="36" fill="rgba(255,255,255,0.3)" font-size="5.5" text-anchor="middle" font-weight="900">S</text>
243
+ <g id="twd-boat-wrap" transform="rotate(0, 20, 20)">
244
+ <path d="M 20,17 L 17,26 L 20,24.5 L 23,26 Z" fill="white" opacity="0.2" />
245
+ </g>
246
+ <g id="twd-arrow" transform="rotate(0, 20, 20)">
247
+ <path id="twd-wind-chevron" d="M 17,5 L 20,7.5 L 23,5" fill="none" stroke="#ffff00" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" />
248
+ <circle id="trend-dot-cw" cx="24" cy="7.5" r="1.5" fill="#ffffff" opacity="0.3" />
249
+ <circle id="trend-dot-ccw" cx="16" cy="7.5" r="1.5" fill="#ffffff" opacity="0.3" />
250
+ </g>
251
+ </svg>
298
252
  <span class="value value-large" id="twd-avg">---&deg;</span>
299
253
  </div>
300
254
  </div>
301
255
 
302
- </div> <!-- /Destra -->
303
- </div> <!-- /Main Container -->
256
+ </div>
257
+ </div>
304
258
 
305
259
  <script src="app.js"></script>
306
260
  </body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sailingrotevista/rotevista-dash",
3
- "version": "2.0.16",
3
+ "version": "2.0.18",
4
4
  "description": "Public Wind Dashboard with navigation and course aids",
5
5
  "main": "index.js",
6
6
  "publishConfig": {
package/style.css CHANGED
@@ -25,27 +25,17 @@ body {
25
25
  box-sizing: border-box;
26
26
  gap: 8px; /* Spazio costante tra tutti i blocchi della dashboard */
27
27
 
28
- /*
29
- LAYOUT LIQUIDO:
28
+ /* LAYOUT LIQUIDO:
30
29
  I lati hanno un minimo vitale (180px) per proteggere i testi.
31
- Il centro (auto) si adatta millimetricamente al diametro dell'SVG.
32
- */
30
+ Il centro (auto) si adatta millimetricamente al diametro dell'SVG. */
33
31
  grid-template-columns: minmax(180px, 1fr) minmax(auto, 1.5fr) minmax(180px, 1fr);
34
32
  grid-template-rows: 100%;
35
33
  transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
36
34
  justify-content: stretch;
37
35
  }
38
36
 
39
- /* Espansione colonne per schermi molto larghi (es. 16:10 / 16:9)
40
- @media (min-aspect-ratio: 1.5) {
41
- .main-container {
42
- grid-template-columns: minmax(200px, 1fr) minmax(auto, 3fr) minmax(200px, 1fr);
43
- gap: 15px;
44
- }
45
- }*/
46
-
47
37
  /* ==========================================================================
48
- 2. PANNELLI E DATA-BOX
38
+ 2. PANNELLI E DATA-BOX (DISTRIBUZIONE FR)
49
39
  ========================================================================== */
50
40
  .side-panel {
51
41
  display: flex;
@@ -54,12 +44,14 @@ body {
54
44
  border-radius: 12px;
55
45
  height: 100%;
56
46
  overflow: hidden;
47
+ gap: 2px;
57
48
  }
58
49
 
59
50
  .left-panel { grid-column: 1; }
60
51
 
61
52
  .center-panel {
62
53
  grid-column: 2;
54
+ position: relative; /* Indispensabile per posizionare #status al suo interno */
63
55
  display: flex;
64
56
  flex-direction: column;
65
57
  align-items: center;
@@ -77,13 +69,14 @@ body {
77
69
  .data-box {
78
70
  position: relative;
79
71
  border-bottom: 1px solid #222;
80
- padding: 4px 8px;
72
+ padding: 2px 4px; /* Padding ottimizzato per il recupero spazio verticale */
81
73
  display: flex;
82
74
  flex-direction: column;
83
75
  width: 100%;
84
76
  box-sizing: border-box;
85
- container-type: size; /* Rende il box misurabile per l'unità cqh */
86
- flex: 1;
77
+ container-type: size; /* Per unità cqh */
78
+ flex: 1 1 0px; /* Divide lo spazio equamente in 5 parti */
79
+ min-height: 0;
87
80
  overflow: hidden;
88
81
  }
89
82
 
@@ -91,35 +84,24 @@ body {
91
84
  .left-panel .data-box { align-items: flex-start; text-align: left; }
92
85
  .right-panel .data-box { align-items: flex-end; text-align: right; }
93
86
 
94
- .side-panel {
95
- display: flex;
96
- flex-direction: column;
97
- height: 100%;
98
- gap: 2px;
99
- }
100
-
101
- .data-box {
102
- flex: 1 1 0px; /* Ogni box si divide lo spazio equamente, ma può ridursi se serve */
103
- min-height: 0; /* Fondamentale per permettere al grafico di non eccedere */
104
- padding: 2px 4px; /* Ancora più compatto */
105
- }
106
-
107
87
  /* ==========================================================================
108
- 3. TACTICAL FOCUS MODE (AUTO-EXPANDING SPLIT)
88
+ 3. TACTICAL FOCUS MODE (AUTO-EXPANDING SPLIT / DUAL SCREEN)
109
89
  ========================================================================== */
110
90
 
111
91
  /* Nasconde le colonne non focalizzate */
112
92
  .focus-active .side-panel:not(.has-focus) { display: none !important; }
113
93
 
114
- /* Protezione larghezza minima per il grafico focalizzato */
115
- .focus-active .side-panel.has-focus { min-width: 400px !important; }
116
-
117
- /* Logica di espansione: il box scelto prende tutto il resto dello spazio (1fr) */
118
- .focus-active.focus-side-left { grid-template-columns: 1fr auto !important; }
94
+ /* In Focus Mode Left: [Pannello Sinistro Largo | Centro Stretto] */
95
+ .focus-active.focus-side-left {
96
+ grid-template-columns: 2fr auto !important;
97
+ }
119
98
  .focus-active.focus-side-left .side-panel.has-focus { grid-column: 1 !important; }
120
99
  .focus-active.focus-side-left .center-panel { grid-column: 2 !important; justify-content: flex-start; }
121
100
 
122
- .focus-active.focus-side-right { grid-template-columns: auto 1fr !important; }
101
+ /* In Focus Mode Right: [Centro Stretto | Pannello Destro Largo] */
102
+ .focus-active.focus-side-right {
103
+ grid-template-columns: auto 2fr !important;
104
+ }
123
105
  .focus-active.focus-side-right .center-panel { grid-column: 1 !important; justify-content: flex-start; }
124
106
  .focus-active.focus-side-right .side-panel.has-focus { grid-column: 2 !important; }
125
107
 
@@ -138,15 +120,6 @@ body {
138
120
  .focus-active .is-focused .label-row .label { font-size: 2rem !important; }
139
121
  .focus-active .is-focused .label-row .unit { font-size: 2rem !important; }
140
122
 
141
- /* Ingrandimento etichette Hercules in Focus */
142
- .focus-active .is-focused.box-hercules .unit::before,
143
- .focus-active .is-focused.box-hercules .unit::after,
144
- .focus-active .is-focused.box-hercules .label::before,
145
- .focus-active .is-focused.box-hercules .label::after {
146
- font-size: 1.5rem !important;
147
- letter-spacing: 2px !important;
148
- }
149
-
150
123
  .focus-active .is-focused .sparkline path { stroke-width: 1px !important; }
151
124
 
152
125
  /* ==========================================================================
@@ -154,17 +127,10 @@ body {
154
127
  ========================================================================== */
155
128
  .label-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2px; width: 100%; }
156
129
 
157
- /* Simmetria Titoli/Unità: SX [Label...Unit], DX [Unit...Label] */
158
- .left-panel .label-row { justify-content: space-between; flex-direction: row; }
159
- .right-panel .label-row { justify-content: space-between; flex-direction: row; }
160
-
161
- /* Forza i titoli MEAN (senza unità) a destra nella colonna DX */
162
- .right-panel .label-row .label:only-child { margin-left: auto; }
163
-
164
130
  .label { color: #666; font-size: 0.65rem; font-weight: bold; text-transform: uppercase; }
165
131
  .unit { color: #888; font-size: 0.6rem; font-weight: bold; }
166
132
 
167
- /* Numero standard (22% altezza box per non coprire il grafico) */
133
+ /* Numero standard (22% altezza box) */
168
134
  .value {
169
135
  color: #fff;
170
136
  font-size: clamp(1.2rem, 22cqh, 3rem);
@@ -174,12 +140,7 @@ body {
174
140
  padding-bottom: 5px;
175
141
  }
176
142
 
177
- /* Centratura verticale automatica per i box senza grafico */
178
- .value-large, .dual-value-container, .value-with-compass {
179
- margin-top: auto;
180
- margin-bottom: auto;
181
- }
182
-
143
+ .value-large, .dual-value-container, .value-with-compass { margin-top: auto; margin-bottom: auto; }
183
144
  .value-large { font-size: clamp(1.5rem, 35cqh, 4.5rem); line-height: 0.85; }
184
145
 
185
146
  /* TACK Layout (Mure opposte) */
@@ -189,12 +150,11 @@ body {
189
150
  .value.dual-val { font-size: clamp(1rem, 22cqh, 2rem); padding-bottom: 0; line-height: 0.9; }
190
151
 
191
152
  /* ==========================================================================
192
- 5. BUSSOLA TWD DINAMICA
153
+ 5. BUSSOLA TWD DINAMICA (WIDGET)
193
154
  ========================================================================== */
194
155
  .value-with-compass { display: flex; justify-content: space-between; align-items: center; width: 100%; gap: 5px; }
195
156
 
196
157
  .mini-compass {
197
- /* Forma circolare protetta: 80% dell'altezza o 42% della larghezza */
198
158
  width: min(80cqh, 42cqw);
199
159
  height: min(80cqh, 42cqw);
200
160
  aspect-ratio: 1 / 1;
@@ -206,13 +166,8 @@ body {
206
166
  transition: all 0.4s ease;
207
167
  }
208
168
 
209
- .value-with-compass .value-large {
210
- margin: 0; flex: 1; text-align: right;
211
- font-size: clamp(1rem, 38cqh, 4rem); white-space: nowrap;
212
- }
213
-
214
169
  /* ==========================================================================
215
- 6. GRAFICI E SCALE (SIMMETRIA INTERNA)
170
+ 6. GRAFICI E SCALE (OTTIMIZZAZIONE ORIZZONTALE)
216
171
  ========================================================================== */
217
172
  .graph-wrapper {
218
173
  position: relative; width: 100%; flex-grow: 1; min-height: 0; margin-top: 4px;
@@ -220,7 +175,7 @@ body {
220
175
  border-radius: 4px; gap: 0px !important;
221
176
  }
222
177
 
223
- /* Spostamento verso il centro per eliminare spazi neri morti */
178
+ /* Allargamento verso il centro */
224
179
  .left-panel .graph-wrapper { margin-right: -6px; }
225
180
  .right-panel .graph-wrapper { margin-left: -6px; }
226
181
 
@@ -229,17 +184,17 @@ body {
229
184
 
230
185
  .scale-labels {
231
186
  display: flex; flex-direction: column; justify-content: space-between;
232
- font-size: 10px; color: #777; font-weight: bold; min-width: 12px;
233
- height: 100%; line-height: 1; padding: 0 px 0; border: none !important;
187
+ font-size: 8px; color: #777; font-weight: bold; min-width: 12px;
188
+ height: 100%; line-height: 1; padding: 0; border: none !important;
234
189
  }
235
190
 
236
- /* Simmetria: le scale numeriche "guardano" sempre il quadrante centrale */
237
- .left-panel .scale-labels { order: 2; text-align: left; padding-left: 4px; border-left: 1px solid rgba(255,255,255,0.08); }
191
+ /* Simmetria scale */
192
+ .left-panel .scale-labels { order: 2; text-align: left; padding-left: 4px; }
238
193
  .left-panel .sparkline { order: 1; }
239
- .right-panel .scale-labels { order: 1; text-align: right; padding-right: 4px; border-right: 1px solid rgba(255,255,255,0.08); }
194
+ .right-panel .scale-labels { order: 1; text-align: right; padding-right: 4px; }
240
195
  .right-panel .sparkline { order: 2; }
241
196
 
242
- /* Colori semantici per i grafici */
197
+ /* Colori grafici */
243
198
  #stw-graph { stroke: #2ecc71; fill: rgba(46, 204, 113, 0.12); }
244
199
  #sog-graph { stroke: #f39c12; fill: rgba(243, 156, 18, 0.12); }
245
200
  #depth-graph { stroke: #3498db; fill: rgba(52, 152, 219, 0.12); }
@@ -252,9 +207,7 @@ body {
252
207
  .box-hercules { background: rgba(255, 0, 0, 0.08) !important; }
253
208
  .box-hercules .scale-labels { color: #ff8888; }
254
209
 
255
- /* Etichetta Hercules dinamica accanto alle unità */
256
- .box-hercules .unit::before, .box-hercules .unit::after,
257
- .box-hercules .label::before {
210
+ .box-hercules .unit::before, .box-hercules .unit::after, .box-hercules .label::before {
258
211
  font-size: 7px; color: #ff4444; font-weight: 900; letter-spacing: 1px; text-transform: uppercase;
259
212
  }
260
213
  .left-panel .box-hercules .unit::before { content: "HERCULES "; }
@@ -262,226 +215,100 @@ body {
262
215
  .right-panel .box-hercules .label:only-child::after { content: " HERCULES"; }
263
216
 
264
217
  /* ==========================================================================
265
- 8. STATI E ANIMAZIONI
218
+ 8. STATI, STATUS E ANIMAZIONI
266
219
  ========================================================================== */
267
- #status { position: absolute; top: 5px; right: 15px; font-size: 0.5rem; text-transform: uppercase; z-index: 1000; }
220
+ #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;
232
+ }
268
233
  .online { color: #2ecc71; opacity: 0.5; }
269
234
  .offline { color: #e74c3c; font-weight: bold; }
270
235
 
271
- /* Animazioni fluide per gli elementi rotanti (0.8s) */
272
236
  #awa-pointer, #twa-pointer, #track-pointer, #twd-arrow, #twd-boat-wrap {
273
237
  transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
274
238
  }
275
239
 
276
- #leeway-mask-rect { transition: none; }
240
+ .unstable-data { animation: blink-unstable 1.5s infinite ease-in-out; color: #f39c12 !important; }
277
241
  .alarm-warning { color: #f1c40f !important; }
278
242
  .alarm-danger { color: #e74c3c !important; font-weight: 900; animation: blink-unstable 1s infinite; }
279
-
280
- @keyframes blink-unstable { 0% { opacity: 1; } 50% { opacity: 0.3; } 100% { opacity: 1; } }
281
- .unstable-data { animation: blink-unstable 1.5s infinite ease-in-out; color: #f39c12 !important; }
243
+ @keyframes blink-unstable { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
282
244
 
283
245
  #wind-gauge { width: 100%; height: 100%; max-height: 100%; object-fit: contain; }
284
246
 
285
247
  /* ==========================================================================
286
- 9. RESPONSIVE (PORTRAIT MODE - IPHONE/TABLET)
248
+ 9. RESPONSIVE (PORTRAIT MODE)
287
249
  ========================================================================== */
288
250
  @media (max-aspect-ratio: 0.9 / 1) {
289
- .main-container {
290
- grid-template-columns: 1fr 1fr !important;
291
- grid-template-rows: 45vh calc(55vh - 8px) !important;
292
- }
293
-
251
+ .main-container { grid-template-columns: 1fr 1fr !important; grid-template-rows: 45vh calc(55vh - 8px) !important; }
294
252
  .center-panel { grid-row: 1 !important; grid-column: 1 / span 2 !important; padding: 5px 0; }
295
253
  .left-panel { grid-row: 2 !important; grid-column: 1 !important; }
296
254
  .right-panel { grid-row: 2 !important; grid-column: 2 !important; }
297
-
298
- /* Focus Mode Verticale: Impilamento Flex sopra/sotto */
299
255
  .main-container.focus-active { display: flex !important; flex-direction: column !important; }
300
- .focus-active .center-panel { flex: 0 0 45vh !important; width: 100% !important; }
301
- .focus-active .side-panel.has-focus { flex: 0 0 calc(55vh - 8px) !important; width: 100% !important; display: flex !important; }
302
-
303
- /* Altezze box Portrait dinamiche (per evitare overflow) */
256
+ .focus-active .center-panel { flex: 0 0 45vh !important; }
257
+ .focus-active .side-panel.has-focus { flex: 0 0 calc(55vh - 8px) !important; display: flex !important; }
304
258
  .data-box { height: auto !important; flex: 1 !important; }
305
- .data-box:nth-child(1), .data-box:nth-child(2) { flex: 1.5 !important; }
306
-
307
- .value-large { font-size: clamp(1.2rem, 35cqh, 2.5rem); margin: auto 0; }
308
- .value.dual-val { font-size: clamp(0.9rem, 30cqh, 1.4rem); }
309
-
310
- /* Protezione bussola per box bassi (Portrait) */
311
- .mini-compass {
312
- width: min(70cqh, 40cqw) !important;
313
- height: min(70cqh, 40cqw) !important;
314
- }
315
-
316
- .right-panel .label-row .label:only-child { margin-left: auto !important; }
317
- .box-hercules .unit::before, .box-hercules .unit::after { font-size: 6px !important; }
318
259
  }
319
260
 
320
261
  /* ==========================================================================
321
262
  10. NIGHT MODE (RED ON BLACK - TACTICAL)
322
263
  ========================================================================== */
323
- body.night-mode {
324
- background-color: #000 !important;
325
- color: #ff0000 !important;
326
- }
327
-
328
- /* --- Pannelli e Box --- */
329
- .night-mode .side-panel {
330
- background: rgba(20, 0, 0, 0.4);
331
- border: 1px solid #330000;
332
- }
333
- .night-mode .data-box {
334
- border-bottom-color: #260000;
335
- }
336
-
337
- /* --- Tipografia Secondaria (Titoli, Unità, TACK Labels) --- */
338
- .night-mode .label,
339
- .night-mode .unit,
340
- .night-mode .dual-label {
341
- color: #800000 !important; /* Rosso cupo per non affaticare la vista */
342
- }
343
-
344
- /* --- Valori Numerici --- */
345
- .night-mode .value,
346
- .night-mode .value-large {
347
- color: #ff3333 !important;
348
- text-shadow: 0 0 8px rgba(255, 0, 0, 0.4); /* Bagliore per profondità */
349
- }
350
-
351
- /* --- Grafici Sparkline (Solo Linea, No Riempimento) --- */
352
- .night-mode .graph-wrapper {
353
- background: rgba(30, 0, 0, 0.3) !important;
354
- }
355
-
356
- /* Nasconde l'area di riempimento sotto la linea */
357
- .night-mode .sparkline path:first-of-type {
358
- display: none !important;
359
- }
360
-
361
- /* Rende la linea del tracciato molto più visibile */
362
- .night-mode .sparkline path {
363
- fill: none !important;
364
- stroke: #ff3333 !important;
365
- stroke-width: 1.8px !important;
366
- opacity: 1 !important;
367
- filter: drop-shadow(0 0 2px rgba(255, 0, 0, 0.4));
368
- }
369
-
370
- /* Griglie interne (Orizzontali e Verticali) uniformi */
371
- .night-mode .sparkline line {
372
- stroke: #4d0000 !important;
373
- stroke-width: 0.7px !important;
374
- opacity: 1 !important;
375
- }
376
-
377
- /* Forza i segmenti colorati del TWS alla stessa luminosità della linea */
378
- .night-mode #tws-graph line:not([stroke*="rgba"]) {
379
- stroke: #ff3333 !important;
380
- stroke-width: 1.8px !important;
381
- }
382
-
383
- /* Numeri delle scale a lato dei grafici */
384
- .night-mode .scale-labels {
385
- color: #660000 !important;
386
- }
387
-
388
- /* --- Hercules Mode in Night Mode --- */
389
- .night-mode .box-hercules {
390
- background: rgba(60, 0, 0, 0.2) !important;
391
- }
392
- .night-mode .line-hercules {
393
- filter: drop-shadow(0 0 6px #ff0000) !important;
394
- }
264
+ body.night-mode { background-color: #000 !important; color: #ff0000 !important; }
265
+ .night-mode .side-panel { background: rgba(20, 0, 0, 0.4); border: 1px solid #330000; }
266
+ .night-mode .data-box { border-bottom-color: #260000; }
267
+ .night-mode .label, .night-mode .unit, .night-mode .dual-label { color: #800000 !important; }
268
+ .night-mode .value, .night-mode .value-large { color: #ff3333 !important; text-shadow: 0 0 8px rgba(255, 0, 0, 0.4); }
269
+
270
+ .night-mode .graph-wrapper { background: rgba(30, 0, 0, 0.3) !important; }
271
+ .night-mode .sparkline path:first-of-type { display: none !important; }
272
+ .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
+ .night-mode .sparkline line { stroke: #4d0000 !important; stroke-width: 0.7px !important; }
274
+ .night-mode #tws-graph line:not([stroke*="rgba"]) { stroke: #ff3333 !important; stroke-width: 1.8px !important; }
275
+ .night-mode .scale-labels { color: #660000 !important; }
276
+
277
+ .night-mode .box-hercules { background: rgba(60, 0, 0, 0.2) !important; }
278
+ .night-mode .line-hercules { filter: drop-shadow(0 0 6px #ff0000) !important; }
395
279
 
396
- /* Scritta Hercules in alto (Colore personalizzato #f60000) */
397
- .night-mode .box-hercules .unit::before,
398
- .night-mode .box-hercules .unit::after,
399
- .night-mode .box-hercules .label::before,
400
- .night-mode .box-hercules .label::after {
401
- color: #f60000 !important;
402
- font-weight: 700;
403
- opacity: 0.9;
404
- letter-spacing: 1px;
405
- }
406
-
407
- /* --- Strumento Centrale (Wind Gauge) --- */
408
280
  .night-mode #wind-gauge circle { stroke: #330000; }
409
281
  .night-mode #ticks line { stroke: #4d0000 !important; }
410
282
  .night-mode #tick-labels { fill: #800000 !important; }
411
283
  .night-mode #boat-icon { fill: #330000 !important; opacity: 0.6; }
412
284
  .night-mode #aws-val-svg { fill: #ff3333 !important; }
413
-
414
- /* Settori Vento (Distinguibili per stile, non per colore) */
415
- .night-mode #wind-gauge path[stroke="#ff0000"] { stroke: #660000 !important; opacity: 0.8; } /* Sx Solid */
416
- .night-mode #wind-gauge path[stroke="#00ff00"] {
417
- stroke: #660000 !important;
418
- stroke-dasharray: 4, 3; /* Dx Dashed */
419
- opacity: 0.8;
420
- }
421
- .night-mode #wind-gauge path[stroke="#ff8800"] { stroke: #330000 !important; stroke-width: 8; } /* Poppa Dark */
422
-
423
- /* Lancette Wind Gauge */
424
285
  .night-mode #awa-pointer path { fill: #ff0000; stroke: #000; }
425
286
  .night-mode #twa-pointer path { fill: #800000; stroke: #000; }
426
287
  .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; }
427
290
 
428
- /* --- Blocco Leeway (Scarroccio) --- */
429
- .night-mode rect[fill="url(#leeway-grad)"] { fill: url(#leeway-night-grad) !important; }
430
- .night-mode #leeway-val { fill: #ff3333 !important; }
431
- .night-mode g[stroke="#555"] line { stroke: #4d0000 !important; }
432
- .night-mode g[fill="#555"] text { fill: #660000 !important; }
433
- .night-mode rect[fill="#222"] { fill: #0a0000 !important; stroke: #200000; }
434
-
435
- /* --- Bussola TWD --- */
291
+ /* Bussola TWD Night */
436
292
  .night-mode .mini-compass { border-color: #330000; background: #000; }
437
293
  .night-mode .mini-compass text { fill: #800000 !important; }
438
- .night-mode #twd-arrow path { fill: #ff0000 !important; stroke: #000 !important; }
439
- .night-mode #twd-boat-wrap path { fill: #ff0000 !important; opacity: 0.15; }
294
+ .night-mode #twd-arrow #twd-wind-chevron { stroke: #ff3333 !important; stroke-width: 3px !important; opacity: 1 !important; filter: drop-shadow(0 0 4px #ff0000); transform-origin: center; }
295
+ .night-mode #twd-boat-wrap path { fill: #ff3333 !important; opacity: 0.4 !important; }
440
296
 
441
- .night-mode #center-glow feDropShadow {
442
- flood-color: #ff0000 !important;
443
- flood-opacity: 0.6 !important;
444
- }
445
-
446
- .night-mode #aws-display-group text {
447
- fill: #ff3333 !important;
448
- }
449
-
450
- /* --- Modifica: Miglioramento visibilità TWD in Night Mode --- */
451
- .night-mode #twd-arrow #twd-wind-chevron {
452
- stroke: #ff3333 !important; /* Rosso più brillante */
453
- stroke-width: 3px !important; /* Più spesso */
454
- opacity: 1 !important; /* Piena opacità */
455
- filter: drop-shadow(0 0 4px #ff0000); /* Effetto neon per staccarsi dallo sfondo */
456
- transform-origin: center; /* Forza il centro corretto */
457
- }
458
-
459
- /* Assicuriamoci che anche la punta della barca sia visibile */
460
- .night-mode #twd-boat-wrap path {
461
- fill: #ff3333 !important;
462
- opacity: 0.4 !important;
463
- }
464
-
465
- /* Definiamo il lampeggio */
466
- @keyframes blink-trend {
467
- 0%, 100% { opacity: 1; }
468
- 50% { opacity: 0; }
469
- }
297
+ /* ==========================================================================
298
+ 11. TREND VENTO E ALLARME STRAMBATA (GYBE)
299
+ ========================================================================== */
300
+ @keyframes blink-trend { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
470
301
 
471
- /* Stile base dei pallini */
472
302
  #trend-dot-cw, #trend-dot-ccw, #trend-gauge-cw, #trend-gauge-ccw {
473
303
  opacity: 0.3;
474
304
  transition: opacity 0.3s ease;
475
305
  }
476
306
 
477
- /* Quando attivi la classe, forziamo l'animazione */
478
- .is-trending {
479
- animation: blink-trend 1s infinite !important;
480
- }
307
+ .is-trending { animation: blink-trend 1s infinite !important; }
481
308
 
482
309
  /* Allarme Strambata: Rosso fisso con bagliore neon */
483
310
  .is-gybing {
484
311
  opacity: 1 !important;
485
- animation: none !important; /* Rimuove il lampeggio */
312
+ animation: none !important;
486
313
  filter: drop-shadow(0 0 8px #ff0000) !important;
487
314
  }