@sailingrotevista/rotevista-dash 2.0.14 → 2.0.16

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 +224 -347
  2. package/index.html +4 -4
  3. package/package.json +1 -1
  4. package/style.css +11 -4
package/app.js CHANGED
@@ -1,33 +1,12 @@
1
1
  // ==========================================================================
2
- // 1. CONFIGURAZIONE E DEFAULT (Sincronizzato con il Plugin SignalK)
2
+ // 1. CONFIGURAZIONE E DEFAULT
3
3
  // ==========================================================================
4
4
  let CONFIG = {
5
- alarms: {
6
- depthDanger: 2.5,
7
- depthWarning: 5.0
8
- },
9
- averages: {
10
- smoothWindow: 2000,
11
- longWindow: 60000,
12
- stabilityTolerance: 2000,
13
- stabilityThreshold: 0.90,
14
- minSpeed: 0.5
15
- },
16
- graphs: {
17
- reef1: 15.0,
18
- reef2: 20.0,
19
- historyMinutes: 5,
20
- samples: 60
21
- },
22
- scales: {
23
- stw: { stdMax: 12, hercSpan: 4, step: 2 },
24
- sog: { stdMax: 12, hercSpan: 4, step: 2 },
25
- tws: { stdMax: 25, hercSpan: 10, step: 5 },
26
- depth: { stdMax: 20, hercSpan: 10, step: 10 }
27
- },
28
- server: {
29
- fallbackIp: "192.168.111.240:3000"
30
- }
5
+ alarms: { depthDanger: 2.5, depthWarning: 5.0 },
6
+ averages: { smoothWindow: 2000, longWindow: 60000, stabilityTolerance: 2000, stabilityThreshold: 0.90, minSpeed: 0.5 },
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 } },
9
+ server: { fallbackIp: "192.168.111.240:3000" }
31
10
  };
32
11
 
33
12
  const RENDER_INTERVAL_MS = 1000;
@@ -41,7 +20,14 @@ let simulationMode = false;
41
20
  let socket, renderInterval, simInterval;
42
21
  let lastAvgUIUpdate = 0, audioCtx = null, lastAlarmTime = 0;
43
22
  let curAwaRot = 0, curTwaRot = 0, curTrackRot = 0, curTwdRoseRot = 0;
23
+
24
+ // Variabili di stato per filtri e logica tattica
44
25
  let smoothedLeeway = 0;
26
+ let rotationTrend = 0;
27
+ let lastShortAvgVal = null;
28
+ let lastInstantTwa = null;
29
+ let isNavigating = false;
30
+
45
31
  let pressTimer, isFocusActive = false, blockNextClick = false;
46
32
 
47
33
  const graphModes = {
@@ -75,34 +61,7 @@ const ui = {
75
61
  };
76
62
 
77
63
  // ==========================================================================
78
- // 3. CARICAMENTO CONFIGURAZIONE DAL SERVER
79
- // ==========================================================================
80
- async function fetchServerConfig() {
81
- if (!window.location.protocol.includes("http")) return;
82
- const pluginID = 'rotevista-dash';
83
- const possibleUrls = [`/skServer/plugins/${pluginID}/config`, `/plugins/${pluginID}/config` ];
84
- for (let url of possibleUrls) {
85
- try {
86
- const response = await fetch(url);
87
- if (response.ok) {
88
- const data = await response.json();
89
- const actual = data.configuration || data;
90
- if (actual && typeof actual === 'object') {
91
- 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]); } };
92
- parseNumbers(actual);
93
- if (actual.alarms) CONFIG.alarms = { ...CONFIG.alarms, ...actual.alarms };
94
- if (actual.graphs) CONFIG.graphs = { ...CONFIG.graphs, ...actual.graphs };
95
- if (actual.averaging) CONFIG.averages = { ...CONFIG.averages, ...actual.averaging };
96
- if (actual.scales) { for (let key in actual.scales) { CONFIG.scales[key] = { ...CONFIG.scales[key], ...actual.scales[key] }; } }
97
- console.log("SUCCESS: Config loaded."); return;
98
- }
99
- }
100
- } catch (e) { }
101
- }
102
- }
103
-
104
- // ==========================================================================
105
- // 4. MATEMATICA E GESTIONE DATI
64
+ // 3. MATEMATICA E UTILS
106
65
  // ==========================================================================
107
66
  function radToDeg(rad) { return rad * (180 / Math.PI); }
108
67
  function degToRad(deg) { return deg * (Math.PI / 180); }
@@ -115,84 +74,156 @@ function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false) {
115
74
  if (validData.length === 0) return null;
116
75
  let sSin = 0, sCos = 0; validData.forEach(item => { sSin += Math.sin(item.val); sCos += Math.cos(item.val); });
117
76
  let R = Math.sqrt(sSin * sSin + sCos * sCos) / validData.length;
118
- let isStable = (validData[validData.length - 1].time - validData[0].time >= windowMs - CONFIG.averages.stabilityTolerance) && (R > CONFIG.averages.stabilityThreshold);
77
+ let isStable = (validData.length > 2) && (validData[validData.length - 1].time - validData[0].time >= windowMs - CONFIG.averages.stabilityTolerance) && (R > CONFIG.averages.stabilityThreshold);
119
78
  let avgDeg = Math.round(radToDeg(Math.atan2(sSin, sCos)));
120
79
  return { val: signed ? avgDeg : (avgDeg + 360) % 360, stable: isStable };
121
80
  }
122
81
 
82
+ // ==========================================================================
83
+ // 4. LOGICA FISICA (VENTO E SICUREZZA)
84
+ // ==========================================================================
85
+
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
+
106
+ function computeTrueWind() {
107
+ 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;
113
+ 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);
124
+ 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 });
132
+ }
133
+
134
+ function updateWindTrend() {
135
+ const twaAvgObj = getCircularAverageFromBuffer(store.longBuf.twa, 60000, true);
136
+ const shortAvg = getCircularAverageFromBuffer(store.longBuf.twd, 5000, false);
137
+ const instantTwaRad = store.raw["environment.wind.angleTrueWater"];
138
+ 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));
142
+ lastInstantTwa = instantTwaDeg;
143
+ const compassDots = { cw: document.getElementById('trend-dot-cw'), ccw: document.getElementById('trend-dot-ccw') };
144
+ const gaugeDots = { cw: document.getElementById('trend-gauge-cw'), ccw: document.getElementById('trend-gauge-ccw') };
145
+ if (gybeDetected && isNavigating) {
146
+ playGybeAlarm();
147
+ [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
+ return;
149
+ }
150
+ let diff = (shortAvg.val - lastShortAvgVal + 540) % 360 - 180;
151
+ lastShortAvgVal = shortAvg.val;
152
+ rotationTrend = (rotationTrend * 0.95) + (diff * 0.05);
153
+ 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);
158
+ const tacticColor = isLift ? "#00ff00" : "#ff0000";
159
+ if (rotationTrend > 0) {
160
+ if (compassDots.cw) { compassDots.cw.classList.add('is-trending'); compassDots.cw.setAttribute('fill', meteoColor); }
161
+ if (gaugeDots.cw) { gaugeDots.cw.classList.add('is-trending'); gaugeDots.cw.setAttribute('fill', tacticColor); }
162
+ [compassDots.ccw, gaugeDots.ccw].forEach(el => { if(el) { el.classList.remove('is-trending', 'is-gybing'); el.setAttribute('fill', '#ffffff'); }});
163
+ } else {
164
+ if (compassDots.ccw) { compassDots.ccw.classList.add('is-trending'); compassDots.ccw.setAttribute('fill', meteoColor); }
165
+ if (gaugeDots.ccw) { gaugeDots.ccw.classList.add('is-trending'); gaugeDots.ccw.setAttribute('fill', tacticColor); }
166
+ [compassDots.cw, gaugeDots.cw].forEach(el => { if(el) { el.classList.remove('is-trending', 'is-gybing'); el.setAttribute('fill', '#ffffff'); }});
167
+ }
168
+ } else {
169
+ [compassDots.cw, compassDots.ccw, gaugeDots.cw, gaugeDots.ccw].forEach(el => { if (el) { el.classList.remove('is-trending', 'is-gybing'); el.setAttribute('fill', '#ffffff'); }});
170
+ }
171
+ }
172
+
173
+ // ==========================================================================
174
+ // 5. DATA ROUTING
175
+ // ==========================================================================
123
176
  function processIncomingData(path, val) {
124
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();
125
181
  if (path === "navigation.headingTrue") { store.smoothBuf.hdg.push({ val: val, time: now }); store.longBuf.hdg.push({ val: val, time: now }); }
126
182
  if (path === "navigation.courseOverGroundTrue") { store.smoothBuf.cog.push({ val: val, time: now }); store.longBuf.cog.push({ val: val, time: now }); }
127
- if (path === "environment.wind.angleApparent") { store.smoothBuf.awa.push({ val: val, time: now }); store.longBuf.awa.push({ val: val, time: now }); }
128
- if (path === "environment.wind.angleTrueWater") { store.smoothBuf.twa.push({ val: val, time: now }); store.longBuf.twa.push({ val: val, time: now }); }
129
- if (path === "navigation.headingTrue" || path === "environment.wind.angleTrueWater" || path === "environment.wind.directionTrue") {
130
- let twdRad = 0; if (path === "environment.wind.directionTrue") twdRad = val;
131
- else if (store.raw["navigation.headingTrue"] !== undefined && store.raw["environment.wind.angleTrueWater"] !== undefined) {
132
- twdRad = (store.raw["navigation.headingTrue"] + store.raw["environment.wind.angleTrueWater"]) % (2 * Math.PI);
133
- if (twdRad < 0) twdRad += (2 * Math.PI);
134
- } else return;
135
- store.smoothBuf.twd.push({ val: twdRad, time: now }); store.longBuf.twd.push({ val: twdRad, time: now });
136
- }
137
183
  }
138
184
 
139
185
  // ==========================================================================
140
- // 5. MOTORE RENDERING PRINCIPALE
186
+ // 6. MOTORE RENDERING UI
141
187
  // ==========================================================================
188
+ 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
+
142
190
  function startDisplayLoop() {
143
191
  renderInterval = setInterval(() => {
144
192
  const now = Date.now();
145
193
  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 };
146
- for (let p in pathsToWatch) { if (!store.timestamps[p] || (now - store.timestamps[p] > TIMEOUT_MS)) { pathsToWatch[p][pathsToWatch[p] === ui.awsSvg ? 'textContent' : 'innerText'] = "---"; delete store.raw[p]; } }
147
-
148
- if (store.raw["navigation.speedThroughWater"] !== undefined) { const v = msToKts(store.raw["navigation.speedThroughWater"]); ui.stw.innerText = v.toFixed(1); manageHistory('stw', v); }
149
- let curSog = 0; if (store.raw["navigation.speedOverGround"] !== undefined) { curSog = msToKts(store.raw["navigation.speedOverGround"]); ui.sog.innerText = curSog.toFixed(1); manageHistory('sog', curSog); }
194
+ 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
+
196
+ const stwKts = msToKts(store.raw["navigation.speedThroughWater"] || 0);
197
+ const sogKts = msToKts(store.raw["navigation.speedOverGround"] || 0);
198
+ isNavigating = stwKts > CONFIG.averages.minSpeed || sogKts > CONFIG.averages.minSpeed;
199
+
200
+ if (store.raw["navigation.speedThroughWater"] !== undefined) { ui.stw.innerText = stwKts.toFixed(1); manageHistory('stw', stwKts); }
201
+ 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);
206
+ }
150
207
  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); }
151
-
152
- // --- 1. RENDERING TWS CON COLORE DINAMICO (BIANCO -> ARANCIO -> ROSSO) ---
153
208
  if (store.raw["environment.wind.speedTrue"] !== undefined) {
154
- const twsKts = msToKts(store.raw["environment.wind.speedTrue"]);
155
- ui.tws.innerText = twsKts.toFixed(1);
156
- let twsColor = "#fff"; // Default: Bianco
157
- if (twsKts >= CONFIG.graphs.reef2) twsColor = "#e74c3c"; // Rosso
158
- else if (twsKts >= CONFIG.graphs.reef1) twsColor = "#e67e22"; // Arancio
159
- ui.tws.style.color = twsColor;
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");
160
211
  manageHistory('tws', twsKts);
161
212
  }
162
213
  if (store.raw["environment.wind.speedApparent"] !== undefined) ui.awsSvg.textContent = msToKts(store.raw["environment.wind.speedApparent"]).toFixed(1);
163
214
 
164
- // Render Quadrante
165
215
  const smAwa = getCircularAverageFromBuffer(store.smoothBuf.awa, CONFIG.averages.smoothWindow, true);
166
216
  if (smAwa) { curAwaRot = getShortestRotation(curAwaRot, smAwa.val); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
167
217
  const smTwa = getCircularAverageFromBuffer(store.smoothBuf.twa, CONFIG.averages.smoothWindow, true);
168
218
  if (smTwa) { curTwaRot = getShortestRotation(curTwaRot, smTwa.val); ui.twa.setAttribute('transform', `rotate(${curTwaRot}, 200, 200)`); }
169
219
 
170
220
  if (store.raw["navigation.courseOverGroundTrue"] !== undefined && store.raw["navigation.headingTrue"] !== undefined) {
171
- // 1. Calcolo grezzo
172
- let rawDrift = (radToDeg(store.raw["navigation.courseOverGroundTrue"]) - radToDeg(store.raw["navigation.headingTrue"]) + 360) % 360;
173
- if (rawDrift > 180) rawDrift -= 360;
174
-
175
- // 2. Filtro di stabilità (Low Pass Filter)
176
- // Se la barca è ferma, azzera brutalmente. Altrimenti filtra il valore.
177
- if (curSog < CONFIG.averages.minSpeed) {
178
- smoothedLeeway = 0;
179
- } else {
180
- // Smoothing: 90% valore precedente, 10% nuovo valore
181
- smoothedLeeway = (smoothedLeeway * 0.9) + (rawDrift * 0.1);
182
- }
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);
223
+ curTrackRot = getShortestRotation(curTrackRot, smoothedLeeway); ui.track.setAttribute('transform', `rotate(${curTrackRot}, 200, 200)`);
224
+ updateLeewayDisplay(Math.max(-20, Math.min(20, smoothedLeeway)));
225
+ } else updateLeewayDisplay(0);
183
226
 
184
- // 3. Renderizziamo il valore filtrato
185
- curTrackRot = getShortestRotation(curTrackRot, smoothedLeeway);
186
- ui.track.setAttribute('transform', `rotate(${curTrackRot}, 200, 200)`);
187
-
188
- // Limita il valore grafico e aggiorna il testo
189
- let ds = Math.max(-20, Math.min(20, smoothedLeeway));
190
- updateLeewayDisplay(ds);
191
- } else {
192
- updateLeewayDisplay(0);
193
- }
194
-
195
- // --- 2. RENDERING MEDIE E BUSSOLA TATTICA ---
196
227
  if (now - lastAvgUIUpdate > 3000) {
197
228
  let hObj = getCircularAverageFromBuffer(store.longBuf.hdg, CONFIG.averages.longWindow, false),
198
229
  cObj = getCircularAverageFromBuffer(store.longBuf.cog, CONFIG.averages.longWindow, false),
@@ -201,56 +232,19 @@ function startDisplayLoop() {
201
232
  twdObj = getCircularAverageFromBuffer(store.longBuf.twd, CONFIG.averages.longWindow, false);
202
233
 
203
234
  const upUI = (el, obj, isCompass = false) => {
204
- if (!obj || obj.val === null) {
205
- el.innerHTML = "---&deg;";
206
- el.classList.remove('unstable-data');
207
- } else {
208
- let val = Math.round(obj.val);
209
- let displayVal;
210
-
211
- if (isCompass) {
212
- // Formattazione per HEADING, COG, TWD (0-360°)
213
- displayVal = ((val + 360) % 360).toString().padStart(3, '0');
214
- } else {
215
- // Formattazione per AWA, TWA (-180 a 180°)
216
- // Se è negativo, il segno viene mantenuto correttamente
217
- displayVal = val.toString();
218
- }
219
-
220
- el.innerHTML = `${displayVal}&deg;`;
221
-
222
- if (obj.stable || curSog < CONFIG.averages.minSpeed) el.classList.remove('unstable-data');
223
- else el.classList.add('unstable-data');
235
+ if (!obj || obj.val === null) { el.innerHTML = "---&deg;"; el.classList.remove('unstable-data'); }
236
+ else {
237
+ let val = Math.round(obj.val); el.innerHTML = (isCompass ? ((val + 360) % 360).toString().padStart(3, '0') : val) + "&deg;";
238
+ if (obj.stable || !isNavigating) el.classList.remove('unstable-data'); else el.classList.add('unstable-data');
224
239
  }
225
240
  };
226
-
227
- upUI(ui.hdg, hObj, true); // Bussola
228
- upUI(ui.cog, cObj, true); // Bussola
229
- upUI(ui.awaAvg, awObj, false); // Angolo
230
- upUI(ui.twaAvg, twObj, false); // Angolo
231
- upUI(ui.twdAvg, twdObj, true); // Bussola (La direzione del vento è sempre 0-360)
232
-
241
+ 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);
233
242
  if (hObj && twObj && hObj.val !== null) {
234
- let tA = twObj.val * 2; ui.tackHdg.innerHTML = `${Math.round((hObj.val - tA + 360) % 360).toString().padStart(3, '0')}&deg;`;
243
+ const tA = twObj.val * 2;
244
+ ui.tackHdg.innerHTML = `${Math.round((hObj.val - tA + 360) % 360).toString().padStart(3, '0')}&deg;`;
235
245
  if (cObj) ui.tackCog.innerHTML = `${Math.round((cObj.val - tA + 360) % 360).toString().padStart(3, '0')}&deg;`;
236
- let tStable = (hObj.stable && twObj.stable) || (curSog < CONFIG.averages.minSpeed);
237
- if (tStable) { ui.tackHdg.classList.remove('unstable-data'); ui.tackCog.classList.remove('unstable-data'); }
238
- else { ui.tackHdg.classList.add('unstable-data'); ui.tackCog.classList.add('unstable-data'); }
239
- }
240
-
241
- // Rotazione Bussola Tattica e Colore Sincronizzato
242
- if (twdObj && hObj) {
243
- updateWindTrend();
244
- curTwdRoseRot = getShortestRotation(curTwdRoseRot, twdObj.val);
245
- ui.twdArrow.setAttribute('transform', `rotate(${curTwdRoseRot}, 20, 20)`);
246
- ui.twdBoat.setAttribute('transform', `rotate(${hObj.val}, 20, 20)`);
247
-
248
- let currentTwsKts = store.raw["environment.wind.speedTrue"] ? msToKts(store.raw["environment.wind.speedTrue"]) : 0;
249
- let reefColor = "#fff";
250
- if (currentTwsKts >= CONFIG.graphs.reef2) reefColor = "#e74c3c";
251
- else if (currentTwsKts >= CONFIG.graphs.reef1) reefColor = "#e67e22";
252
- if (ui.twdChevron) ui.twdChevron.setAttribute('stroke', reefColor);
253
246
  }
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)`); }
254
248
  lastAvgUIUpdate = now;
255
249
  }
256
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(); }
@@ -259,251 +253,134 @@ function startDisplayLoop() {
259
253
  }
260
254
 
261
255
  // ==========================================================================
262
- // 6. CONNESSIONE SIGNALK
256
+ // 7. CONFIG E GRAFICI
263
257
  // ==========================================================================
264
- function connect() {
265
- if (simulationMode) return;
266
- let addr = (window.location.protocol.includes("http")) ? window.location.host : CONFIG.server.fallbackIp;
267
- try {
268
- socket = new WebSocket(`ws://${addr}/signalk/v1/stream?subscribe=self`);
269
- socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; };
270
- 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))); };
271
- socket.onclose = () => !simulationMode && setTimeout(connect, 5000);
272
- } catch (e) { setTimeout(connect, 5000); }
258
+ async function fetchServerConfig() {
259
+ if (!window.location.protocol.includes("http")) return;
260
+ const pluginID = 'rotevista-dash';
261
+ const possibleUrls = [`/skServer/plugins/${pluginID}/config`, `/plugins/${pluginID}/config` ];
262
+ for (let url of possibleUrls) {
263
+ try {
264
+ const response = await fetch(url);
265
+ if (response.ok) {
266
+ const data = await response.json();
267
+ const actual = data.configuration || data;
268
+ if (actual && typeof actual === 'object') {
269
+ 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]); } };
270
+ parseNumbers(actual);
271
+ if (actual.alarms) CONFIG.alarms = { ...CONFIG.alarms, ...actual.alarms };
272
+ if (actual.graphs) CONFIG.graphs = { ...CONFIG.graphs, ...actual.graphs };
273
+ if (actual.averaging) CONFIG.averages = { ...CONFIG.averages, ...actual.averaging };
274
+ if (actual.scales) { for (let key in actual.scales) { CONFIG.scales[key] = { ...CONFIG.scales[key], ...actual.scales[key] }; } }
275
+ }
276
+ }
277
+ } catch (e) { }
278
+ }
273
279
  }
274
280
 
275
- // ==========================================================================
276
- // 7. FUNZIONI GRAFICHE E DISEGNO (BIANCO -> ARANCIO -> ROSSO)
277
- // ==========================================================================
278
- 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)}°`; }
279
-
280
281
  function manageHistory(t, v) {
281
- const n = Date.now();
282
- const interval = simulationMode ? SIM_SAMPLE_INTERVAL : (CONFIG.graphs.historyMinutes * 60000) / CONFIG.graphs.samples;
282
+ const n = Date.now(); const interval = simulationMode ? SIM_SAMPLE_INTERVAL : (CONFIG.graphs.historyMinutes * 60000) / CONFIG.graphs.samples;
283
283
  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
284
  const mode = graphModes[t], cfg = calculateScale(t, store.histories[t], mode);
285
- const box = document.getElementById(t + '-graph').closest('.data-box');
286
- if (mode === 'hercules') box.classList.add('box-hercules'); else box.classList.remove('box-hercules');
287
- updateScaleLabels(t, cfg.min, cfg.max);
288
- drawGraph(store.histories[t], t + '-graph', cfg.min, cfg.max, t === 'tws', mode === 'hercules');
285
+ updateScaleLabels(t, cfg.min, cfg.max); drawGraph(store.histories[t], t + '-graph', cfg.min, cfg.max, t === 'tws', mode === 'hercules');
289
286
  }
290
287
 
291
288
  function calculateScale(type, data, mode) {
292
- const s = CONFIG.scales[type] || { stdMax: 12, hercSpan: 4, step: 2 };
293
- let aMin = Math.min(...data), aMax = Math.max(...data);
294
- if (mode === 'hercules') {
295
- let avg = (aMin + aMax) / 2; let span = Math.max(s.hercSpan, Math.ceil(aMax - aMin)); if (span % 2 !== 0) span += 1;
296
- let min = Math.max(0, Math.floor(avg - (span / 2))); return { min, max: min + span };
297
- } else return { min: 0, max: Math.max(s.stdMax, Math.ceil(aMax / s.step) * s.step) };
289
+ const s = CONFIG.scales[type] || { stdMax: 12, hercSpan: 4, step: 2 }; let aMin = Math.min(...data), aMax = Math.max(...data);
290
+ if (mode === 'hercules') { let avg = (aMin + aMax) / 2; let span = Math.max(s.hercSpan, Math.ceil(aMax - aMin)); if (span % 2 !== 0) span += 1; let min = Math.max(0, Math.floor(avg - (span / 2))); return { min, max: min + span }; }
291
+ else return { min: 0, max: Math.max(s.stdMax, Math.ceil(aMax / s.step) * s.step) };
298
292
  }
299
293
 
300
- function updateScaleLabels(t, min, max) {
301
- const el = document.getElementById(t + '-scale'); if (!el) return;
302
- el.innerHTML = `<span>${Math.round(max)}</span><span>${Math.round((min+max)/2)}</span><span>${Math.round(min)}</span>`;
303
- }
294
+ function updateScaleLabels(t, min, max) { const el = document.getElementById(t + '-scale'); if (el) el.innerHTML = `<span>${Math.round(max)}</span><span>${Math.round((min+max)/2)}</span><span>${Math.round(min)}</span>`; }
304
295
 
305
296
  function drawGraph(d, id, min, max, isTws, isHercules) {
306
297
  const svg = document.getElementById(id); if (!svg || d.length < 2) return;
307
298
  const w = 200, h = 40, range = max - min || 1;
308
299
  let grids = ""; [0.25, 0.5, 0.75].forEach(p => { grids += `<line x1="0" y1="${h-(p*h)}" x2="${w}" y2="${h-(p*h)}" stroke="rgba(255,255,255,0.08)" stroke-width="0.5" />`; });
309
300
  for (let m = 1; m < CONFIG.graphs.historyMinutes; m++) { const x = w - (m / CONFIG.graphs.historyMinutes) * w; grids += `<line x1="${x}" y1="0" x2="${x}" y2="${h}" stroke="rgba(255,255,255,0.05)" stroke-width="0.5" />`; }
310
- let pD = "", cS = "";
301
+ let pD = ""; let cS = "";
311
302
  d.forEach((v, i) => {
312
303
  const x = (i/(CONFIG.graphs.samples-1))*w, y = h-(Math.max(0,Math.min(1,(v-min)/range))*h); pD += `${i===0?'M':'L'} ${x} ${y} `;
313
304
  if (isTws && i > 0) {
314
305
  const px = ((i-1)/(CONFIG.graphs.samples-1))*w, py = h-(Math.max(0,Math.min(1,(d[i-1]-min)/range))*h);
315
- let c = "#fff"; // Default: Bianco
316
- if (v >= CONFIG.graphs.reef2) c = "#e74c3c"; else if (v >= CONFIG.graphs.reef1) c = "#e67e22";
306
+ let c = (v >= CONFIG.graphs.reef2) ? "#e74c3c" : (v >= CONFIG.graphs.reef1 ? "#e67e22" : "#fff");
317
307
  cS += `<line x1="${px}" y1="${py}" x2="${x}" y2="${y}" stroke="${c}" class="${isHercules?'line-hercules':''}" />`;
318
308
  }
319
309
  });
320
- const aP = pD + ` L ${((d.length-1)/(CONFIG.graphs.samples-1))*w} ${h} L 0 ${h} Z`, clrs = { 'stw-graph': '#2ecc71', 'sog-graph': '#f39c12', 'depth-graph': '#3498db', 'tws-graph': '#ffffff' };
321
- svg.innerHTML = isTws ? `${grids}<path d="${aP}" fill="rgba(255,255,255,0.08)" stroke="none" />${cS}` : `${grids}<path d="${aP}" fill="${clrs[id]}22" stroke="none" /><path d="${pD}" class="${isHercules?'line-hercules':''}" fill="none" stroke="${clrs[id]}" />`;
310
+ 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]}" />`;
322
312
  }
323
313
 
324
314
  // ==========================================================================
325
- // 8. EVENTI E INTERAZIONI (SMART TOUCH MANAGER)
315
+ // 8. INTERAZIONI
326
316
  // ==========================================================================
327
317
  window.addEventListener('contextmenu', e => e.preventDefault(), true);
328
318
 
329
319
  function toggleFocusMode(type, element) {
330
- const container = document.querySelector('.main-container');
331
- const parentPanel = element.closest('.side-panel');
332
- const isLeft = parentPanel.classList.contains('left-panel');
320
+ const container = document.querySelector('.main-container'); const parentPanel = element.closest('.side-panel'); const isLeft = parentPanel.classList.contains('left-panel');
333
321
  isFocusActive = !isFocusActive;
334
- if (isFocusActive) {
335
- container.classList.add('focus-active');
336
- container.classList.add(isLeft ? 'focus-side-left' : 'focus-side-right');
337
- parentPanel.classList.add('has-focus');
338
- element.classList.add('is-focused');
339
- blockNextClick = true;
340
- } else {
341
- container.classList.remove('focus-active', 'focus-side-left', 'focus-side-right');
342
- document.querySelectorAll('.side-panel').forEach(p => p.classList.remove('has-focus'));
343
- document.querySelectorAll('.data-box').forEach(b => b.classList.remove('is-focused'));
344
- }
322
+ if (isFocusActive) { container.classList.add('focus-active', isLeft ? 'focus-side-left' : 'focus-side-right'); parentPanel.classList.add('has-focus'); element.classList.add('is-focused'); }
323
+ else { container.classList.remove('focus-active', 'focus-side-left', 'focus-side-right'); document.querySelectorAll('.side-panel').forEach(p => p.classList.remove('has-focus')); document.querySelectorAll('.data-box').forEach(b => b.classList.remove('is-focused')); }
345
324
  }
346
325
 
347
326
  ['stw', 'sog', 'tws', 'depth'].forEach(type => {
348
- const el = document.getElementById(type + '-graph').closest('.data-box');
349
- let lastTapTime = 0, tapTimeout;
350
-
351
- const handleInteraction = (e) => {
352
- if (e.cancelable) e.preventDefault();
353
- const currentTime = new Date().getTime();
354
- const tapDelay = currentTime - lastTapTime;
355
-
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;
356
330
  pressTimer = setTimeout(() => { if (!isFocusActive) { toggleFocusMode(type, el); lastTapTime = 0; } }, 1000);
357
-
358
- if (tapDelay < 300 && tapDelay > 0) {
359
- clearTimeout(tapTimeout);
360
- if (!isFocusActive) {
361
- graphModes[type] = graphModes[type] === 'standard' ? 'hercules' : 'standard';
362
- localStorage.setItem('mode_' + type, graphModes[type]);
363
- el.style.backgroundColor = "rgba(255,255,255,0.15)"; setTimeout(() => el.style.backgroundColor = "", 200);
364
- }
365
- lastTapTime = 0;
366
- } else {
367
- lastTapTime = currentTime;
368
- tapTimeout = setTimeout(() => { if (isFocusActive && el.classList.contains('is-focused')) toggleFocusMode(type, el); }, 350);
369
- }
370
- };
371
- el.addEventListener('pointerdown', handleInteraction);
372
- el.addEventListener('pointerup', () => clearTimeout(pressTimer));
373
- el.addEventListener('pointerleave', () => clearTimeout(pressTimer));
331
+ 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); }
333
+ });
334
+ el.addEventListener('pointerup', () => clearTimeout(pressTimer)); el.addEventListener('pointerleave', () => clearTimeout(pressTimer));
374
335
  });
375
336
 
376
- // ==========================================================================
377
- // GESTIONE HOTSPOT: Click (Fullscreen) e Long Press (Night Mode)
378
- // ==========================================================================
379
337
  if (ui.hotspot) {
380
- let pressTimer;
381
- const HOLD_DURATION = 1000; // 1 secondo di pressione per attivare Night Mode
382
-
383
- ui.hotspot.addEventListener('pointerdown', (e) => {
384
- // Avvia il timer al tocco
385
- pressTimer = setTimeout(() => {
386
- document.body.classList.toggle('night-mode');
387
- // Feedback visivo immediato al cambio modalità
388
- ui.hotspot.style.opacity = "0.5";
389
- setTimeout(() => ui.hotspot.style.opacity = "1", 200);
390
- pressTimer = null; // Reset per evitare che il click scatti dopo
391
- }, HOLD_DURATION);
392
- });
393
-
394
- ui.hotspot.addEventListener('pointerup', (e) => {
395
- if (pressTimer) {
396
- clearTimeout(pressTimer);
397
- pressTimer = null;
398
- // Se arriviamo qui, è stato un click rapido -> Fullscreen
399
- const doc = document.documentElement;
400
- const isF = document.fullscreenElement || document.webkitFullscreenElement;
401
- if (!isF) {
402
- if (doc.requestFullscreen) doc.requestFullscreen();
403
- else if (doc.webkitRequestFullscreen) doc.webkitRequestFullscreen();
404
- } else {
405
- if (document.exitFullscreen) document.exitFullscreen();
406
- else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
407
- }
408
- }
409
- });
410
-
411
- ui.hotspot.addEventListener('pointerleave', () => {
412
- if (pressTimer) {
413
- clearTimeout(pressTimer);
414
- pressTimer = null;
415
- }
416
- });
338
+ ui.hotspot.addEventListener('pointerdown', (e) => { pressTimer = setTimeout(() => { document.body.classList.toggle('night-mode'); ui.hotspot.style.opacity = "0.5"; setTimeout(() => ui.hotspot.style.opacity = "1", 200); pressTimer = null; }, 1000); });
339
+ ui.hotspot.addEventListener('pointerup', (e) => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; const doc = document.documentElement, isF = document.fullscreenElement || document.webkitFullscreenElement; if (!isF) { if (doc.requestFullscreen) doc.requestFullscreen(); else if (doc.webkitRequestFullscreen) doc.webkitRequestFullscreen(); } else { if (document.exitFullscreen) document.exitFullscreen(); else if (document.webkitExitFullscreen) document.webkitExitFullscreen(); } } });
340
+ ui.hotspot.addEventListener('pointerleave', () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = null; } });
417
341
  }
418
- (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); } } })();
419
-
420
- async function init() { await fetchServerConfig(); startDisplayLoop(); connect(); }
421
- window.addEventListener('load', init);
422
- 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'); }
423
- function playBingBing() { if (!audioCtx) return; 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); }
424
-
425
- // Simulatore su Depth (3 click rapidi)
426
- ui.depth.closest('.data-box').addEventListener('click', (function() {
427
- let dC = 0, lC = 0;
428
- return function() {
429
- const n = Date.now();
430
- if (n - lC < 500) dC++; else dC = 1;
431
- lC = n;
432
- if (dC === 3) {
433
- simulationMode = !simulationMode;
434
- if (simulationMode) {
435
- if (socket) socket.close();
436
- startDynamicSimulation(); // Chiama la nuova funzione
437
- } else {
438
- location.reload();
439
- }
440
- dC = 0;
441
- }
442
- };
443
- })());
444
342
 
445
343
  // ==========================================================================
446
- // 9. MOTORE SIMULAZIONE DINAMICA (VERSIONE STOCASTICA)
344
+ // 9. CONNESSIONE E SIMULATORE
447
345
  // ==========================================================================
448
- function startDynamicSimulation() {
449
- ui.status.innerText = "SIM ATTIVO";
346
+ function connect() {
347
+ if (simulationMode) return;
348
+ let addr = (window.location.protocol.includes("http")) ? window.location.host : CONFIG.server.fallbackIp;
349
+ try {
350
+ socket = new WebSocket(`ws://${addr}/signalk/v1/stream?subscribe=self`);
351
+ socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; };
352
+ 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); }
355
+ }
450
356
 
451
- // 1. STATO INIZIALE (Aggiunto leeway a 0)
452
- let sim = {
453
- hdg: Math.random() * 360,
454
- tws: 12,
455
- twd: Math.random() * 360,
456
- depth: 12,
457
- stw: 5,
458
- leeway: 0, // Inerzia per il Leeway
459
- startTime: Date.now()
460
- };
357
+ 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; } }; })());
461
358
 
359
+ function startDynamicSimulation() {
360
+ ui.status.innerText = "SIM ATTIVO";
361
+ let sim = { hdg: 45, tws: 12, twd: 45, depth: 12, stw: 5, leeway: 0, startTime: Date.now() };
462
362
  simInterval = setInterval(() => {
463
363
  const elapsed = (Date.now() - sim.startTime) / 1000;
464
-
465
- // 2. SALTO DI VENTO (dopo 120 secondi)
466
- if (elapsed > 120 && elapsed < 121) {
467
- sim.twd = Math.random() * 360;
468
- }
469
-
470
- // 3. RAFFICA (ogni 40s per 5s) con smoothing 0.05
471
- const inGust = (elapsed % 40) < 5;
472
- const targetTws = (inGust ? 18 : 10) + Math.sin(elapsed / 10) * 2;
473
- sim.tws += (targetTws - sim.tws) * 0.05; // Smoothing lento
474
-
475
- // 4. POLARE SEMPLIFICATA (STW in base al TWA)
476
- let twaRel = (sim.twd - sim.hdg + 360) % 360;
477
- if (twaRel > 180) twaRel -= 360;
478
-
479
- let targetStw = 3 + (4 * Math.sin((Math.abs(twaRel) - 45) * Math.PI / 125));
480
- sim.stw += (Math.max(3, Math.min(8, targetStw)) - sim.stw) * 0.05; // Smoothing STW
481
-
482
- // 5. CALCOLO LEEWAY CON FILTRO (Inerzia)
483
- const rawLeeway = Math.sin(degToRad(twaRel)) * 4;
484
- sim.leeway += (rawLeeway - sim.leeway) * 0.05; // Smoothing 0.05 per evitare i balli di +/- 3.1
485
-
486
- // 6. CALCOLO VETTORIALE COG
487
- const cog = (sim.hdg - sim.leeway + 360) % 360;
488
-
489
- // 7. CALCOLO VENTO APPARENTE (AWS/AWA)
490
- const twaRad = degToRad(twaRel);
364
+ if (elapsed > 120 && elapsed < 121) sim.twd = Math.random() * 360;
365
+ sim.twd = (sim.twd + (Math.sin(elapsed / 20) * 0.5) + 360) % 360;
366
+ const inGust = (elapsed % 40) < 5; const targetTws = (inGust ? 18 : 10) + Math.sin(elapsed / 10) * 2;
367
+ sim.tws += (targetTws - sim.tws) * 0.05; sim.hdg = (sim.hdg + (Math.random() - 0.5) * 1 + 360) % 360;
368
+ let twaRel = (sim.twd - sim.hdg + 360) % 360; if (twaRel > 180) twaRel -= 360;
369
+ 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
+ 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);
491
372
  const aws = Math.sqrt(Math.pow(sim.stw, 2) + Math.pow(sim.tws, 2) + 2 * sim.stw * sim.tws * Math.cos(twaRad));
492
- const awa = radToDeg(Math.atan2(sim.tws * Math.sin(twaRad), sim.stw + sim.tws * Math.cos(twaRad)));
493
-
494
- // 8. INVIO DATI PULITI
495
- processIncomingData("environment.wind.speedTrue", ktsToMs(sim.tws));
496
- processIncomingData("environment.wind.directionTrue", degToRad(sim.twd));
497
- processIncomingData("environment.wind.angleTrueWater", degToRad(twaRel));
498
- processIncomingData("environment.wind.speedApparent", ktsToMs(aws));
499
- processIncomingData("environment.wind.angleApparent", degToRad(awa));
500
- processIncomingData("environment.depth.belowTransducer", sim.depth);
501
- processIncomingData("navigation.headingTrue", degToRad(sim.hdg));
502
- processIncomingData("navigation.speedThroughWater", ktsToMs(sim.stw));
503
- processIncomingData("navigation.speedOverGround", ktsToMs(sim.stw));
504
- processIncomingData("navigation.courseOverGroundTrue", degToRad(cog));
505
-
506
- // Aggiorna display grafico
507
- updateLeewayDisplay(sim.leeway);
373
+ const awa = Math.atan2(sim.tws * Math.sin(twaRad), sim.stw + sim.tws * Math.cos(twaRad));
374
+ processIncomingData("environment.wind.speedApparent", ktsToMs(aws)); processIncomingData("environment.wind.angleApparent", awa);
375
+ 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));
377
+ processIncomingData("navigation.courseOverGroundTrue", degToRad(cog)); processIncomingData("navigation.position", { latitude: 45.0, longitude: 12.0 });
508
378
  }, 1000);
509
379
  }
380
+
381
+ // ==========================================================================
382
+ // 10. INIT
383
+ // ==========================================================================
384
+ (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
+ async function init() { await fetchServerConfig(); startDisplayLoop(); connect(); }
386
+ window.addEventListener('load', init);
package/index.html CHANGED
@@ -179,8 +179,8 @@
179
179
  <g id="twa-pointer" transform="rotate(0, 200, 200)" opacity="0.85">
180
180
  <path d="M200,90 L206,98 L200,125 L194,98 Z" fill="#ffff00" stroke="#000" stroke-width="0.8" />
181
181
  <text x="200" y="104" fill="#000" font-size="8" font-weight="900" text-anchor="middle" font-family="Arial Black">T</text>
182
- <circle id="trend-gauge-cw" cx="215" cy="110" r="4" fill="#ff0000" opacity="0" />
183
- <circle id="trend-gauge-ccw" cx="185" cy="110" r="4" fill="#00ff00" opacity="0" />
182
+ <circle id="trend-gauge-cw" cx="215" cy="110" r="4" fill="#ffffff" opacity="0.3" />
183
+ <circle id="trend-gauge-ccw" cx="185" cy="110" r="4" fill="#ffffff" opacity="0.3" />
184
184
  </g>
185
185
 
186
186
  <!-- Barra LEEWAY / SCARROCCIO Inferiore -->
@@ -291,8 +291,8 @@
291
291
  stroke-width="2.2"
292
292
  stroke-linecap="round"
293
293
  stroke-linejoin="round" />
294
- <circle id="trend-dot-cw" cx="24" cy="7.5" r="1.5" fill="#ff0000" />
295
- <circle id="trend-dot-ccw" cx="16" cy="7.5" r="1.5" fill="#00ff00" />
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
296
  </g>
297
297
  </svg>
298
298
  <span class="value value-large" id="twd-avg">---&deg;</span>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sailingrotevista/rotevista-dash",
3
- "version": "2.0.14",
3
+ "version": "2.0.16",
4
4
  "description": "Public Wind Dashboard with navigation and course aids",
5
5
  "main": "index.js",
6
6
  "publishConfig": {
package/style.css CHANGED
@@ -30,19 +30,19 @@ body {
30
30
  I lati hanno un minimo vitale (180px) per proteggere i testi.
31
31
  Il centro (auto) si adatta millimetricamente al diametro dell'SVG.
32
32
  */
33
- grid-template-columns: minmax(180px, 1fr) minmax(auto, 3fr) minmax(180px, 1fr);
33
+ grid-template-columns: minmax(180px, 1fr) minmax(auto, 1.5fr) minmax(180px, 1fr);
34
34
  grid-template-rows: 100%;
35
35
  transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
36
36
  justify-content: stretch;
37
37
  }
38
38
 
39
- /* Espansione colonne per schermi molto larghi (es. 16:10 / 16:9) */
39
+ /* Espansione colonne per schermi molto larghi (es. 16:10 / 16:9)
40
40
  @media (min-aspect-ratio: 1.5) {
41
41
  .main-container {
42
- grid-template-columns: minmax(200px, 1fr) auto minmax(200px, 1fr);
42
+ grid-template-columns: minmax(200px, 1fr) minmax(auto, 3fr) minmax(200px, 1fr);
43
43
  gap: 15px;
44
44
  }
45
- }
45
+ }*/
46
46
 
47
47
  /* ==========================================================================
48
48
  2. PANNELLI E DATA-BOX
@@ -478,3 +478,10 @@ body.night-mode {
478
478
  .is-trending {
479
479
  animation: blink-trend 1s infinite !important;
480
480
  }
481
+
482
+ /* Allarme Strambata: Rosso fisso con bagliore neon */
483
+ .is-gybing {
484
+ opacity: 1 !important;
485
+ animation: none !important; /* Rimuove il lampeggio */
486
+ filter: drop-shadow(0 0 8px #ff0000) !important;
487
+ }