@sailingrotevista/rotevista-dash 1.0.25 → 1.0.26

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 (3) hide show
  1. package/app.js +135 -65
  2. package/package.json +1 -1
  3. package/style.css +53 -102
package/app.js CHANGED
@@ -1,29 +1,51 @@
1
1
  // ==========================================================================
2
- // 1. CONFIGURAZIONE E STATO
2
+ // 1. CONFIGURAZIONE E DEFAULT (Sincronizzato con il Plugin SignalK)
3
3
  // ==========================================================================
4
4
  let CONFIG = {
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 },
5
+ alarms: {
6
+ depthDanger: 2.5,
7
+ depthWarning: 5.0
8
+ },
9
+ averages: {
10
+ smoothWindow: 2000, // Media veloce per le lancette (ms)
11
+ longWindow: 60000, // Media lunga per i dati MEAN (ms)
12
+ stabilityTolerance: 2000, // Tolleranza riempimento buffer (ms)
13
+ stabilityThreshold: 0.90, // Indice R minimo per dati stabili
14
+ minSpeed: 0.5 // Velocità sotto la quale non lampeggia nulla (kts)
15
+ },
16
+ graphs: {
17
+ reef1: 15.0, // Soglia arancio TWS
18
+ reef2: 20.0, // Soglia rossa TWS
19
+ historyMinutes: 5, // Durata temporale dei grafici (minuti)
20
+ samples: 60 // Numero di punti disegnati nel grafico
21
+ },
8
22
  scales: {
9
- stw: { stdMax: 12, hercSpan: 4, step: 2 },
10
- sog: { stdMax: 12, hercSpan: 4, step: 2 },
11
- tws: { stdMax: 25, hercSpan: 10, step: 5 },
23
+ stw: { stdMax: 12, hercSpan: 4, step: 2 },
24
+ sog: { stdMax: 12, hercSpan: 4, step: 2 },
25
+ tws: { stdMax: 25, hercSpan: 10, step: 5 },
12
26
  depth: { stdMax: 20, hercSpan: 10, step: 10 }
13
27
  },
14
- server: { fallbackIp: "192.168.111.240:3000" }
28
+ server: {
29
+ fallbackIp: "192.168.111.240:3000"
30
+ }
15
31
  };
16
32
 
17
33
  const RENDER_INTERVAL_MS = 1000;
18
34
  const TIMEOUT_MS = 5000;
19
35
  const SIM_SAMPLE_INTERVAL = 1000;
20
36
 
37
+ // ==========================================================================
38
+ // 2. STATO GLOBALE E RIFERIMENTI UI
39
+ // ==========================================================================
21
40
  let simulationMode = false;
22
41
  let socket, renderInterval, simInterval;
23
42
  let lastAvgUIUpdate = 0, audioCtx = null, lastAlarmTime = 0;
24
43
  let curAwaRot = 0, curTwaRot = 0, curTrackRot = 0, curTwdRoseRot = 0;
44
+
45
+ // Gestione Interazioni (Long Press, Focus, Ghost Clicks)
25
46
  let pressTimer, isFocusActive = false, blockNextClick = false;
26
47
 
48
+ // Modalità Scale (Standard/Hercules) salvate nel browser
27
49
  const graphModes = {
28
50
  stw: localStorage.getItem('mode_stw') || 'standard',
29
51
  sog: localStorage.getItem('mode_sog') || 'standard',
@@ -31,7 +53,13 @@ const graphModes = {
31
53
  depth: localStorage.getItem('mode_depth') || 'standard'
32
54
  };
33
55
 
34
- const store = { raw: {}, timestamps: {}, smoothBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] }, longBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] }, histories: { stw: [], sog: [], depth: [], tws: [] }, lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0 } };
56
+ const store = {
57
+ raw: {}, timestamps: {},
58
+ smoothBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
59
+ longBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
60
+ histories: { stw: [], sog: [], depth: [], tws: [] },
61
+ lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0 }
62
+ };
35
63
 
36
64
  const ui = {
37
65
  stw: document.getElementById('stw'), sog: document.getElementById('sog'),
@@ -47,13 +75,13 @@ const ui = {
47
75
  };
48
76
 
49
77
  // ==========================================================================
50
- // 3. LOGICA DI COMUNICAZIONE E CALCOLO
78
+ // 3. COMUNICAZIONE CON IL SERVER (FETCH CONFIG)
51
79
  // ==========================================================================
52
-
53
80
  async function fetchServerConfig() {
54
81
  if (!window.location.protocol.includes("http")) return;
55
82
  const pluginID = 'rotevista-dash';
56
83
  const possibleUrls = [`/skServer/plugins/${pluginID}/config`, `/plugins/${pluginID}/config` ];
84
+
57
85
  for (let url of possibleUrls) {
58
86
  try {
59
87
  const response = await fetch(url);
@@ -61,31 +89,48 @@ async function fetchServerConfig() {
61
89
  const data = await response.json();
62
90
  const actual = data.configuration || data;
63
91
  if (actual && typeof actual === 'object') {
64
- 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
+ // Normalizzazione dati (conversione stringhe -> numeri)
93
+ const parseNumbers = (obj) => {
94
+ for (let k in obj) {
95
+ if (typeof obj[k] === 'object') parseNumbers(obj[k]);
96
+ else if (!isNaN(obj[k]) && obj[k] !== "") obj[k] = parseFloat(obj[k]);
97
+ }
98
+ };
65
99
  parseNumbers(actual);
100
+ // Merge nel sistema locale
66
101
  if (actual.alarms) CONFIG.alarms = { ...CONFIG.alarms, ...actual.alarms };
67
102
  if (actual.graphs) CONFIG.graphs = { ...CONFIG.graphs, ...actual.graphs };
68
103
  if (actual.averaging) CONFIG.averages = { ...CONFIG.averages, ...actual.averaging };
69
- if (actual.scales) { for (let key in actual.scales) { CONFIG.scales[key] = { ...CONFIG.scales[key], ...actual.scales[key] }; } }
70
- console.log("Dashboard: Configurazione caricata."); return;
104
+ if (actual.scales) {
105
+ for (let key in actual.scales) { CONFIG.scales[key] = { ...CONFIG.scales[key], ...actual.scales[key] }; }
106
+ }
107
+ console.log("Dashboard: Configurazione caricata dal server.");
108
+ return;
71
109
  }
72
110
  }
73
111
  } catch (e) { }
74
112
  }
75
113
  }
76
114
 
115
+ // ==========================================================================
116
+ // 4. MATEMATICA E GESTIONE DATI
117
+ // ==========================================================================
77
118
  function radToDeg(rad) { return rad * (180 / Math.PI); }
78
119
  function degToRad(deg) { return deg * (Math.PI / 180); }
79
120
  function msToKts(ms) { return ms * 1.94384; }
80
121
  function ktsToMs(kts) { return kts / 1.94384; }
81
122
  function getShortestRotation(curr, target) { let diff = (target - curr) % 360; if (diff > 180) diff -= 360; else if (diff < -180) diff += 360; return curr + diff; }
82
123
 
124
+ // Calcolo media circolare vettoriale
83
125
  function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false) {
84
- const now = Date.now(); const validData = bufferArray.filter(item => (now - item.time) <= windowMs);
126
+ const now = Date.now();
127
+ const validData = bufferArray.filter(item => (now - item.time) <= windowMs);
85
128
  if (validData.length === 0) return null;
86
- let sSin = 0, sCos = 0; validData.forEach(item => { sSin += Math.sin(item.val); sCos += Math.cos(item.val); });
129
+ let sSin = 0, sCos = 0;
130
+ validData.forEach(item => { sSin += Math.sin(item.val); sCos += Math.cos(item.val); });
87
131
  let R = Math.sqrt(sSin * sSin + sCos * sCos) / validData.length;
88
- let isStable = (validData[validData.length - 1].time - validData[0].time >= windowMs - CONFIG.averages.stabilityTolerance) && (R > CONFIG.averages.stabilityThreshold);
132
+ let timeSpan = validData[validData.length - 1].time - validData[0].time;
133
+ let isStable = (timeSpan >= windowMs - CONFIG.averages.stabilityTolerance) && (R > CONFIG.averages.stabilityThreshold);
89
134
  let avgDeg = Math.round(radToDeg(Math.atan2(sSin, sCos)));
90
135
  return { val: signed ? avgDeg : (avgDeg + 360) % 360, stable: isStable };
91
136
  }
@@ -96,35 +141,45 @@ function processIncomingData(path, val) {
96
141
  if (path === "navigation.courseOverGroundTrue") { store.smoothBuf.cog.push({ val: val, time: now }); store.longBuf.cog.push({ val: val, time: now }); }
97
142
  if (path === "environment.wind.angleApparent") { store.smoothBuf.awa.push({ val: val, time: now }); store.longBuf.awa.push({ val: val, time: now }); }
98
143
  if (path === "environment.wind.angleTrueWater") { store.smoothBuf.twa.push({ val: val, time: now }); store.longBuf.twa.push({ val: val, time: now }); }
144
+
145
+ // Calcolo TWD (Vento Reale Direzione Geografica)
99
146
  if (path === "navigation.headingTrue" || path === "environment.wind.angleTrueWater" || path === "environment.wind.directionTrue") {
100
- let twdRad = 0; if (path === "environment.wind.directionTrue") twdRad = val;
101
- else if (store.raw["navigation.headingTrue"] !== undefined && store.raw["environment.wind.angleTrueWater"] !== undefined) { twdRad = (store.raw["navigation.headingTrue"] + store.raw["environment.wind.angleTrueWater"]) % (2 * Math.PI); if (twdRad < 0) twdRad += (2 * Math.PI); }
102
- else return;
103
- store.smoothBuf.twd.push({ val: twdRad, time: now }); store.longBuf.twd.push({ val: twdRad, time: now });
147
+ let twdRad = 0;
148
+ if (path === "environment.wind.directionTrue") twdRad = val;
149
+ else if (store.raw["navigation.headingTrue"] !== undefined && store.raw["environment.wind.angleTrueWater"] !== undefined) {
150
+ twdRad = (store.raw["navigation.headingTrue"] + store.raw["environment.wind.angleTrueWater"]) % (2 * Math.PI);
151
+ if (twdRad < 0) twdRad += (2 * Math.PI);
152
+ } else return;
153
+ store.smoothBuf.twd.push({ val: twdRad, time: now });
154
+ store.longBuf.twd.push({ val: twdRad, time: now });
104
155
  }
105
156
  }
106
157
 
107
158
  // ==========================================================================
108
- // 4. RENDERING LOOP E GRAFICA
159
+ // 5. MOTORE DI RENDERING PRINCIPALE
109
160
  // ==========================================================================
110
-
111
161
  function startDisplayLoop() {
112
162
  renderInterval = setInterval(() => {
113
163
  const now = Date.now();
164
+
165
+ // 5.1 Watchdog
114
166
  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 };
115
167
  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]; } }
116
168
 
169
+ // 5.2 Renders Istantanei
117
170
  if (store.raw["navigation.speedThroughWater"] !== undefined) { const v = msToKts(store.raw["navigation.speedThroughWater"]); ui.stw.innerText = v.toFixed(1); manageHistory('stw', v); }
118
171
  let curSog = 0; if (store.raw["navigation.speedOverGround"] !== undefined) { curSog = msToKts(store.raw["navigation.speedOverGround"]); ui.sog.innerText = curSog.toFixed(1); manageHistory('sog', curSog); }
119
172
  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); }
120
173
  if (store.raw["environment.wind.speedTrue"] !== undefined) { const w = msToKts(store.raw["environment.wind.speedTrue"]); ui.tws.innerText = w.toFixed(1); manageHistory('tws', w); }
121
174
  if (store.raw["environment.wind.speedApparent"] !== undefined) ui.awsSvg.textContent = msToKts(store.raw["environment.wind.speedApparent"]).toFixed(1);
122
175
 
176
+ // 5.3 Render Quadrante (2s smoothing)
123
177
  const smAwa = getCircularAverageFromBuffer(store.smoothBuf.awa, CONFIG.averages.smoothWindow, true);
124
178
  if (smAwa) { curAwaRot = getShortestRotation(curAwaRot, smAwa.val); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
125
179
  const smTwa = getCircularAverageFromBuffer(store.smoothBuf.twa, CONFIG.averages.smoothWindow, true);
126
180
  if (smTwa) { curTwaRot = getShortestRotation(curTwaRot, smTwa.val); ui.twa.setAttribute('transform', `rotate(${curTwaRot}, 200, 200)`); }
127
181
 
182
+ // Drift Reale (COG-HDG)
128
183
  if (store.raw["navigation.courseOverGroundTrue"] && store.raw["navigation.headingTrue"]) {
129
184
  let drift = (radToDeg(store.raw["navigation.courseOverGroundTrue"]) - radToDeg(store.raw["navigation.headingTrue"]) + 360) % 360;
130
185
  if (curSog < CONFIG.averages.minSpeed) drift = 0;
@@ -132,18 +187,30 @@ function startDisplayLoop() {
132
187
  let ds = drift > 180 ? drift - 360 : drift; updateLeewayDisplay(Math.max(-20, Math.min(20, ds)));
133
188
  } else updateLeewayDisplay(0);
134
189
 
190
+ // 5.4 Render Medie Lunghe (Ogni 3s)
135
191
  if (now - lastAvgUIUpdate > 3000) {
136
192
  let hObj = getCircularAverageFromBuffer(store.longBuf.hdg, CONFIG.averages.longWindow, false),
137
193
  cObj = getCircularAverageFromBuffer(store.longBuf.cog, CONFIG.averages.longWindow, false),
138
194
  awObj = getCircularAverageFromBuffer(store.longBuf.awa, CONFIG.averages.longWindow, true),
139
195
  twObj = getCircularAverageFromBuffer(store.longBuf.twa, CONFIG.averages.longWindow, true),
140
196
  twdObj = getCircularAverageFromBuffer(store.longBuf.twd, CONFIG.averages.longWindow, false);
141
- const upUI = (el, obj) => { if (!obj) { el.innerHTML = "---&deg;"; el.classList.remove('unstable-data'); } else { el.innerHTML = `${obj.val.toString().padStart(3, '0')}&deg;`; if (obj.stable || curSog < CONFIG.averages.minSpeed) el.classList.remove('unstable-data'); else el.classList.add('unstable-data'); } };
197
+
198
+ const upUI = (el, obj) => {
199
+ if (!obj) { el.innerHTML = "---&deg;"; el.classList.remove('unstable-data'); }
200
+ else {
201
+ el.innerHTML = `${obj.val.toString().padStart(3, '0')}&deg;`;
202
+ if (obj.stable || curSog < CONFIG.averages.minSpeed) el.classList.remove('unstable-data'); else el.classList.add('unstable-data');
203
+ }
204
+ };
142
205
  upUI(ui.hdg, hObj); upUI(ui.cog, cObj); upUI(ui.awaAvg, awObj); upUI(ui.twaAvg, twObj); upUI(ui.twdAvg, twdObj);
206
+
207
+ // TACK
143
208
  if (hObj && twObj && hObj.val !== null) {
144
209
  let tA = twObj.val * 2; ui.tackHdg.innerHTML = `${Math.round((hObj.val - tA + 360) % 360).toString().padStart(3, '0')}&deg;`;
145
210
  if (cObj) ui.tackCog.innerHTML = `${Math.round((cObj.val - tA + 360) % 360).toString().padStart(3, '0')}&deg;`;
146
- if (hObj.stable && twObj.stable || curSog < CONFIG.averages.minSpeed) { ui.tackHdg.classList.remove('unstable-data'); ui.tackCog.classList.remove('unstable-data'); } else { ui.tackHdg.classList.add('unstable-data'); ui.tackCog.classList.add('unstable-data'); }
211
+ let tStable = (hObj.stable && twObj.stable) || (curSog < CONFIG.averages.minSpeed);
212
+ if (tStable) { ui.tackHdg.classList.remove('unstable-data'); ui.tackCog.classList.remove('unstable-data'); }
213
+ else { ui.tackHdg.classList.add('unstable-data'); ui.tackCog.classList.add('unstable-data'); }
147
214
  }
148
215
  if (twdObj) { curTwdRoseRot = getShortestRotation(curTwdRoseRot, twdObj.val); ui.twdArrow.setAttribute('transform', `rotate(${curTwdRoseRot}, 20, 20)`); }
149
216
  lastAvgUIUpdate = now;
@@ -153,6 +220,23 @@ function startDisplayLoop() {
153
220
  }, RENDER_INTERVAL_MS);
154
221
  }
155
222
 
223
+ // ==========================================================================
224
+ // 6. CONNESSIONE SIGNALK (subscribe=self)
225
+ // ==========================================================================
226
+ function connect() {
227
+ if (simulationMode) return;
228
+ let addr = (window.location.protocol.includes("http")) ? window.location.host : CONFIG.server.fallbackIp;
229
+ try {
230
+ socket = new WebSocket(`ws://${addr}/signalk/v1/stream?subscribe=self`);
231
+ socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; };
232
+ socket.onmessage = (e) => { const d = JSON.parse(e.data); if (d.updates) d.updates.forEach(u => u.values && u.values.forEach(v => processIncomingData(v.path, v.value))); };
233
+ socket.onclose = () => !simulationMode && setTimeout(connect, 5000);
234
+ } catch (e) { setTimeout(connect, 5000); }
235
+ }
236
+
237
+ // ==========================================================================
238
+ // 7. FUNZIONI GRAFICHE E SCALATURA
239
+ // ==========================================================================
156
240
  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)}°`; }
157
241
 
158
242
  function manageHistory(t, v) {
@@ -181,52 +265,30 @@ function updateScaleLabels(t, min, max) {
181
265
  }
182
266
 
183
267
  function drawGraph(d, id, min, max, isTws, isHercules) {
184
- const svg = document.getElementById(id);
185
- if (!svg || d.length < 2) return;
186
-
268
+ const svg = document.getElementById(id); if (!svg || d.length < 2) return;
187
269
  const w = 200, h = 40, range = max - min || 1;
188
- const lClass = isHercules ? 'line-hercules' : '';
189
-
190
- // 1. Griglie
191
- let grids = "";
192
- [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" />`; });
193
- for (let m = 1; m < CONFIG.graphs.historyMinutes; m++) {
194
- const x = w - (m / CONFIG.graphs.historyMinutes) * w;
195
- grids += `<line x1="${x}" y1="0" x2="${x}" y2="${h}" stroke="rgba(255,255,255,0.05)" stroke-width="0.5" />`;
196
- }
197
-
198
- // 2. Calcolo Percorsi
270
+ 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" />`; });
271
+ 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" />`; }
199
272
  let pD = "", cS = "";
200
273
  d.forEach((v, i) => {
201
- const x = (i/(CONFIG.graphs.samples-1))*w, y = h-(Math.max(0,Math.min(1,(v-min)/range))*h);
202
- pD += `${i===0?'M':'L'} ${x} ${y} `;
203
-
204
- // Logica segmenti colorati per TWS
274
+ 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} `;
205
275
  if (isTws && i > 0) {
206
276
  const px = ((i-1)/(CONFIG.graphs.samples-1))*w, py = h-(Math.max(0,Math.min(1,(d[i-1]-min)/range))*h);
207
- let c = "#f1c40f";
208
- if (v >= CONFIG.graphs.reef2) c = "#e74c3c";
209
- else if (v >= CONFIG.graphs.reef1) c = "#e67e22";
210
- // Lo spessore ora è rimosso da qui e gestito dal CSS tramite la classe
211
- cS += `<line x1="${px}" y1="${py}" x2="${x}" y2="${y}" stroke="${c}" class="${lClass}" />`;
277
+ let c = "#f1c40f"; if (v >= CONFIG.graphs.reef2) c = "#e74c3c"; else if (v >= CONFIG.graphs.reef1) c = "#e67e22";
278
+ cS += `<line x1="${px}" y1="${py}" x2="${x}" y2="${y}" stroke="${c}" class="${isHercules?'line-hercules':''}" />`;
212
279
  }
213
280
  });
214
-
215
- // 3. Rendering finale
216
- const aP = pD + ` L ${((d.length-1)/(CONFIG.graphs.samples-1))*w} ${h} L 0 ${h} Z`;
217
- const clrs = { 'stw-graph': '#2ecc71', 'sog-graph': '#f39c12', 'depth-graph': '#3498db', 'tws-graph': '#f1c40f' };
218
-
219
- if (isTws) {
220
- svg.innerHTML = `${grids}<path d="${aP}" fill="rgba(241,196,15,0.12)" stroke="none" />${cS}`;
221
- } else {
222
- svg.innerHTML = `${grids}<path d="${aP}" fill="${clrs[id]}22" stroke="none" /><path d="${pD}" class="${lClass}" fill="none" stroke="${clrs[id]}" />`;
223
- }
281
+ 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': '#f1c40f' };
282
+ svg.innerHTML = isTws ? `${grids}<path d="${aP}" fill="rgba(241,196,15,0.12)" 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]}" />`;
224
283
  }
225
284
 
226
285
  // ==========================================================================
227
- // 5. EVENTI E INTERAZIONI
286
+ // 8. EVENTI E INTERAZIONI (Touch, Focus e Fullscreen)
228
287
  // ==========================================================================
229
288
 
289
+ // Blocco globale del menu contestuale per Android/iOS
290
+ window.addEventListener('contextmenu', e => e.preventDefault());
291
+
230
292
  function toggleFocusMode(type, element) {
231
293
  const container = document.querySelector('.main-container');
232
294
  const parentPanel = element.closest('.side-panel');
@@ -238,24 +300,32 @@ function toggleFocusMode(type, element) {
238
300
 
239
301
  ['stw', 'sog', 'tws', 'depth'].forEach(type => {
240
302
  const el = document.getElementById(type + '-graph').closest('.data-box');
303
+
304
+ // Doppio Click -> Hercules Zoom
241
305
  el.addEventListener('dblclick', (e) => { if (isFocusActive) return; e.preventDefault(); graphModes[type] = graphModes[type] === 'standard' ? 'hercules' : 'standard'; localStorage.setItem('mode_' + type, graphModes[type]); el.style.backgroundColor = "rgba(255,255,255,0.15)"; setTimeout(() => el.style.backgroundColor = "", 200); });
242
- const startPress = (e) => { if (!isFocusActive) pressTimer = setTimeout(() => { toggleFocusMode(type, el); if (e.cancelable) e.preventDefault(); }, 1000); };
306
+
307
+ // Long Press -> Tactical Focus
308
+ const startPress = () => { if (!isFocusActive) pressTimer = setTimeout(() => toggleFocusMode(type, el), 1000); };
243
309
  const cancelPress = () => { clearTimeout(pressTimer); };
244
- el.addEventListener('mousedown', startPress); el.addEventListener('touchstart', startPress, {passive: false});
245
- ['mouseup', 'mouseleave', 'touchend'].forEach(evt => el.addEventListener(evt, cancelPress));
310
+ el.addEventListener('mousedown', startPress); el.addEventListener('touchstart', startPress, {passive: true});
311
+ ['mouseup', 'mouseleave', 'touchend', 'touchcancel'].forEach(evt => el.addEventListener(evt, cancelPress));
312
+
313
+ // Click -> Exit Focus o Ghost click filtering
246
314
  el.addEventListener('click', (e) => { if (blockNextClick) { blockNextClick = false; return; } if (isFocusActive && el.classList.contains('is-focused')) toggleFocusMode(type, el); });
247
- el.addEventListener('contextmenu', (e) => { if (isFocusActive) e.preventDefault(); });
248
315
  });
249
316
 
317
+ // Fullscreen via Hotspot
250
318
  if (ui.hotspot) { ui.hotspot.addEventListener('click', () => { 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(); } }); }
251
319
 
252
320
  // ==========================================================================
253
- // 6. INIZIALIZZAZIONE
321
+ // 9. INIZIALIZZAZIONE
254
322
  // ==========================================================================
255
323
  (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); } } })();
256
- function connect() { if (simulationMode) return; let addr = (window.location.protocol.includes("http")) ? window.location.host : CONFIG.server.fallbackIp; try { socket = new WebSocket(`ws://${addr}/signalk/v1/stream?subscribe=self`); socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; }; socket.onmessage = (e) => { const d = JSON.parse(e.data); if (d.updates) d.updates.forEach(u => u.values && u.values.forEach(v => processIncomingData(v.path, v.value))); }; socket.onclose = () => !simulationMode && setTimeout(connect, 5000); } catch (e) { setTimeout(connect, 5000); } }
324
+
257
325
  async function init() { await fetchServerConfig(); startDisplayLoop(); connect(); }
258
326
  window.addEventListener('load', init);
327
+
328
+ // Allarmi e Audio
259
329
  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'); }
260
330
  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); }
261
331
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sailingrotevista/rotevista-dash",
3
- "version": "1.0.25",
3
+ "version": "1.0.26",
4
4
  "description": "Public Wind Dashboard with navigation and course aids",
5
5
  "main": "index.js",
6
6
  "publishConfig": {
package/style.css CHANGED
@@ -1,139 +1,92 @@
1
1
  /* ==========================================================================
2
- 1. BASE E STRUTTURA GENERALE (GRID LAYOUT)
2
+ 1. BASE E STRUTTURA GENERALE
3
3
  ========================================================================== */
4
4
  body {
5
5
  background-color: #000;
6
6
  color: #fff;
7
7
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
8
- margin: 0;
9
- padding: 0;
10
- height: 100vh;
11
- width: 100vw;
8
+ margin: 0; padding: 0;
9
+ height: 100vh; width: 100vw;
12
10
  overflow: hidden;
13
- /* Impedisce selezioni accidentali su tutto il corpo dell'app */
11
+ /* BLOCCO TOTALE GESTI DI SISTEMA (Android/iOS) */
12
+ -webkit-touch-callout: none;
14
13
  -webkit-user-select: none;
15
14
  user-select: none;
15
+ touch-action: none;
16
16
  }
17
17
 
18
18
  .main-container {
19
19
  display: grid;
20
- width: 100%;
21
- height: 100%;
22
- padding: 5px;
23
- box-sizing: border-box;
20
+ width: 100%; height: 100%;
21
+ padding: 5px; box-sizing: border-box;
24
22
  gap: 8px;
25
- /* Desktop Default: 3 colonne */
26
- grid-template-columns: 1.5fr 3fr 1.5fr;
23
+ grid-template-columns: 1.5fr 3.5fr 1.5fr;
27
24
  grid-template-rows: 100%;
28
- transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
29
- }
30
-
31
- /* Ottimizzazione per schermi molto larghi (16:10, 16:9) */
32
- @media (min-aspect-ratio: 1.5) {
33
- .main-container {
34
- grid-template-columns: 2fr 3fr 2fr;
35
- gap: 15px;
36
- }
25
+ transition: all 0.4s ease;
37
26
  }
38
27
 
39
28
  /* ==========================================================================
40
29
  2. PANNELLI E DATA-BOX
41
30
  ========================================================================== */
42
- .side-panel {
43
- display: flex;
44
- flex-direction: column;
45
- background: rgba(255, 255, 255, 0.03);
46
- border-radius: 12px;
47
- z-index: 10;
48
- height: 100%;
49
- overflow: hidden;
50
- }
51
-
31
+ .side-panel { display: flex; flex-direction: column; background: rgba(255, 255, 255, 0.03); border-radius: 12px; height: 100%; overflow: hidden; }
52
32
  .left-panel { grid-column: 1; }
33
+ .center-panel { grid-column: 2; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; overflow: hidden; }
53
34
  .right-panel { grid-column: 3; align-items: flex-end; text-align: right; }
54
35
 
55
- .center-panel {
56
- grid-column: 2;
57
- display: flex;
58
- flex-direction: column;
59
- align-items: center;
60
- justify-content: center;
61
- height: 100%;
62
- overflow: hidden;
63
- }
64
-
65
36
  .data-box {
66
37
  position: relative;
67
38
  border-bottom: 1px solid #222;
68
39
  padding: 8px 12px;
69
40
  display: flex;
70
41
  flex-direction: column;
71
- width: 100%;
72
- box-sizing: border-box;
42
+ width: 100%; box-sizing: border-box;
73
43
  container-type: size;
74
44
  flex: 1;
75
- overflow: hidden; /* Evita sconfinamenti su iPhone */
45
+ overflow: hidden;
76
46
  }
77
47
 
78
- /* Altezze Proporzionali Desktop */
48
+ /* Altezze Desktop */
79
49
  .data-box:nth-child(1), .data-box:nth-child(2) { height: 25vh; }
80
50
  .data-box:nth-child(3), .data-box:nth-child(4), .data-box:nth-child(5) { height: 16.666vh; }
81
51
 
82
52
  /* ==========================================================================
83
- 3. TACTICAL FOCUS MODE (50/50 SPLIT)
53
+ 3. TACTICAL FOCUS MODE (ORIZZONTALE)
84
54
  ========================================================================== */
85
55
  .focus-active { grid-template-columns: 1fr 1fr !important; }
86
56
  .focus-active .side-panel:not(.has-focus) { display: none !important; }
87
57
 
88
- /* Simmetria: se il focus è a sx, il vento va a dx e viceversa */
89
- .focus-active.focus-side-left .side-panel.has-focus { grid-column: 1; }
90
- .focus-active.focus-side-left .center-panel { grid-column: 2; }
91
- .focus-active.focus-side-right .center-panel { grid-column: 1; }
92
- .focus-active.focus-side-right .side-panel.has-focus { grid-column: 2; }
58
+ /* Simmetria Orizzontale */
59
+ .focus-active.focus-side-left .side-panel.has-focus { grid-column: 1 !important; }
60
+ .focus-active.focus-side-left .center-panel { grid-column: 2 !important; }
61
+ .focus-active.focus-side-right .center-panel { grid-column: 1 !important; }
62
+ .focus-active.focus-side-right .side-panel.has-focus { grid-column: 2 !important; }
93
63
 
94
- /* Box Focalizzato a tutto schermo */
95
64
  .focus-active .has-focus .data-box:not(.is-focused) { display: none !important; }
96
- .focus-active .has-focus .data-box.is-focused {
97
- height: 100vh !important;
98
- border: none;
99
- background: rgba(255, 255, 255, 0.05);
100
- }
65
+ .focus-active .has-focus .data-box.is-focused { height: 100vh !important; border: none; background: rgba(255, 255, 255, 0.05); }
101
66
 
67
+ /* Tipografia e Grafica in Focus */
102
68
  .focus-active .is-focused .value { font-size: clamp(3rem, 18cqh, 8rem) !important; margin-top: 10px; }
103
69
  .focus-active .is-focused .scale-labels { font-size: 22px !important; min-width: 40px !important; }
104
-
105
- /* Grafica Linee Sottili in Focus */
106
70
  .focus-active .is-focused .sparkline path { stroke-width: 0.8px !important; }
107
71
 
108
72
  /* ==========================================================================
109
- 4. TIPOGRAFIA DINAMICA (ELASTICA CQH)
73
+ 4. TIPOGRAFIA DINAMICA (CQH)
110
74
  ========================================================================== */
111
75
  .label-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 2px; width: 100%; }
112
76
  .label { color: #666; font-size: 0.65rem; font-weight: bold; text-transform: uppercase; }
113
77
  .unit { color: #888; font-size: 0.6rem; font-weight: bold; }
78
+ .value { color: #fff; font-size: clamp(1.2rem, 22cqh, 3rem); font-weight: 600; line-height: 0.9; letter-spacing: -1px; padding-bottom: 5px; }
114
79
 
115
- .value { color: #fff; font-size: clamp(1.2rem, 22cqh, 3rem); font-weight: 600; line-height: 0.9; letter-spacing: -1px; transition: color 0.3s ease; padding-bottom: 5px; }
116
-
117
- /* Allineamento centrato per box senza grafico */
118
- .value-large, .dual-value-container, .value-with-compass {
119
- margin-top: auto;
120
- margin-bottom: auto;
121
- }
122
-
80
+ .value-large, .dual-value-container, .value-with-compass { margin-top: auto; margin-bottom: auto; }
123
81
  .value-large { font-size: clamp(1.5rem, 35cqh, 4.5rem); line-height: 0.85; }
124
82
 
125
- /* TACK E TWD BUSSOLA */
126
83
  .dual-value-container { display: flex; justify-content: space-between; width: 100%; }
127
84
  .dual-value-col { display: flex; flex-direction: column; width: 48%; }
128
85
  .dual-label { color: #666; font-size: 0.55rem; font-weight: bold; text-transform: uppercase; margin-bottom: 2px; }
129
86
  .value.dual-val { font-size: clamp(1rem, 22cqh, 2rem); padding-bottom: 0; line-height: 0.9; }
130
87
 
131
88
  .value-with-compass { display: flex; justify-content: space-between; align-items: center; width: 100%; gap: 10px; }
132
- .mini-compass {
133
- width: clamp(40px, 60cqh, 120px); height: clamp(40px, 60cqh, 120px);
134
- background: #000; border-radius: 50%; flex-shrink: 0; border: 1.5px solid #333;
135
- box-shadow: inset 0 0 10px rgba(0,0,0,0.8); transition: all 0.4s ease;
136
- }
89
+ .mini-compass { width: clamp(40px, 60cqh, 120px); height: clamp(40px, 60cqh, 120px); background: #000; border-radius: 50%; flex-shrink: 0; border: 1.5px solid #333; box-shadow: inset 0 0 10px rgba(0,0,0,0.8); }
137
90
  .value-with-compass .value-large { margin: 0; flex-grow: 1; text-align: right; font-size: clamp(1.2rem, 35cqh, 4rem); }
138
91
 
139
92
  /* ==========================================================================
@@ -141,17 +94,9 @@ body {
141
94
  ========================================================================== */
142
95
  .graph-wrapper { position: relative; width: 100%; flex-grow: 1; min-height: 0; margin-top: 4px; display: flex; align-items: stretch; gap: 6px; }
143
96
  .sparkline { flex-grow: 1; height: 100%; background: rgba(255, 255, 255, 0.03); border-radius: 4px; }
97
+ .sparkline path, .sparkline line { stroke-width: 1px !important; transition: all 0.3s ease; }
144
98
 
145
- /* Uniforma lo spessore di TUTTI i tipi di linea nei grafici (Standard) */
146
- .sparkline path,
147
- .sparkline line {
148
- stroke-width: 1px !important;
149
- transition: stroke-width 0.3s ease;
150
- }
151
-
152
- .scale-labels { display: flex; flex-direction: column; justify-content: space-between; font-size: 10px; color: #777; font-weight: bold; min-width: 16px; height: 100%; line-height: 1; transition: all 0.3s ease; }
153
-
154
- /* Simmetria interna */
99
+ .scale-labels { display: flex; flex-direction: column; justify-content: space-between; font-size: 10px; color: #777; font-weight: bold; min-width: 20px; height: 100%; line-height: 1; }
155
100
  .left-panel .scale-labels { order: 2; text-align: left; }
156
101
  .left-panel .sparkline { order: 1; }
157
102
  .right-panel .scale-labels { order: 1; text-align: right; }
@@ -162,12 +107,8 @@ body {
162
107
  #depth-graph { stroke: #3498db; fill: rgba(52, 152, 219, 0.12); }
163
108
  #tws-graph { stroke: #f1c40f; fill: rgba(241, 196, 15, 0.12); }
164
109
 
165
- /* HERCULES MODE */
166
- .line-hercules {
167
- filter: drop-shadow(0 0 5px #ff0000);
168
- stroke-width: 1.8px !important;
169
- }
170
- .box-hercules { background: rgba(255, 0, 0, 0.08) !important; transition: all 0.3s ease; }
110
+ .line-hercules { filter: drop-shadow(0 0 5px #ff0000); stroke-width: 1.8px !important; }
111
+ .box-hercules { background: rgba(255, 0, 0, 0.08) !important; }
171
112
  .box-hercules::after { content: "HERCULES"; position: absolute; bottom: 4px; right: 35px; font-size: 7px; color: #ff4444; font-weight: 900; opacity: 0.8; }
172
113
 
173
114
  /* ==========================================================================
@@ -185,27 +126,37 @@ body {
185
126
  #wind-gauge { width: 100%; height: 100%; max-height: 100%; object-fit: contain; }
186
127
 
187
128
  /* ==========================================================================
188
- 7. RESPONSIVE (PORTRAIT)
129
+ 7. RESPONSIVE (PORTRAIT) - FIX DEFINITIVO 2x2 E FOCUS
189
130
  ========================================================================== */
190
131
  @media (max-aspect-ratio: 0.9 / 1) {
191
132
  .main-container {
192
133
  grid-template-columns: 1fr 1fr !important;
193
- grid-template-rows: 45vh 55vh !important; /* Calibrato per iPhone */
134
+ grid-template-rows: 45vh 55vh !important;
194
135
  }
195
136
 
196
- .center-panel { grid-row: 1; grid-column: 1 / span 2; padding: 5px 0; }
197
- .left-panel { grid-row: 2; grid-column: 1; }
198
- .right-panel { grid-row: 2; grid-column: 2; }
137
+ /* Ordine Standard Portrait: Vento (1), SX (2), DX (3) */
138
+ .center-panel { grid-row: 1 !important; grid-column: 1 / span 2 !important; }
139
+ .left-panel { grid-row: 2 !important; grid-column: 1 !important; }
140
+ .right-panel { grid-row: 2 !important; grid-column: 2 !important; }
199
141
 
200
- /* Focus Mode Verticale */
201
- .main-container.focus-active { grid-template-columns: 100% !important; }
202
- .focus-active .center-panel { grid-row: 1; height: 45vh !important; }
203
- .focus-active .side-panel.has-focus { grid-row: 2; height: 55vh !important; }
142
+ /* FOCUS MODE IN VERTICALE: Abbandoniamo la Grid, usiamo Flex per sicurezza */
143
+ .main-container.focus-active {
144
+ display: flex !important;
145
+ flex-direction: column !important;
146
+ }
147
+ .focus-active .center-panel {
148
+ flex: 0 0 45vh !important;
149
+ width: 100% !important;
150
+ }
151
+ .focus-active .side-panel.has-focus {
152
+ flex: 0 0 55vh !important;
153
+ width: 100% !important;
154
+ display: flex !important;
155
+ }
204
156
 
205
- /* Altezze box iPhone */
157
+ /* Calibrazione altezze box per Portrait */
206
158
  .data-box:nth-child(1), .data-box:nth-child(2) { height: 13.5vh; }
207
159
  .data-box:nth-child(3), .data-box:nth-child(4), .data-box:nth-child(5) { height: 9.3vh; }
208
160
 
209
- .value-large { font-size: clamp(1.2rem, 35cqh, 2.5rem); margin: auto 0; }
210
- .value.dual-val { font-size: clamp(0.9rem, 30cqh, 1.4rem); }
161
+ .value-large { font-size: clamp(1.2rem, 35cqh, 2.5rem); }
211
162
  }