@sailingrotevista/rotevista-dash 2.0.15 → 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 +215 -355
  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;
@@ -42,9 +21,13 @@ let socket, renderInterval, simInterval;
42
21
  let lastAvgUIUpdate = 0, audioCtx = null, lastAlarmTime = 0;
43
22
  let curAwaRot = 0, curTwaRot = 0, curTrackRot = 0, curTwdRoseRot = 0;
44
23
 
45
- // Variabili di stato per filtri e trend
24
+ // Variabili di stato per filtri e logica tattica
46
25
  let smoothedLeeway = 0;
47
26
  let rotationTrend = 0;
27
+ let lastShortAvgVal = null;
28
+ let lastInstantTwa = null;
29
+ let isNavigating = false;
30
+
48
31
  let pressTimer, isFocusActive = false, blockNextClick = false;
49
32
 
50
33
  const graphModes = {
@@ -78,34 +61,7 @@ const ui = {
78
61
  };
79
62
 
80
63
  // ==========================================================================
81
- // 3. CARICAMENTO CONFIGURAZIONE DAL SERVER
82
- // ==========================================================================
83
- async function fetchServerConfig() {
84
- if (!window.location.protocol.includes("http")) return;
85
- const pluginID = 'rotevista-dash';
86
- const possibleUrls = [`/skServer/plugins/${pluginID}/config`, `/plugins/${pluginID}/config` ];
87
- for (let url of possibleUrls) {
88
- try {
89
- const response = await fetch(url);
90
- if (response.ok) {
91
- const data = await response.json();
92
- const actual = data.configuration || data;
93
- if (actual && typeof actual === 'object') {
94
- 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]); } };
95
- parseNumbers(actual);
96
- if (actual.alarms) CONFIG.alarms = { ...CONFIG.alarms, ...actual.alarms };
97
- if (actual.graphs) CONFIG.graphs = { ...CONFIG.graphs, ...actual.graphs };
98
- if (actual.averaging) CONFIG.averages = { ...CONFIG.averages, ...actual.averaging };
99
- if (actual.scales) { for (let key in actual.scales) { CONFIG.scales[key] = { ...CONFIG.scales[key], ...actual.scales[key] }; } }
100
- console.log("SUCCESS: Config loaded."); return;
101
- }
102
- }
103
- } catch (e) { }
104
- }
105
- }
106
-
107
- // ==========================================================================
108
- // 4. MATEMATICA E GESTIONE DATI
64
+ // 3. MATEMATICA E UTILS
109
65
  // ==========================================================================
110
66
  function radToDeg(rad) { return rad * (180 / Math.PI); }
111
67
  function degToRad(deg) { return deg * (Math.PI / 180); }
@@ -118,110 +74,156 @@ function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false) {
118
74
  if (validData.length === 0) return null;
119
75
  let sSin = 0, sCos = 0; validData.forEach(item => { sSin += Math.sin(item.val); sCos += Math.cos(item.val); });
120
76
  let R = Math.sqrt(sSin * sSin + sCos * sCos) / validData.length;
121
- 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);
122
78
  let avgDeg = Math.round(radToDeg(Math.atan2(sSin, sCos)));
123
79
  return { val: signed ? avgDeg : (avgDeg + 360) % 360, stable: isStable };
124
80
  }
125
81
 
126
- function processIncomingData(path, val) {
127
- const now = Date.now(); store.timestamps[path] = now; store.raw[path] = val;
128
- if (path === "navigation.headingTrue") { store.smoothBuf.hdg.push({ val: val, time: now }); store.longBuf.hdg.push({ val: val, time: now }); }
129
- if (path === "navigation.courseOverGroundTrue") { store.smoothBuf.cog.push({ val: val, time: now }); store.longBuf.cog.push({ val: val, time: now }); }
130
- if (path === "environment.wind.angleApparent") { store.smoothBuf.awa.push({ val: val, time: now }); store.longBuf.awa.push({ val: val, time: now }); }
131
- if (path === "environment.wind.angleTrueWater") { store.smoothBuf.twa.push({ val: val, time: now }); store.longBuf.twa.push({ val: val, time: now }); }
132
- if (path === "navigation.headingTrue" || path === "environment.wind.angleTrueWater" || path === "environment.wind.directionTrue") {
133
- let twdRad = 0; if (path === "environment.wind.directionTrue") twdRad = val;
134
- else if (store.raw["navigation.headingTrue"] !== undefined && store.raw["environment.wind.angleTrueWater"] !== undefined) {
135
- twdRad = (store.raw["navigation.headingTrue"] + store.raw["environment.wind.angleTrueWater"]) % (2 * Math.PI);
136
- if (twdRad < 0) twdRad += (2 * Math.PI);
137
- } else return;
138
- store.smoothBuf.twd.push({ val: twdRad, time: now }); store.longBuf.twd.push({ val: twdRad, time: now });
139
- }
140
- }
141
-
142
82
  // ==========================================================================
143
- // 5. MOTORE RENDERING PRINCIPALE E TREND
83
+ // 4. LOGICA FISICA (VENTO E SICUREZZA)
144
84
  // ==========================================================================
145
85
 
146
- // Calcolo Trend del Vento (Definita prima del loop per evitare ReferenceError)
147
- function updateWindTrend() {
148
- const longAvg = getCircularAverageFromBuffer(store.longBuf.twd, 60000, false);
149
- const shortAvg = getCircularAverageFromBuffer(store.longBuf.twd, 15000, false);
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
+ }
150
92
 
151
- if (!longAvg || !shortAvg) return;
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
+ }
152
99
 
153
- let diff = (shortAvg.val - longAvg.val + 540) % 360 - 180;
154
-
155
- // Smoothing del trend per evitare scatti
156
- rotationTrend = (rotationTrend * 0.95) + (diff * 0.05);
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
+ }
157
133
 
158
- // Seleziona sia i pallini della bussola che del gauge centrale
159
- const cwDots = [document.getElementById('trend-dot-cw'), document.getElementById('trend-gauge-cw')];
160
- const ccwDots = [document.getElementById('trend-dot-ccw'), document.getElementById('trend-gauge-ccw')];
161
-
162
- // Soglia: il vento deve ruotare di almeno 1.5 gradi di media per attivarsi
163
- if (Math.abs(rotationTrend) > 1.5) {
164
- if (rotationTrend > 0) { // Orario
165
- cwDots.forEach(el => el && el.classList.add('is-trending'));
166
- ccwDots.forEach(el => el && el.classList.remove('is-trending'));
167
- } else { // Antiorario
168
- ccwDots.forEach(el => el && el.classList.add('is-trending'));
169
- cwDots.forEach(el => el && el.classList.remove('is-trending'));
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'); }});
170
167
  }
171
168
  } else {
172
- // Vento stabile, spegni tutti i pallini
173
- cwDots.forEach(el => el && el.classList.remove('is-trending'));
174
- ccwDots.forEach(el => el && el.classList.remove('is-trending'));
169
+ [compassDots.cw, compassDots.ccw, gaugeDots.cw, gaugeDots.ccw].forEach(el => { if (el) { el.classList.remove('is-trending', 'is-gybing'); el.setAttribute('fill', '#ffffff'); }});
175
170
  }
176
171
  }
177
172
 
173
+ // ==========================================================================
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
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
+
178
190
  function startDisplayLoop() {
179
191
  renderInterval = setInterval(() => {
180
192
  const now = Date.now();
181
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 };
182
- 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]; } }
183
-
184
- if (store.raw["navigation.speedThroughWater"] !== undefined) { const v = msToKts(store.raw["navigation.speedThroughWater"]); ui.stw.innerText = v.toFixed(1); manageHistory('stw', v); }
185
- 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
+ }
186
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); }
187
-
188
- // --- 1. RENDERING TWS CON COLORE DINAMICO ---
189
208
  if (store.raw["environment.wind.speedTrue"] !== undefined) {
190
- const twsKts = msToKts(store.raw["environment.wind.speedTrue"]);
191
- ui.tws.innerText = twsKts.toFixed(1);
192
- let twsColor = "#fff"; // Default: Bianco
193
- if (twsKts >= CONFIG.graphs.reef2) twsColor = "#e74c3c"; // Rosso
194
- else if (twsKts >= CONFIG.graphs.reef1) twsColor = "#e67e22"; // Arancio
195
- 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");
196
211
  manageHistory('tws', twsKts);
197
212
  }
198
213
  if (store.raw["environment.wind.speedApparent"] !== undefined) ui.awsSvg.textContent = msToKts(store.raw["environment.wind.speedApparent"]).toFixed(1);
199
214
 
200
- // Render Quadrante
201
215
  const smAwa = getCircularAverageFromBuffer(store.smoothBuf.awa, CONFIG.averages.smoothWindow, true);
202
216
  if (smAwa) { curAwaRot = getShortestRotation(curAwaRot, smAwa.val); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
203
217
  const smTwa = getCircularAverageFromBuffer(store.smoothBuf.twa, CONFIG.averages.smoothWindow, true);
204
218
  if (smTwa) { curTwaRot = getShortestRotation(curTwaRot, smTwa.val); ui.twa.setAttribute('transform', `rotate(${curTwaRot}, 200, 200)`); }
205
219
 
206
- // --- CALCOLO E SMUSSAMENTO LEEWAY ---
207
220
  if (store.raw["navigation.courseOverGroundTrue"] !== undefined && store.raw["navigation.headingTrue"] !== undefined) {
208
- let rawDrift = (radToDeg(store.raw["navigation.courseOverGroundTrue"]) - radToDeg(store.raw["navigation.headingTrue"]) + 360) % 360;
209
- if (rawDrift > 180) rawDrift -= 360;
210
-
211
- if (curSog < CONFIG.averages.minSpeed) {
212
- smoothedLeeway = 0;
213
- } else {
214
- smoothedLeeway = (smoothedLeeway * 0.9) + (rawDrift * 0.1);
215
- }
216
-
217
- curTrackRot = getShortestRotation(curTrackRot, smoothedLeeway);
218
- ui.track.setAttribute('transform', `rotate(${curTrackRot}, 200, 200)`);
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)`);
219
224
  updateLeewayDisplay(Math.max(-20, Math.min(20, smoothedLeeway)));
220
- } else {
221
- updateLeewayDisplay(0);
222
- }
225
+ } else updateLeewayDisplay(0);
223
226
 
224
- // --- 2. RENDERING MEDIE E BUSSOLA TATTICA ---
225
227
  if (now - lastAvgUIUpdate > 3000) {
226
228
  let hObj = getCircularAverageFromBuffer(store.longBuf.hdg, CONFIG.averages.longWindow, false),
227
229
  cObj = getCircularAverageFromBuffer(store.longBuf.cog, CONFIG.averages.longWindow, false),
@@ -230,51 +232,19 @@ function startDisplayLoop() {
230
232
  twdObj = getCircularAverageFromBuffer(store.longBuf.twd, CONFIG.averages.longWindow, false);
231
233
 
232
234
  const upUI = (el, obj, isCompass = false) => {
233
- if (!obj || obj.val === null) {
234
- el.innerHTML = "---&deg;";
235
- el.classList.remove('unstable-data');
236
- } else {
237
- let val = Math.round(obj.val);
238
- let displayVal;
239
-
240
- if (isCompass) displayVal = ((val + 360) % 360).toString().padStart(3, '0');
241
- else displayVal = val.toString();
242
-
243
- el.innerHTML = `${displayVal}&deg;`;
244
-
245
- if (obj.stable || curSog < CONFIG.averages.minSpeed) el.classList.remove('unstable-data');
246
- 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');
247
239
  }
248
240
  };
249
-
250
- upUI(ui.hdg, hObj, true);
251
- upUI(ui.cog, cObj, true);
252
- upUI(ui.awaAvg, awObj, false);
253
- upUI(ui.twaAvg, twObj, false);
254
- upUI(ui.twdAvg, twdObj, true);
255
-
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);
256
242
  if (hObj && twObj && hObj.val !== null) {
257
- 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;`;
258
245
  if (cObj) ui.tackCog.innerHTML = `${Math.round((cObj.val - tA + 360) % 360).toString().padStart(3, '0')}&deg;`;
259
- let tStable = (hObj.stable && twObj.stable) || (curSog < CONFIG.averages.minSpeed);
260
- if (tStable) { ui.tackHdg.classList.remove('unstable-data'); ui.tackCog.classList.remove('unstable-data'); }
261
- else { ui.tackHdg.classList.add('unstable-data'); ui.tackCog.classList.add('unstable-data'); }
262
- }
263
-
264
- // Rotazione Bussola Tattica e Colore Sincronizzato
265
- if (twdObj && hObj) {
266
- updateWindTrend(); // Aggiorna i pallini
267
-
268
- curTwdRoseRot = getShortestRotation(curTwdRoseRot, twdObj.val);
269
- ui.twdArrow.setAttribute('transform', `rotate(${curTwdRoseRot}, 20, 20)`);
270
- ui.twdBoat.setAttribute('transform', `rotate(${hObj.val}, 20, 20)`);
271
-
272
- let currentTwsKts = store.raw["environment.wind.speedTrue"] ? msToKts(store.raw["environment.wind.speedTrue"]) : 0;
273
- let reefColor = "#fff";
274
- if (currentTwsKts >= CONFIG.graphs.reef2) reefColor = "#e74c3c";
275
- else if (currentTwsKts >= CONFIG.graphs.reef1) reefColor = "#e67e22";
276
- if (ui.twdChevron) ui.twdChevron.setAttribute('stroke', reefColor);
277
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)`); }
278
248
  lastAvgUIUpdate = now;
279
249
  }
280
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(); }
@@ -283,244 +253,134 @@ function startDisplayLoop() {
283
253
  }
284
254
 
285
255
  // ==========================================================================
286
- // 6. CONNESSIONE SIGNALK
256
+ // 7. CONFIG E GRAFICI
287
257
  // ==========================================================================
288
- function connect() {
289
- if (simulationMode) return;
290
- let addr = (window.location.protocol.includes("http")) ? window.location.host : CONFIG.server.fallbackIp;
291
- try {
292
- socket = new WebSocket(`ws://${addr}/signalk/v1/stream?subscribe=self`);
293
- socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; };
294
- 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))); };
295
- socket.onclose = () => !simulationMode && setTimeout(connect, 5000);
296
- } 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
+ }
297
279
  }
298
280
 
299
- // ==========================================================================
300
- // 7. FUNZIONI GRAFICHE E DISEGNO (BIANCO -> ARANCIO -> ROSSO)
301
- // ==========================================================================
302
- 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)}°`; }
303
-
304
281
  function manageHistory(t, v) {
305
- const n = Date.now();
306
- 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;
307
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; }
308
284
  const mode = graphModes[t], cfg = calculateScale(t, store.histories[t], mode);
309
- const box = document.getElementById(t + '-graph').closest('.data-box');
310
- if (mode === 'hercules') box.classList.add('box-hercules'); else box.classList.remove('box-hercules');
311
- updateScaleLabels(t, cfg.min, cfg.max);
312
- 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');
313
286
  }
314
287
 
315
288
  function calculateScale(type, data, mode) {
316
- const s = CONFIG.scales[type] || { stdMax: 12, hercSpan: 4, step: 2 };
317
- let aMin = Math.min(...data), aMax = Math.max(...data);
318
- if (mode === 'hercules') {
319
- let avg = (aMin + aMax) / 2; let span = Math.max(s.hercSpan, Math.ceil(aMax - aMin)); if (span % 2 !== 0) span += 1;
320
- let min = Math.max(0, Math.floor(avg - (span / 2))); return { min, max: min + span };
321
- } 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) };
322
292
  }
323
293
 
324
- function updateScaleLabels(t, min, max) {
325
- const el = document.getElementById(t + '-scale'); if (!el) return;
326
- el.innerHTML = `<span>${Math.round(max)}</span><span>${Math.round((min+max)/2)}</span><span>${Math.round(min)}</span>`;
327
- }
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>`; }
328
295
 
329
296
  function drawGraph(d, id, min, max, isTws, isHercules) {
330
297
  const svg = document.getElementById(id); if (!svg || d.length < 2) return;
331
298
  const w = 200, h = 40, range = max - min || 1;
332
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" />`; });
333
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" />`; }
334
- let pD = "", cS = "";
301
+ let pD = ""; let cS = "";
335
302
  d.forEach((v, i) => {
336
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} `;
337
304
  if (isTws && i > 0) {
338
305
  const px = ((i-1)/(CONFIG.graphs.samples-1))*w, py = h-(Math.max(0,Math.min(1,(d[i-1]-min)/range))*h);
339
- let c = "#fff"; // Default: Bianco
340
- 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");
341
307
  cS += `<line x1="${px}" y1="${py}" x2="${x}" y2="${y}" stroke="${c}" class="${isHercules?'line-hercules':''}" />`;
342
308
  }
343
309
  });
344
- 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' };
345
- 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]}" />`;
346
312
  }
347
313
 
348
314
  // ==========================================================================
349
- // 8. EVENTI E INTERAZIONI (SMART TOUCH MANAGER)
315
+ // 8. INTERAZIONI
350
316
  // ==========================================================================
351
317
  window.addEventListener('contextmenu', e => e.preventDefault(), true);
352
318
 
353
319
  function toggleFocusMode(type, element) {
354
- const container = document.querySelector('.main-container');
355
- const parentPanel = element.closest('.side-panel');
356
- 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');
357
321
  isFocusActive = !isFocusActive;
358
- if (isFocusActive) {
359
- container.classList.add('focus-active');
360
- container.classList.add(isLeft ? 'focus-side-left' : 'focus-side-right');
361
- parentPanel.classList.add('has-focus');
362
- element.classList.add('is-focused');
363
- blockNextClick = true;
364
- } else {
365
- container.classList.remove('focus-active', 'focus-side-left', 'focus-side-right');
366
- document.querySelectorAll('.side-panel').forEach(p => p.classList.remove('has-focus'));
367
- document.querySelectorAll('.data-box').forEach(b => b.classList.remove('is-focused'));
368
- }
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')); }
369
324
  }
370
325
 
371
326
  ['stw', 'sog', 'tws', 'depth'].forEach(type => {
372
- const el = document.getElementById(type + '-graph').closest('.data-box');
373
- let lastTapTime = 0, tapTimeout;
374
-
375
- const handleInteraction = (e) => {
376
- if (e.cancelable) e.preventDefault();
377
- const currentTime = new Date().getTime();
378
- const tapDelay = currentTime - lastTapTime;
379
-
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;
380
330
  pressTimer = setTimeout(() => { if (!isFocusActive) { toggleFocusMode(type, el); lastTapTime = 0; } }, 1000);
381
-
382
- if (tapDelay < 300 && tapDelay > 0) {
383
- clearTimeout(tapTimeout);
384
- if (!isFocusActive) {
385
- graphModes[type] = graphModes[type] === 'standard' ? 'hercules' : 'standard';
386
- localStorage.setItem('mode_' + type, graphModes[type]);
387
- el.style.backgroundColor = "rgba(255,255,255,0.15)"; setTimeout(() => el.style.backgroundColor = "", 200);
388
- }
389
- lastTapTime = 0;
390
- } else {
391
- lastTapTime = currentTime;
392
- tapTimeout = setTimeout(() => { if (isFocusActive && el.classList.contains('is-focused')) toggleFocusMode(type, el); }, 350);
393
- }
394
- };
395
- el.addEventListener('pointerdown', handleInteraction);
396
- el.addEventListener('pointerup', () => clearTimeout(pressTimer));
397
- 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));
398
335
  });
399
336
 
400
- // ==========================================================================
401
- // GESTIONE HOTSPOT: Click (Fullscreen) e Long Press (Night Mode)
402
- // ==========================================================================
403
337
  if (ui.hotspot) {
404
- let pressTimer;
405
- const HOLD_DURATION = 1000;
406
-
407
- ui.hotspot.addEventListener('pointerdown', (e) => {
408
- pressTimer = setTimeout(() => {
409
- document.body.classList.toggle('night-mode');
410
- ui.hotspot.style.opacity = "0.5";
411
- setTimeout(() => ui.hotspot.style.opacity = "1", 200);
412
- pressTimer = null;
413
- }, HOLD_DURATION);
414
- });
415
-
416
- ui.hotspot.addEventListener('pointerup', (e) => {
417
- if (pressTimer) {
418
- clearTimeout(pressTimer);
419
- pressTimer = null;
420
- const doc = document.documentElement;
421
- const isF = document.fullscreenElement || document.webkitFullscreenElement;
422
- if (!isF) {
423
- if (doc.requestFullscreen) doc.requestFullscreen();
424
- else if (doc.webkitRequestFullscreen) doc.webkitRequestFullscreen();
425
- } else {
426
- if (document.exitFullscreen) document.exitFullscreen();
427
- else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
428
- }
429
- }
430
- });
431
-
432
- ui.hotspot.addEventListener('pointerleave', () => {
433
- if (pressTimer) {
434
- clearTimeout(pressTimer);
435
- pressTimer = null;
436
- }
437
- });
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; } });
438
341
  }
439
- (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); } } })();
440
-
441
- async function init() { await fetchServerConfig(); startDisplayLoop(); connect(); }
442
- window.addEventListener('load', init);
443
- 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'); }
444
- 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); }
445
342
 
446
343
  // ==========================================================================
447
- // 9. MOTORE SIMULAZIONE DINAMICA E AVVIO (3 Click su Depth)
344
+ // 9. CONNESSIONE E SIMULATORE
448
345
  // ==========================================================================
449
- ui.depth.closest('.data-box').addEventListener('click', (function() {
450
- let dC = 0, lC = 0;
451
- return function() {
452
- const n = Date.now();
453
- if (n - lC < 500) dC++; else dC = 1;
454
- lC = n;
455
- if (dC === 3) {
456
- simulationMode = !simulationMode;
457
- if (simulationMode) {
458
- if (socket) socket.close();
459
- startDynamicSimulation();
460
- } else {
461
- location.reload();
462
- }
463
- dC = 0;
464
- }
465
- };
466
- })());
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
+ }
356
+
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; } }; })());
467
358
 
468
359
  function startDynamicSimulation() {
469
360
  ui.status.innerText = "SIM ATTIVO";
470
-
471
- let sim = {
472
- hdg: Math.random() * 360,
473
- tws: 12,
474
- twd: Math.random() * 360,
475
- depth: 12,
476
- stw: 5,
477
- leeway: 0,
478
- startTime: Date.now()
479
- };
480
-
361
+ let sim = { hdg: 45, tws: 12, twd: 45, depth: 12, stw: 5, leeway: 0, startTime: Date.now() };
481
362
  simInterval = setInterval(() => {
482
363
  const elapsed = (Date.now() - sim.startTime) / 1000;
483
-
484
- // Vento: Rotazione lenta + Salto netto a 120s
485
364
  if (elapsed > 120 && elapsed < 121) sim.twd = Math.random() * 360;
486
365
  sim.twd = (sim.twd + (Math.sin(elapsed / 20) * 0.5) + 360) % 360;
487
-
488
- // Raffica
489
- const inGust = (elapsed % 40) < 5;
490
- const targetTws = (inGust ? 18 : 10) + Math.sin(elapsed / 10) * 2;
491
- sim.tws += (targetTws - sim.tws) * 0.05;
492
-
493
- // Barca segue il vento pigramente
494
- sim.hdg = (sim.hdg + (Math.random() - 0.5) * 1 + 360) % 360;
495
-
496
- let twaRel = (sim.twd - sim.hdg + 360) % 360;
497
- if (twaRel > 180) twaRel -= 360;
498
-
499
- // Polare smussata
500
- let targetStw = 3 + (4 * Math.sin((Math.abs(twaRel) - 45) * Math.PI / 125));
501
- sim.stw += (Math.max(3, Math.min(8, targetStw)) - sim.stw) * 0.05;
502
-
503
- // Leeway smussato
504
- const rawLeeway = Math.sin(degToRad(twaRel)) * 4;
505
- sim.leeway += (rawLeeway - sim.leeway) * 0.05;
506
-
507
- // Vettori Reali
508
- const cog = (sim.hdg - sim.leeway + 360) % 360;
509
- const twaRad = degToRad(twaRel);
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);
510
372
  const aws = Math.sqrt(Math.pow(sim.stw, 2) + Math.pow(sim.tws, 2) + 2 * sim.stw * sim.tws * Math.cos(twaRad));
511
- const awa = radToDeg(Math.atan2(sim.tws * Math.sin(twaRad), sim.stw + sim.tws * Math.cos(twaRad)));
512
-
513
- // Invio Dati (Con tutto il set necessario per la UI)
514
- processIncomingData("environment.wind.speedTrue", ktsToMs(sim.tws));
515
- processIncomingData("environment.wind.directionTrue", degToRad(sim.twd));
516
- processIncomingData("environment.wind.angleTrueWater", degToRad(twaRel));
517
- processIncomingData("environment.wind.speedApparent", ktsToMs(aws));
518
- processIncomingData("environment.wind.angleApparent", degToRad(awa));
519
- processIncomingData("environment.depth.belowTransducer", sim.depth);
520
- processIncomingData("navigation.headingTrue", degToRad(sim.hdg));
521
- processIncomingData("navigation.speedThroughWater", ktsToMs(sim.stw));
522
- processIncomingData("navigation.speedOverGround", ktsToMs(sim.stw));
523
- processIncomingData("navigation.courseOverGroundTrue", degToRad(cog));
524
-
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 });
525
378
  }, 1000);
526
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.15",
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
+ }