@sailingrotevista/rotevista-dash 1.0.0
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.
- package/app.js +462 -0
- package/app.js.txt +311 -0
- package/index.html +222 -0
- package/manifest.json +17 -0
- package/package.json +17 -0
- package/style.css +269 -0
package/app.js
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
// ==========================================================================
|
|
2
|
+
// 1. CONFIGURAZIONE E COSTANTI
|
|
3
|
+
// ==========================================================================
|
|
4
|
+
const SIGNALK_SERVER_IP = "192.168.111.240:3000"; // Indirizzo IP e porta server SignalK
|
|
5
|
+
const ALARM_DANGER_DEPTH = 2.5; // Allarme rosso + suono (metri)
|
|
6
|
+
const ALARM_WARNING_DEPTH = 5.0; // Pre-allarme giallo (metri)
|
|
7
|
+
|
|
8
|
+
const RENDER_INTERVAL_MS = 1000; // Refresh UI (1 sec)
|
|
9
|
+
const TIMEOUT_MS = 5000; // Watchdog: oscura dato se manca per 5 sec
|
|
10
|
+
|
|
11
|
+
const SMOOTH_WINDOW_MS = 2000; // Media veloce (2 sec) per lancette SVG
|
|
12
|
+
const LONG_AVG_WINDOW_MS = 60000; // Media lunga (60 sec) per riquadri "MEAN"
|
|
13
|
+
|
|
14
|
+
const MAX_HISTORY_SAMPLES = 60; // Pixel max per sparklines
|
|
15
|
+
const REAL_SAMPLE_INTERVAL = 5000; // Campionamento sparkline reale (5 sec)
|
|
16
|
+
const SIM_SAMPLE_INTERVAL = 1000; // Campionamento sparkline simulazione (1 sec)
|
|
17
|
+
|
|
18
|
+
const STABILITY_TIME_TOLERANCE_MS = 2000; // Tolleranza riempimento buffer (es. 58s su 60s)
|
|
19
|
+
const STABILITY_CONFIDENCE_THRESHOLD = 0.90; // Indice circolare (R) minimo per non lampeggiare
|
|
20
|
+
const MIN_SPEED_FOR_STABILITY_KTS = 0.5; // Sotto questa velocità NON lampeggia nulla (barca ferma)
|
|
21
|
+
|
|
22
|
+
// ==========================================================================
|
|
23
|
+
// 2. VARIABILI GLOBALI E STATO
|
|
24
|
+
// ==========================================================================
|
|
25
|
+
let simulationMode = false;
|
|
26
|
+
let socket;
|
|
27
|
+
let renderInterval = null;
|
|
28
|
+
let simInterval = null;
|
|
29
|
+
let lastAvgUIUpdate = 0;
|
|
30
|
+
let audioCtx = null, lastAlarmTime = 0;
|
|
31
|
+
let curAwaRot = 0, curTwaRot = 0, curTrackRot = 0, curTwdRoseRot = 0;
|
|
32
|
+
|
|
33
|
+
const store = {
|
|
34
|
+
raw: {},
|
|
35
|
+
timestamps: {},
|
|
36
|
+
smoothBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [], leeway: [] },
|
|
37
|
+
longBuf: { hdg: [], cog: [], awa: [], twa: [], twd: [] },
|
|
38
|
+
histories: { stw: [], sog: [], depth: [], tws: [] },
|
|
39
|
+
lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0 }
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const ui = {
|
|
43
|
+
stw: document.getElementById('stw'), sog: document.getElementById('sog'),
|
|
44
|
+
hdg: document.getElementById('hdg'), cog: document.getElementById('cog'),
|
|
45
|
+
awsSvg: document.getElementById('aws-val-svg'), awa: document.getElementById('awa-pointer'),
|
|
46
|
+
twa: document.getElementById('twa-pointer'), track: document.getElementById('track-pointer'),
|
|
47
|
+
tws: document.getElementById('tws'), depth: document.getElementById('depth'),
|
|
48
|
+
twaAvg: document.getElementById('twa-avg'), awaAvg: document.getElementById('awa-avg'),
|
|
49
|
+
twdAvg: document.getElementById('twd-avg'), twdArrow: document.getElementById('twd-arrow'),
|
|
50
|
+
leewayMask: document.getElementById('leeway-mask-rect'), leewayVal: document.getElementById('leeway-val'),
|
|
51
|
+
tackHdg: document.getElementById('tack-hdg'), tackCog: document.getElementById('tack-cog'),
|
|
52
|
+
status: document.getElementById('status')
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// ==========================================================================
|
|
56
|
+
// 3. FUNZIONI MATEMATICHE E HELPER
|
|
57
|
+
// ==========================================================================
|
|
58
|
+
function radToDeg(rad) { return rad * (180 / Math.PI); }
|
|
59
|
+
function degToRad(deg) { return deg * (Math.PI / 180); }
|
|
60
|
+
function msToKts(ms) { return ms * 1.94384; }
|
|
61
|
+
function ktsToMs(kts) { return kts / 1.94384; }
|
|
62
|
+
|
|
63
|
+
function getShortestRotation(curr, target) {
|
|
64
|
+
let diff = (target - curr) % 360;
|
|
65
|
+
if (diff > 180) diff -= 360; else if (diff < -180) diff += 360;
|
|
66
|
+
return curr + diff;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false) {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
const validData = bufferArray.filter(item => (now - item.time) <= windowMs);
|
|
72
|
+
if (validData.length === 0) return null;
|
|
73
|
+
|
|
74
|
+
let sumSin = 0, sumCos = 0;
|
|
75
|
+
validData.forEach(item => { sumSin += Math.sin(item.val); sumCos += Math.cos(item.val); });
|
|
76
|
+
|
|
77
|
+
let R = Math.sqrt(sumSin * sumSin + sumCos * sumCos) / validData.length;
|
|
78
|
+
let timeSpan = validData[validData.length - 1].time - validData[0].time;
|
|
79
|
+
let isStable = (timeSpan >= windowMs - STABILITY_TIME_TOLERANCE_MS) && (R > STABILITY_CONFIDENCE_THRESHOLD);
|
|
80
|
+
let avgRad = Math.atan2(sumSin, sumCos);
|
|
81
|
+
let avgDeg = Math.round(radToDeg(avgRad));
|
|
82
|
+
let finalVal = signed ? avgDeg : (avgDeg + 360) % 360;
|
|
83
|
+
|
|
84
|
+
return { val: finalVal, stable: isStable };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function cleanBuffer(bufferArray, windowMs) {
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
while (bufferArray.length > 0 && (now - bufferArray[0].time) > windowMs) bufferArray.shift();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ==========================================================================
|
|
93
|
+
// 4. GESTIONE DATI IN INGRESSO (SIGNALK / SIMULATORE)
|
|
94
|
+
// ==========================================================================
|
|
95
|
+
function processIncomingData(path, val) {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
store.timestamps[path] = now;
|
|
98
|
+
store.raw[path] = val;
|
|
99
|
+
|
|
100
|
+
if (path === "navigation.headingTrue") { store.smoothBuf.hdg.push({ val: val, time: now }); store.longBuf.hdg.push({ val: val, time: now }); }
|
|
101
|
+
if (path === "navigation.courseOverGroundTrue") { store.smoothBuf.cog.push({ val: val, time: now }); store.longBuf.cog.push({ val: val, time: now }); }
|
|
102
|
+
if (path === "environment.wind.angleApparent") { store.smoothBuf.awa.push({ val: val, time: now }); store.longBuf.awa.push({ val: val, time: now }); }
|
|
103
|
+
if (path === "environment.wind.angleTrueWater") { store.smoothBuf.twa.push({ val: val, time: now }); store.longBuf.twa.push({ val: val, time: now }); }
|
|
104
|
+
|
|
105
|
+
// Calcolo TWD in locale
|
|
106
|
+
if (path === "environment.wind.angleTrueWater" || path === "navigation.headingTrue") {
|
|
107
|
+
if (store.raw["navigation.headingTrue"] !== undefined && store.raw["environment.wind.angleTrueWater"] !== undefined) {
|
|
108
|
+
let twdRad = (store.raw["navigation.headingTrue"] + store.raw["environment.wind.angleTrueWater"]) % (2 * Math.PI);
|
|
109
|
+
if (twdRad < 0) twdRad += (2 * Math.PI);
|
|
110
|
+
store.raw["environment.wind.directionTrue"] = twdRad;
|
|
111
|
+
store.timestamps["environment.wind.directionTrue"] = now;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (store.raw["environment.wind.directionTrue"] !== undefined) {
|
|
116
|
+
store.smoothBuf.twd.push({ val: store.raw["environment.wind.directionTrue"], time: now });
|
|
117
|
+
store.longBuf.twd.push({ val: store.raw["environment.wind.directionTrue"], time: now });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Calcolo Leeway in locale
|
|
121
|
+
if (path === "environment.wind.angleTrueWater" || path === "environment.wind.speedTrue" || path === "navigation.speedThroughWater") {
|
|
122
|
+
if (store.raw["environment.wind.angleTrueWater"] !== undefined && store.raw["environment.wind.speedTrue"] !== undefined && store.raw["navigation.speedThroughWater"] !== undefined) {
|
|
123
|
+
const twsKts = msToKts(store.raw["environment.wind.speedTrue"]);
|
|
124
|
+
const stwKts = msToKts(store.raw["navigation.speedThroughWater"]);
|
|
125
|
+
const stwSafe = Math.max(stwKts, 0.1);
|
|
126
|
+
let leewayDeg = - (12 * twsKts / (stwSafe * stwSafe)) * Math.sin(store.raw["environment.wind.angleTrueWater"]);
|
|
127
|
+
leewayDeg = Math.max(-20, Math.min(20, leewayDeg));
|
|
128
|
+
store.raw["navigation.leewayAngle"] = degToRad(leewayDeg);
|
|
129
|
+
store.timestamps["navigation.leewayAngle"] = now;
|
|
130
|
+
store.smoothBuf.leeway.push({ val: degToRad(leewayDeg), time: now });
|
|
131
|
+
}
|
|
132
|
+
} else if (path === "navigation.leewayAngle") {
|
|
133
|
+
store.smoothBuf.leeway.push({ val: val, time: now });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ==========================================================================
|
|
138
|
+
// 5. MOTORE DI RENDERING PRINCIPALE (UI LOOP)
|
|
139
|
+
// ==========================================================================
|
|
140
|
+
function startDisplayLoop() {
|
|
141
|
+
if (renderInterval) clearInterval(renderInterval);
|
|
142
|
+
|
|
143
|
+
renderInterval = setInterval(() => {
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
|
|
146
|
+
// --- WATCHDOG TIMEOUT ---
|
|
147
|
+
const pathsToWatch = {
|
|
148
|
+
"navigation.speedThroughWater": ui.stw, "navigation.speedOverGround": ui.sog,
|
|
149
|
+
"navigation.headingTrue": ui.hdg, "navigation.courseOverGroundTrue": ui.cog,
|
|
150
|
+
"environment.wind.speedApparent": ui.awsSvg,
|
|
151
|
+
"environment.depth.belowTransducer": ui.depth, "environment.wind.speedTrue": ui.tws
|
|
152
|
+
};
|
|
153
|
+
for (let p in pathsToWatch) {
|
|
154
|
+
if (!store.timestamps[p] || (now - store.timestamps[p] > TIMEOUT_MS)) {
|
|
155
|
+
pathsToWatch[p][pathsToWatch[p] === ui.awsSvg ? 'textContent' : 'innerText'] = "---";
|
|
156
|
+
if (p === "environment.depth.belowTransducer") ui.depth.classList.remove('alarm-warning', 'alarm-danger');
|
|
157
|
+
delete store.raw[p];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- RENDER DATI ISTANTANEI ---
|
|
162
|
+
if (store.raw["navigation.speedThroughWater"] !== undefined) {
|
|
163
|
+
const stw = msToKts(store.raw["navigation.speedThroughWater"]); ui.stw.innerText = stw.toFixed(1); manageHistory('stw', stw);
|
|
164
|
+
}
|
|
165
|
+
if (store.raw["navigation.speedOverGround"] !== undefined) {
|
|
166
|
+
const sog = msToKts(store.raw["navigation.speedOverGround"]); ui.sog.innerText = sog.toFixed(1); manageHistory('sog', sog);
|
|
167
|
+
}
|
|
168
|
+
if (store.raw["environment.depth.belowTransducer"] !== undefined) {
|
|
169
|
+
const d = store.raw["environment.depth.belowTransducer"]; ui.depth.innerText = d.toFixed(1); checkDepthAlarm(d); manageHistory('depth', d);
|
|
170
|
+
}
|
|
171
|
+
if (store.raw["environment.wind.speedTrue"] !== undefined) {
|
|
172
|
+
const tws = msToKts(store.raw["environment.wind.speedTrue"]); ui.tws.innerText = tws.toFixed(1); manageHistory('tws', tws);
|
|
173
|
+
}
|
|
174
|
+
if (store.raw["environment.wind.speedApparent"] !== undefined) {
|
|
175
|
+
ui.awsSvg.textContent = msToKts(store.raw["environment.wind.speedApparent"]).toFixed(1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- RENDER QUADRANTE VENTO (SMOOTHING 2s) ---
|
|
179
|
+
const smoothLeewayObj = getCircularAverageFromBuffer(store.smoothBuf.leeway, SMOOTH_WINDOW_MS, true);
|
|
180
|
+
const smoothLeeway = smoothLeewayObj ? smoothLeewayObj.val : null;
|
|
181
|
+
|
|
182
|
+
const smoothAwaObj = getCircularAverageFromBuffer(store.smoothBuf.awa, SMOOTH_WINDOW_MS, true);
|
|
183
|
+
if (smoothAwaObj !== null) { curAwaRot = getShortestRotation(curAwaRot, smoothAwaObj.val); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
|
|
184
|
+
|
|
185
|
+
const smoothTwaObj = getCircularAverageFromBuffer(store.smoothBuf.twa, SMOOTH_WINDOW_MS, true);
|
|
186
|
+
if (smoothTwaObj !== null) { curTwaRot = getShortestRotation(curTwaRot, smoothTwaObj.val); ui.twa.setAttribute('transform', `rotate(${curTwaRot}, 200, 200)`); }
|
|
187
|
+
|
|
188
|
+
if (smoothLeeway !== null) {
|
|
189
|
+
updateLeewayDisplay(smoothLeeway);
|
|
190
|
+
curTrackRot = getShortestRotation(curTrackRot, smoothLeeway); ui.track.setAttribute('transform', `rotate(${curTrackRot}, 200, 200)`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// --- RENDER MEDIE LUNGHE E TACK (Ogni 3s su buffer di 60s) ---
|
|
194
|
+
if (now - lastAvgUIUpdate > 3000) {
|
|
195
|
+
let hdgObj = getCircularAverageFromBuffer(store.longBuf.hdg, LONG_AVG_WINDOW_MS, false);
|
|
196
|
+
let cogObj = getCircularAverageFromBuffer(store.longBuf.cog, LONG_AVG_WINDOW_MS, false);
|
|
197
|
+
let awObj = getCircularAverageFromBuffer(store.longBuf.awa, LONG_AVG_WINDOW_MS, true);
|
|
198
|
+
let twObj = getCircularAverageFromBuffer(store.longBuf.twa, LONG_AVG_WINDOW_MS, true);
|
|
199
|
+
let twdObj = getCircularAverageFromBuffer(store.longBuf.twd, LONG_AVG_WINDOW_MS, false);
|
|
200
|
+
|
|
201
|
+
// Lettura velocità per soppressione allarmi stabilità all'ormeggio
|
|
202
|
+
let currentSogKts = 0;
|
|
203
|
+
if (store.histories.sog && store.histories.sog.length > 0) {
|
|
204
|
+
currentSogKts = store.histories.sog[store.histories.sog.length - 1];
|
|
205
|
+
} else if (store.raw["navigation.speedOverGround"] !== undefined) {
|
|
206
|
+
currentSogKts = msToKts(store.raw["navigation.speedOverGround"]);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const updateMeanUI = (el, obj) => {
|
|
210
|
+
if (!obj) {
|
|
211
|
+
el.innerHTML = `---°`;
|
|
212
|
+
el.classList.remove('unstable-data');
|
|
213
|
+
} else {
|
|
214
|
+
el.innerHTML = `${obj.val.toString().padStart(3, '0')}°`;
|
|
215
|
+
let isEffectivelyStable = obj.stable || (currentSogKts < MIN_SPEED_FOR_STABILITY_KTS);
|
|
216
|
+
if (isEffectivelyStable) el.classList.remove('unstable-data');
|
|
217
|
+
else el.classList.add('unstable-data');
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
updateMeanUI(ui.hdg, hdgObj);
|
|
222
|
+
updateMeanUI(ui.cog, cogObj);
|
|
223
|
+
updateMeanUI(ui.awaAvg, awObj);
|
|
224
|
+
updateMeanUI(ui.twaAvg, twObj);
|
|
225
|
+
updateMeanUI(ui.twdAvg, twdObj);
|
|
226
|
+
|
|
227
|
+
// Calcolo TACK (Mure Opposte)
|
|
228
|
+
if (hdgObj && twObj && hdgObj.val !== null && twObj.val !== null) {
|
|
229
|
+
let tackAngleOffset = twObj.val * 2;
|
|
230
|
+
let newHdg = (hdgObj.val - tackAngleOffset + 360) % 360;
|
|
231
|
+
ui.tackHdg.innerHTML = `${Math.round(newHdg).toString().padStart(3, '0')}°`;
|
|
232
|
+
|
|
233
|
+
if (cogObj && cogObj.val !== null) {
|
|
234
|
+
let newCog = (cogObj.val - tackAngleOffset + 360) % 360;
|
|
235
|
+
ui.tackCog.innerHTML = `${Math.round(newCog).toString().padStart(3, '0')}°`;
|
|
236
|
+
} else {
|
|
237
|
+
ui.tackCog.innerHTML = `---°`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Lampeggio TACK con esenzione per barca ferma applicata
|
|
241
|
+
let tackMathStable = hdgObj.stable && twObj.stable && (!cogObj || cogObj.stable);
|
|
242
|
+
let tackEffectivelyStable = tackMathStable || (currentSogKts < MIN_SPEED_FOR_STABILITY_KTS);
|
|
243
|
+
|
|
244
|
+
if (tackEffectivelyStable) {
|
|
245
|
+
ui.tackHdg.classList.remove('unstable-data');
|
|
246
|
+
ui.tackCog.classList.remove('unstable-data');
|
|
247
|
+
} else {
|
|
248
|
+
ui.tackHdg.classList.add('unstable-data');
|
|
249
|
+
ui.tackCog.classList.add('unstable-data');
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
ui.tackHdg.innerHTML = `---°`;
|
|
253
|
+
ui.tackCog.innerHTML = `---°`;
|
|
254
|
+
ui.tackHdg.classList.remove('unstable-data');
|
|
255
|
+
ui.tackCog.classList.remove('unstable-data');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (twdObj !== null) {
|
|
259
|
+
curTwdRoseRot = getShortestRotation(curTwdRoseRot, twdObj.val);
|
|
260
|
+
ui.twdArrow.setAttribute('transform', `rotate(${curTwdRoseRot}, 20, 20)`);
|
|
261
|
+
}
|
|
262
|
+
lastAvgUIUpdate = now;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Pulizia ciclica dei buffer per evitare memory leak
|
|
266
|
+
cleanBuffer(store.smoothBuf.hdg, SMOOTH_WINDOW_MS); cleanBuffer(store.smoothBuf.cog, SMOOTH_WINDOW_MS);
|
|
267
|
+
cleanBuffer(store.smoothBuf.awa, SMOOTH_WINDOW_MS); cleanBuffer(store.smoothBuf.twa, SMOOTH_WINDOW_MS);
|
|
268
|
+
cleanBuffer(store.smoothBuf.twd, SMOOTH_WINDOW_MS); cleanBuffer(store.smoothBuf.leeway, SMOOTH_WINDOW_MS);
|
|
269
|
+
cleanBuffer(store.longBuf.awa, LONG_AVG_WINDOW_MS); cleanBuffer(store.longBuf.twa, LONG_AVG_WINDOW_MS);
|
|
270
|
+
cleanBuffer(store.longBuf.twd, LONG_AVG_WINDOW_MS); cleanBuffer(store.longBuf.hdg, LONG_AVG_WINDOW_MS);
|
|
271
|
+
cleanBuffer(store.longBuf.cog, LONG_AVG_WINDOW_MS);
|
|
272
|
+
|
|
273
|
+
}, RENDER_INTERVAL_MS);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ==========================================================================
|
|
277
|
+
// 6. ALLARMI AUDIO
|
|
278
|
+
// ==========================================================================
|
|
279
|
+
document.addEventListener('click', () => { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }, { once: true });
|
|
280
|
+
function playBingBing() {
|
|
281
|
+
if (!audioCtx) return; const n = Date.now(); if (n - lastAlarmTime < 3000) return; lastAlarmTime = n;
|
|
282
|
+
function b(f, s) {
|
|
283
|
+
const o = audioCtx.createOscillator(); const g = audioCtx.createGain();
|
|
284
|
+
o.connect(g); g.connect(audioCtx.destination); o.frequency.value = f;
|
|
285
|
+
g.gain.setValueAtTime(0.1, s); g.gain.exponentialRampToValueAtTime(0.01, s + 0.4);
|
|
286
|
+
o.start(s); o.stop(s + 0.5);
|
|
287
|
+
}
|
|
288
|
+
b(880, audioCtx.currentTime); b(880, audioCtx.currentTime + 0.6);
|
|
289
|
+
}
|
|
290
|
+
function checkDepthAlarm(m) {
|
|
291
|
+
ui.depth.classList.remove('alarm-warning', 'alarm-danger');
|
|
292
|
+
if (m < ALARM_DANGER_DEPTH) { ui.depth.classList.add('alarm-danger'); playBingBing(); }
|
|
293
|
+
else if (m < ALARM_WARNING_DEPTH) ui.depth.classList.add('alarm-warning');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ==========================================================================
|
|
297
|
+
// 7. CONNESSIONE SIGNALK (SAFARI FRIENDLY)
|
|
298
|
+
// ==========================================================================
|
|
299
|
+
let isConnecting = false;
|
|
300
|
+
|
|
301
|
+
function connect() {
|
|
302
|
+
// Se siamo in simulazione o se c'è già un tentativo in corso, ci fermiamo
|
|
303
|
+
if (simulationMode || isConnecting) return;
|
|
304
|
+
|
|
305
|
+
// Se il socket esiste ed è già aperto o in fase di apertura, non facciamo nulla
|
|
306
|
+
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
isConnecting = true;
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
socket = new WebSocket(`ws://${SIGNALK_SERVER_IP}/signalk/v1/stream?subscribe=all`);
|
|
314
|
+
|
|
315
|
+
socket.onopen = () => {
|
|
316
|
+
isConnecting = false;
|
|
317
|
+
ui.status.className = "online";
|
|
318
|
+
ui.status.innerText = "ONLINE";
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
socket.onmessage = (e) => {
|
|
322
|
+
if (simulationMode) return;
|
|
323
|
+
const d = JSON.parse(e.data);
|
|
324
|
+
if (d.updates) d.updates.forEach(u => u.values && u.values.forEach(v => processIncomingData(v.path, v.value)));
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
socket.onerror = (err) => {
|
|
328
|
+
// Non forziamo la chiusura qui. Lasciamo che Safari gestisca l'errore
|
|
329
|
+
// in modo nativo e faccia scattare onclose da solo.
|
|
330
|
+
isConnecting = false;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
socket.onclose = () => {
|
|
334
|
+
isConnecting = false;
|
|
335
|
+
if (!simulationMode) {
|
|
336
|
+
ui.status.className = "offline";
|
|
337
|
+
ui.status.innerText = "OFFLINE";
|
|
338
|
+
// Attendiamo ben 5 secondi prima di riprovare, per non infastidire Safari
|
|
339
|
+
setTimeout(connect, 5000);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
} catch (e) {
|
|
343
|
+
isConnecting = false;
|
|
344
|
+
setTimeout(connect, 5000);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
// ==========================================================================
|
|
350
|
+
// 8. SIMULATORE / MOTORE FISICO
|
|
351
|
+
// ==========================================================================
|
|
352
|
+
const physicsEngine = {
|
|
353
|
+
time: 0,
|
|
354
|
+
config: { hdgStart: Math.random() * 360, hdgDir: Math.random() > 0.5 ? 1 : -1, twdBase: Math.random() * 360, rotSpeed: 360 / 600 },
|
|
355
|
+
getPolars: function(twaDeg, twsKts) {
|
|
356
|
+
let eff = 0; const aT = Math.abs(twaDeg);
|
|
357
|
+
if (aT < 30) eff = 0.1; else if (aT < 45) eff = 0.75; else if (aT < 90) eff = 1.0; else if (aT < 150) eff = 0.85; else eff = 0.65;
|
|
358
|
+
return Math.min(Math.sqrt(twsKts) * 2.2 * eff, 11.0);
|
|
359
|
+
},
|
|
360
|
+
step: function() {
|
|
361
|
+
this.time++;
|
|
362
|
+
const hdgDeg = (this.config.hdgStart + (this.time * this.config.rotSpeed * this.config.hdgDir) + 360) % 360;
|
|
363
|
+
const twdDeg = (this.config.twdBase + Math.sin(this.time / 20) * 15 + 360) % 360;
|
|
364
|
+
const twsKts = 14 + Math.sin(this.time / 40) * 6;
|
|
365
|
+
const depthM = 20 + Math.sin(this.time / 25) * 18;
|
|
366
|
+
let twaDeg = (twdDeg - hdgDeg + 360) % 360; if (twaDeg > 180) twaDeg -= 360;
|
|
367
|
+
const stwKts = this.getPolars(twaDeg, twsKts);
|
|
368
|
+
const stwMs = ktsToMs(stwKts), twsMs = ktsToMs(twsKts), twaRad = degToRad(twaDeg);
|
|
369
|
+
const vAx = twsMs * Math.sin(twaRad), vAy = twsMs * Math.cos(twaRad) + stwMs;
|
|
370
|
+
const awsMs = Math.sqrt(vAx*vAx + vAy*vAy), awaRad = Math.atan2(vAx, vAy);
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
"navigation.headingTrue": degToRad(hdgDeg),
|
|
374
|
+
"navigation.courseOverGroundTrue": degToRad((hdgDeg + 4) % 360), // COG fittizio simulato
|
|
375
|
+
"navigation.speedThroughWater": stwMs,
|
|
376
|
+
"navigation.speedOverGround": stwMs * 1.05,
|
|
377
|
+
"environment.wind.speedTrue": twsMs,
|
|
378
|
+
"environment.wind.directionTrue": degToRad(twdDeg),
|
|
379
|
+
"environment.wind.angleTrueWater": twaRad,
|
|
380
|
+
"environment.wind.speedApparent": awsMs,
|
|
381
|
+
"environment.wind.angleApparent": awaRad,
|
|
382
|
+
"environment.depth.belowTransducer": depthM
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
let dC = 0, lC = 0;
|
|
388
|
+
ui.depth.closest('.data-box').addEventListener('click', () => {
|
|
389
|
+
const n = Date.now(); if (n - lC < 500) dC++; else dC = 1; lC = n;
|
|
390
|
+
if (dC === 3) {
|
|
391
|
+
simulationMode = !simulationMode;
|
|
392
|
+
if (simulationMode) {
|
|
393
|
+
if (socket) socket.close(); ui.status.innerText = "SIM ATTIVO";
|
|
394
|
+
if(simInterval) clearInterval(simInterval);
|
|
395
|
+
simInterval = setInterval(() => { const simulatedData = physicsEngine.step(); for (let path in simulatedData) processIncomingData(path, simulatedData[path]); }, 200);
|
|
396
|
+
} else location.reload();
|
|
397
|
+
dC = 0;
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// ==========================================================================
|
|
402
|
+
// 9. FUNZIONI UI GRAFICHE (Sparklines, Leeway, Ticks)
|
|
403
|
+
// ==========================================================================
|
|
404
|
+
function updateLeewayDisplay(deg) {
|
|
405
|
+
const c = 125, px = 125/20; let w = Math.min(Math.abs(deg)*px, 125);
|
|
406
|
+
ui.leewayMask.setAttribute('x', deg >= 0 ? c : c - w); ui.leewayMask.setAttribute('width', w);
|
|
407
|
+
ui.leewayVal.textContent = `LEEWAY: ${deg.toFixed(1)}°`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function manageHistory(t, v) {
|
|
411
|
+
const n = Date.now(), i = simulationMode ? SIM_SAMPLE_INTERVAL : REAL_SAMPLE_INTERVAL;
|
|
412
|
+
if (n - store.lastUpdates[t] > i || store.histories[t].length === 0) {
|
|
413
|
+
store.histories[t].push(v); if (store.histories[t].length > MAX_HISTORY_SAMPLES) store.histories[t].shift(); store.lastUpdates[t] = n;
|
|
414
|
+
}
|
|
415
|
+
const m = Math.max(...store.histories[t], 1); const c = getDynamicScale(t, m);
|
|
416
|
+
updateScaleLabels(t, c.scale); drawGraph(store.histories[t], t + '-graph', c.scale, c.gridLines);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function getDynamicScale(t, m) {
|
|
420
|
+
if (t === 'stw' || t === 'sog') return { scale: 12, gridLines: [3, 6, 9] };
|
|
421
|
+
const s = { tws: [10, 25, 45, 60], depth: [10, 20, 50, 200] };
|
|
422
|
+
const av = s[t] || [10, 20, 50]; let sel = av[av.length - 1];
|
|
423
|
+
for (let x of av) if (m <= x) { sel = x; break; }
|
|
424
|
+
return { scale: sel, gridLines: [sel/4, sel/2, (sel/4)*3] };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function updateScaleLabels(t, s) { const el = document.getElementById(t + '-scale'); if (el) el.innerHTML = `<span>${s}</span><span>${s/2}</span><span>0</span>`; }
|
|
428
|
+
|
|
429
|
+
function drawGraph(d, id, s, gl) {
|
|
430
|
+
const svg = document.getElementById(id); if (!svg || d.length < 2) return;
|
|
431
|
+
const w = 200, h = 40; let gH = "";
|
|
432
|
+
gl.forEach(v => { const y = h - (v / s) * h; gH += `<line x1="0" y1="${y}" x2="${w}" y2="${y}" stroke="rgba(255,255,255,0.08)" stroke-width="0.5" />`; });
|
|
433
|
+
let p = ""; d.forEach((v, i) => { const x = (i/(MAX_HISTORY_SAMPLES - 1))*w; const y = h - (Math.min(v, s)/s)*h; p += `${i===0?'M':'L'} ${x} ${y} `; });
|
|
434
|
+
const aP = p + ` L ${((d.length-1)/(MAX_HISTORY_SAMPLES - 1))*w} ${h} L 0 ${h} Z`;
|
|
435
|
+
const clrs = { 'stw-graph': { s: '#2ecc71', f: 'rgba(46, 204, 113, 0.15)' }, 'sog-graph': { s: '#f39c12', f: 'rgba(243, 156, 18, 0.15)' }, 'depth-graph': { s: '#3498db', f: 'rgba(52, 152, 219, 0.15)' }, 'tws-graph': { s: '#f1c40f', f: 'rgba(241, 196, 15, 0.15)' } };
|
|
436
|
+
svg.innerHTML = `${gH}<path d="${aP}" fill="${clrs[id].f}" stroke="none" /><path d="${p}" fill="none" stroke="${clrs[id].s}" stroke-width="1.5" />`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function generateTicks() {
|
|
440
|
+
const c = document.getElementById('ticks');
|
|
441
|
+
for (let i = 0; i < 360; i += 10) {
|
|
442
|
+
const l = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
|
443
|
+
const m = i % 30 === 0; l.setAttribute("x1", "200"); l.setAttribute("y1", "40");
|
|
444
|
+
l.setAttribute("x2", "200"); l.setAttribute("y2", (m ? 60 : 50));
|
|
445
|
+
l.setAttribute("stroke", m ? "#fff" : "#666");
|
|
446
|
+
l.setAttribute("stroke-width", m ? "2" : "1");
|
|
447
|
+
l.setAttribute("transform", `rotate(${i}, 200, 200)`);
|
|
448
|
+
c.appendChild(l);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ==========================================================================
|
|
453
|
+
// 10. INIZIALIZZAZIONE (Attesa caricamento DOM per Safari)
|
|
454
|
+
// ==========================================================================
|
|
455
|
+
generateTicks();
|
|
456
|
+
startDisplayLoop();
|
|
457
|
+
|
|
458
|
+
// Attendiamo che il browser abbia finito di renderizzare la grafica
|
|
459
|
+
// prima di aprire il socket, evita blocchi su iPad/iPhone.
|
|
460
|
+
window.addEventListener('load', () => {
|
|
461
|
+
setTimeout(connect, 500);
|
|
462
|
+
});
|
package/app.js.txt
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
const SIGNALK_SERVER_IP = "192.168.111.240:3000";
|
|
2
|
+
const ALARM_DANGER_DEPTH = 2.5;
|
|
3
|
+
const ALARM_WARNING_DEPTH = 5.0;
|
|
4
|
+
|
|
5
|
+
const SMOOTH_WINDOW_MS = 2000;
|
|
6
|
+
const LONG_AVG_WINDOW_MS = 60000;
|
|
7
|
+
const RENDER_INTERVAL_MS = 1000;
|
|
8
|
+
const TIMEOUT_MS = 5000;
|
|
9
|
+
|
|
10
|
+
const MAX_HISTORY_SAMPLES = 60;
|
|
11
|
+
const REAL_SAMPLE_INTERVAL = 5000;
|
|
12
|
+
const SIM_SAMPLE_INTERVAL = 1000;
|
|
13
|
+
|
|
14
|
+
let simulationMode = false;
|
|
15
|
+
let socket;
|
|
16
|
+
let renderInterval = null;
|
|
17
|
+
let simInterval = null;
|
|
18
|
+
let lastAvgUIUpdate = 0;
|
|
19
|
+
let audioCtx = null, lastAlarmTime = 0;
|
|
20
|
+
|
|
21
|
+
let curAwaRot = 0, curTwaRot = 0, curTrackRot = 0, curTwdRoseRot = 0;
|
|
22
|
+
|
|
23
|
+
const store = {
|
|
24
|
+
raw: {},
|
|
25
|
+
timestamps: {},
|
|
26
|
+
smoothBuf: { hdg: [], awa: [], twa: [], twd: [], leeway: [] },
|
|
27
|
+
longBuf: { awa: [], twa: [], twd: [] },
|
|
28
|
+
histories: { stw: [], sog: [], depth: [], tws: [] },
|
|
29
|
+
lastUpdates: { stw: 0, sog: 0, depth: 0, tws: 0 }
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const ui = {
|
|
33
|
+
stw: document.getElementById('stw'), sog: document.getElementById('sog'),
|
|
34
|
+
hdg: document.getElementById('hdg'), cog: document.getElementById('cog'),
|
|
35
|
+
awsSvg: document.getElementById('aws-val-svg'), awa: document.getElementById('awa-pointer'),
|
|
36
|
+
twa: document.getElementById('twa-pointer'), track: document.getElementById('track-pointer'),
|
|
37
|
+
tws: document.getElementById('tws'), depth: document.getElementById('depth'),
|
|
38
|
+
twaAvg: document.getElementById('twa-avg'), awaAvg: document.getElementById('awa-avg'),
|
|
39
|
+
twdAvg: document.getElementById('twd-avg'), twdArrow: document.getElementById('twd-arrow'),
|
|
40
|
+
leewayMask: document.getElementById('leeway-mask-rect'), leewayVal: document.getElementById('leeway-val'),
|
|
41
|
+
status: document.getElementById('status')
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function radToDeg(rad) { return rad * (180 / Math.PI); }
|
|
45
|
+
function degToRad(deg) { return deg * (Math.PI / 180); }
|
|
46
|
+
function msToKts(ms) { return ms * 1.94384; }
|
|
47
|
+
function ktsToMs(kts) { return kts / 1.94384; }
|
|
48
|
+
|
|
49
|
+
function getShortestRotation(curr, target) {
|
|
50
|
+
let diff = (target - curr) % 360;
|
|
51
|
+
if (diff > 180) diff -= 360; else if (diff < -180) diff += 360;
|
|
52
|
+
return curr + diff;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getCircularAverageFromBuffer(bufferArray, windowMs, signed = false) {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const validData = bufferArray.filter(item => (now - item.time) <= windowMs);
|
|
58
|
+
if (validData.length === 0) return null;
|
|
59
|
+
let sumSin = 0, sumCos = 0;
|
|
60
|
+
validData.forEach(item => { sumSin += Math.sin(item.val); sumCos += Math.cos(item.val); });
|
|
61
|
+
let avgRad = Math.atan2(sumSin, sumCos);
|
|
62
|
+
let avgDeg = Math.round(radToDeg(avgRad));
|
|
63
|
+
return signed ? avgDeg : (avgDeg + 360) % 360;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function cleanBuffer(bufferArray, windowMs) {
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
while (bufferArray.length > 0 && (now - bufferArray[0].time) > windowMs) bufferArray.shift();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function processIncomingData(path, val) {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
store.timestamps[path] = now;
|
|
74
|
+
store.raw[path] = val;
|
|
75
|
+
|
|
76
|
+
if (path === "navigation.headingMagnetic") store.smoothBuf.hdg.push({ val: val, time: now });
|
|
77
|
+
if (path === "environment.wind.angleApparent") { store.smoothBuf.awa.push({ val: val, time: now }); store.longBuf.awa.push({ val: val, time: now }); }
|
|
78
|
+
if (path === "environment.wind.angleTrueWater") { store.smoothBuf.twa.push({ val: val, time: now }); store.longBuf.twa.push({ val: val, time: now }); }
|
|
79
|
+
|
|
80
|
+
if (path === "environment.wind.angleTrueWater" || path === "navigation.headingMagnetic") {
|
|
81
|
+
if (store.raw["navigation.headingMagnetic"] !== undefined && store.raw["environment.wind.angleTrueWater"] !== undefined) {
|
|
82
|
+
let twdRad = (store.raw["navigation.headingMagnetic"] + store.raw["environment.wind.angleTrueWater"]) % (2 * Math.PI);
|
|
83
|
+
if (twdRad < 0) twdRad += (2 * Math.PI);
|
|
84
|
+
store.raw["environment.wind.directionTrue"] = twdRad;
|
|
85
|
+
store.timestamps["environment.wind.directionTrue"] = now;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (store.raw["environment.wind.directionTrue"] !== undefined) {
|
|
90
|
+
store.smoothBuf.twd.push({ val: store.raw["environment.wind.directionTrue"], time: now });
|
|
91
|
+
store.longBuf.twd.push({ val: store.raw["environment.wind.directionTrue"], time: now });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (path === "environment.wind.angleTrueWater" || path === "environment.wind.speedTrue" || path === "navigation.speedThroughWater") {
|
|
95
|
+
if (store.raw["environment.wind.angleTrueWater"] !== undefined && store.raw["environment.wind.speedTrue"] !== undefined && store.raw["navigation.speedThroughWater"] !== undefined) {
|
|
96
|
+
const twsKts = msToKts(store.raw["environment.wind.speedTrue"]);
|
|
97
|
+
const stwKts = msToKts(store.raw["navigation.speedThroughWater"]);
|
|
98
|
+
const stwSafe = Math.max(stwKts, 0.1);
|
|
99
|
+
let leewayDeg = - (12 * twsKts / (stwSafe * stwSafe)) * Math.sin(store.raw["environment.wind.angleTrueWater"]);
|
|
100
|
+
leewayDeg = Math.max(-20, Math.min(20, leewayDeg));
|
|
101
|
+
store.raw["navigation.leewayAngle"] = degToRad(leewayDeg);
|
|
102
|
+
store.timestamps["navigation.leewayAngle"] = now;
|
|
103
|
+
store.smoothBuf.leeway.push({ val: degToRad(leewayDeg), time: now });
|
|
104
|
+
}
|
|
105
|
+
} else if (path === "navigation.leewayAngle") {
|
|
106
|
+
store.smoothBuf.leeway.push({ val: val, time: now });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- RENDER ENGINE PRINCIPALE (1Hz) ---
|
|
111
|
+
function startDisplayLoop() {
|
|
112
|
+
if (renderInterval) clearInterval(renderInterval);
|
|
113
|
+
|
|
114
|
+
renderInterval = setInterval(() => {
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
|
|
117
|
+
// 1. Watchdog Timeout
|
|
118
|
+
const pathsToWatch = {
|
|
119
|
+
"navigation.speedThroughWater": ui.stw, "navigation.speedOverGround": ui.sog,
|
|
120
|
+
"navigation.headingMagnetic": ui.hdg, "environment.wind.speedApparent": ui.awsSvg,
|
|
121
|
+
"environment.depth.belowTransducer": ui.depth, "environment.wind.speedTrue": ui.tws
|
|
122
|
+
};
|
|
123
|
+
for (let p in pathsToWatch) {
|
|
124
|
+
if (!store.timestamps[p] || (now - store.timestamps[p] > TIMEOUT_MS)) {
|
|
125
|
+
pathsToWatch[p][pathsToWatch[p] === ui.awsSvg ? 'textContent' : 'innerText'] = "---";
|
|
126
|
+
if (p === "environment.depth.belowTransducer") ui.depth.classList.remove('alarm-warning', 'alarm-danger');
|
|
127
|
+
delete store.raw[p];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 2. Render Istantanei
|
|
132
|
+
if (store.raw["navigation.speedThroughWater"] !== undefined) {
|
|
133
|
+
const stw = msToKts(store.raw["navigation.speedThroughWater"]); ui.stw.innerText = stw.toFixed(1); manageHistory('stw', stw);
|
|
134
|
+
}
|
|
135
|
+
if (store.raw["navigation.speedOverGround"] !== undefined) {
|
|
136
|
+
const sog = msToKts(store.raw["navigation.speedOverGround"]); ui.sog.innerText = sog.toFixed(1); manageHistory('sog', sog);
|
|
137
|
+
}
|
|
138
|
+
if (store.raw["environment.depth.belowTransducer"] !== undefined) {
|
|
139
|
+
const d = store.raw["environment.depth.belowTransducer"]; ui.depth.innerText = d.toFixed(1); checkDepthAlarm(d); manageHistory('depth', d);
|
|
140
|
+
}
|
|
141
|
+
if (store.raw["environment.wind.speedTrue"] !== undefined) {
|
|
142
|
+
const tws = msToKts(store.raw["environment.wind.speedTrue"]); ui.tws.innerText = tws.toFixed(1); manageHistory('tws', tws);
|
|
143
|
+
}
|
|
144
|
+
if (store.raw["environment.wind.speedApparent"] !== undefined) ui.awsSvg.textContent = msToKts(store.raw["environment.wind.speedApparent"]).toFixed(1);
|
|
145
|
+
|
|
146
|
+
// 3. Render Smussati (2s)
|
|
147
|
+
const smoothHdg = getCircularAverageFromBuffer(store.smoothBuf.hdg, SMOOTH_WINDOW_MS, false);
|
|
148
|
+
const smoothLeeway = getCircularAverageFromBuffer(store.smoothBuf.leeway, SMOOTH_WINDOW_MS, true);
|
|
149
|
+
|
|
150
|
+
if (smoothHdg !== null) {
|
|
151
|
+
ui.hdg.innerHTML = `${Math.round(smoothHdg).toString().padStart(3, '0')}°`; // Aggiunto gradi in chiaro
|
|
152
|
+
if (smoothLeeway !== null) {
|
|
153
|
+
let cog = (smoothHdg + smoothLeeway + 360) % 360;
|
|
154
|
+
ui.cog.innerHTML = `${Math.round(cog).toString().padStart(3, '0')}°`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const smoothAwa = getCircularAverageFromBuffer(store.smoothBuf.awa, SMOOTH_WINDOW_MS, true);
|
|
159
|
+
if (smoothAwa !== null) { curAwaRot = getShortestRotation(curAwaRot, smoothAwa); ui.awa.setAttribute('transform', `rotate(${curAwaRot}, 200, 200)`); }
|
|
160
|
+
|
|
161
|
+
const smoothTwa = getCircularAverageFromBuffer(store.smoothBuf.twa, SMOOTH_WINDOW_MS, true);
|
|
162
|
+
if (smoothTwa !== null) { curTwaRot = getShortestRotation(curTwaRot, smoothTwa); ui.twa.setAttribute('transform', `rotate(${curTwaRot}, 200, 200)`); }
|
|
163
|
+
|
|
164
|
+
if (smoothLeeway !== null) {
|
|
165
|
+
updateLeewayDisplay(smoothLeeway);
|
|
166
|
+
curTrackRot = getShortestRotation(curTrackRot, smoothLeeway); ui.track.setAttribute('transform', `rotate(${curTrackRot}, 200, 200)`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 4. Render Medie Lunghe (60s)
|
|
170
|
+
if (now - lastAvgUIUpdate > 3000) {
|
|
171
|
+
let awA = getCircularAverageFromBuffer(store.longBuf.awa, LONG_AVG_WINDOW_MS, true);
|
|
172
|
+
let twA = getCircularAverageFromBuffer(store.longBuf.twa, LONG_AVG_WINDOW_MS, true);
|
|
173
|
+
let twD = getCircularAverageFromBuffer(store.longBuf.twd, LONG_AVG_WINDOW_MS, false);
|
|
174
|
+
|
|
175
|
+
ui.awaAvg.innerHTML = awA === null ? `---°` : `${awA.toString().padStart(3, '0')}°`;
|
|
176
|
+
ui.twaAvg.innerHTML = twA === null ? `---°` : `${twA.toString().padStart(3, '0')}°`;
|
|
177
|
+
ui.twdAvg.innerHTML = twD === null ? `---°` : `${twD.toString().padStart(3, '0')}°`;
|
|
178
|
+
|
|
179
|
+
if (twD !== null) { curTwdRoseRot = getShortestRotation(curTwdRoseRot, twD); ui.twdArrow.setAttribute('transform', `rotate(${curTwdRoseRot}, 20, 20)`); }
|
|
180
|
+
lastAvgUIUpdate = now;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
cleanBuffer(store.smoothBuf.hdg, SMOOTH_WINDOW_MS); cleanBuffer(store.smoothBuf.awa, SMOOTH_WINDOW_MS);
|
|
184
|
+
cleanBuffer(store.smoothBuf.twa, SMOOTH_WINDOW_MS); cleanBuffer(store.smoothBuf.twd, SMOOTH_WINDOW_MS);
|
|
185
|
+
cleanBuffer(store.smoothBuf.leeway, SMOOTH_WINDOW_MS); cleanBuffer(store.longBuf.awa, LONG_AVG_WINDOW_MS);
|
|
186
|
+
cleanBuffer(store.longBuf.twa, LONG_AVG_WINDOW_MS); cleanBuffer(store.longBuf.twd, LONG_AVG_WINDOW_MS);
|
|
187
|
+
|
|
188
|
+
}, RENDER_INTERVAL_MS);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
document.addEventListener('click', () => { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }, { once: true });
|
|
192
|
+
function playBingBing() {
|
|
193
|
+
if (!audioCtx) return; const n = Date.now(); if (n - lastAlarmTime < 3000) return; lastAlarmTime = n;
|
|
194
|
+
function b(f, s) {
|
|
195
|
+
const o = audioCtx.createOscillator(); const g = audioCtx.createGain();
|
|
196
|
+
o.connect(g); g.connect(audioCtx.destination); o.frequency.value = f;
|
|
197
|
+
g.gain.setValueAtTime(0.1, s); g.gain.exponentialRampToValueAtTime(0.01, s + 0.4);
|
|
198
|
+
o.start(s); o.stop(s + 0.5);
|
|
199
|
+
}
|
|
200
|
+
b(880, audioCtx.currentTime); b(880, audioCtx.currentTime + 0.6);
|
|
201
|
+
}
|
|
202
|
+
function checkDepthAlarm(m) {
|
|
203
|
+
ui.depth.classList.remove('alarm-warning', 'alarm-danger');
|
|
204
|
+
if (m < ALARM_DANGER_DEPTH) { ui.depth.classList.add('alarm-danger'); playBingBing(); }
|
|
205
|
+
else if (m < ALARM_WARNING_DEPTH) ui.depth.classList.add('alarm-warning');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function connect() {
|
|
209
|
+
if (simulationMode) return;
|
|
210
|
+
socket = new WebSocket(`ws://${SIGNALK_SERVER_IP}/signalk/v1/stream?subscribe=all`);
|
|
211
|
+
socket.onopen = () => { ui.status.className = "online"; ui.status.innerText = "ONLINE"; };
|
|
212
|
+
socket.onmessage = (e) => {
|
|
213
|
+
if (simulationMode) return;
|
|
214
|
+
const d = JSON.parse(e.data);
|
|
215
|
+
if (d.updates) d.updates.forEach(u => u.values && u.values.forEach(v => processIncomingData(v.path, v.value)));
|
|
216
|
+
};
|
|
217
|
+
socket.onclose = () => !simulationMode && setTimeout(connect, 5000);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const physicsEngine = {
|
|
221
|
+
time: 0,
|
|
222
|
+
config: { hdgStart: Math.random() * 360, hdgDir: Math.random() > 0.5 ? 1 : -1, twdBase: Math.random() * 360, rotSpeed: 360 / 600 },
|
|
223
|
+
getPolars: function(twaDeg, twsKts) {
|
|
224
|
+
let eff = 0; const aT = Math.abs(twaDeg);
|
|
225
|
+
if (aT < 30) eff = 0.1; else if (aT < 45) eff = 0.75; else if (aT < 90) eff = 1.0; else if (aT < 150) eff = 0.85; else eff = 0.65;
|
|
226
|
+
return Math.min(Math.sqrt(twsKts) * 2.2 * eff, 11.0);
|
|
227
|
+
},
|
|
228
|
+
step: function() {
|
|
229
|
+
this.time++;
|
|
230
|
+
const hdgDeg = (this.config.hdgStart + (this.time * this.config.rotSpeed * this.config.hdgDir) + 360) % 360;
|
|
231
|
+
const twdDeg = (this.config.twdBase + Math.sin(this.time / 20) * 15 + 360) % 360;
|
|
232
|
+
const twsKts = 14 + Math.sin(this.time / 40) * 6;
|
|
233
|
+
const depthM = 20 + Math.sin(this.time / 25) * 18;
|
|
234
|
+
let twaDeg = (twdDeg - hdgDeg + 360) % 360; if (twaDeg > 180) twaDeg -= 360;
|
|
235
|
+
const stwKts = this.getPolars(twaDeg, twsKts);
|
|
236
|
+
const stwMs = ktsToMs(stwKts), twsMs = ktsToMs(twsKts), twaRad = degToRad(twaDeg);
|
|
237
|
+
const vAx = twsMs * Math.sin(twaRad), vAy = twsMs * Math.cos(twaRad) + stwMs;
|
|
238
|
+
const awsMs = Math.sqrt(vAx*vAx + vAy*vAy), awaRad = Math.atan2(vAx, vAy);
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
"navigation.headingMagnetic": degToRad(hdgDeg),
|
|
242
|
+
"navigation.speedThroughWater": stwMs, "navigation.speedOverGround": stwMs * 1.05,
|
|
243
|
+
"environment.wind.speedTrue": twsMs, "environment.wind.directionTrue": degToRad(twdDeg),
|
|
244
|
+
"environment.wind.angleTrueWater": twaRad, "environment.wind.speedApparent": awsMs,
|
|
245
|
+
"environment.wind.angleApparent": awaRad, "environment.depth.belowTransducer": depthM
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
let dC = 0, lC = 0;
|
|
251
|
+
ui.depth.closest('.data-box').addEventListener('click', () => {
|
|
252
|
+
const n = Date.now(); if (n - lC < 500) dC++; else dC = 1; lC = n;
|
|
253
|
+
if (dC === 3) {
|
|
254
|
+
simulationMode = !simulationMode;
|
|
255
|
+
if (simulationMode) {
|
|
256
|
+
if (socket) socket.close(); ui.status.innerText = "SIM ENGINE ATTIVO";
|
|
257
|
+
if(simInterval) clearInterval(simInterval);
|
|
258
|
+
simInterval = setInterval(() => { const simulatedData = physicsEngine.step(); for (let path in simulatedData) processIncomingData(path, simulatedData[path]); }, 200);
|
|
259
|
+
} else location.reload();
|
|
260
|
+
dC = 0;
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
function updateLeewayDisplay(deg) {
|
|
265
|
+
const c = 125, px = 125/20; let w = Math.min(Math.abs(deg)*px, 125);
|
|
266
|
+
ui.leewayMask.setAttribute('x', deg >= 0 ? c : c - w); ui.leewayMask.setAttribute('width', w);
|
|
267
|
+
ui.leewayVal.textContent = `LEEWAY: ${deg.toFixed(1)}°`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function manageHistory(t, v) {
|
|
271
|
+
const n = Date.now(), i = simulationMode ? SIM_SAMPLE_INTERVAL : REAL_SAMPLE_INTERVAL;
|
|
272
|
+
if (n - store.lastUpdates[t] > i || store.histories[t].length === 0) {
|
|
273
|
+
store.histories[t].push(v); if (store.histories[t].length > MAX_HISTORY_SAMPLES) store.histories[t].shift(); store.lastUpdates[t] = n;
|
|
274
|
+
}
|
|
275
|
+
const m = Math.max(...store.histories[t], 1); const c = getDynamicScale(t, m);
|
|
276
|
+
updateScaleLabels(t, c.scale); drawGraph(store.histories[t], t + '-graph', c.scale, c.gridLines);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function getDynamicScale(t, m) {
|
|
280
|
+
if (t === 'stw' || t === 'sog') return { scale: 12, gridLines: [3, 6, 9] };
|
|
281
|
+
const s = { tws: [10, 25, 45, 60], depth: [10, 20, 50, 200] };
|
|
282
|
+
const av = s[t] || [10, 20, 50]; let sel = av[av.length - 1];
|
|
283
|
+
for (let x of av) if (m <= x) { sel = x; break; }
|
|
284
|
+
return { scale: sel, gridLines: [sel/4, sel/2, (sel/4)*3] };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function updateScaleLabels(t, s) { const el = document.getElementById(t + '-scale'); if (el) el.innerHTML = `<span>${s}</span><span>${s/2}</span><span>0</span>`; }
|
|
288
|
+
|
|
289
|
+
function drawGraph(d, id, s, gl) {
|
|
290
|
+
const svg = document.getElementById(id); if (!svg || d.length < 2) return;
|
|
291
|
+
const w = 200, h = 40; let gH = "";
|
|
292
|
+
gl.forEach(v => { const y = h - (v / s) * h; gH += `<line x1="0" y1="${y}" x2="${w}" y2="${y}" stroke="rgba(255,255,255,0.08)" stroke-width="0.5" />`; });
|
|
293
|
+
let p = ""; d.forEach((v, i) => { const x = (i/(MAX_HISTORY_SAMPLES - 1))*w; const y = h - (Math.min(v, s)/s)*h; p += `${i===0?'M':'L'} ${x} ${y} `; });
|
|
294
|
+
const aP = p + ` L ${((d.length-1)/(MAX_HISTORY_SAMPLES - 1))*w} ${h} L 0 ${h} Z`;
|
|
295
|
+
const clrs = { 'stw-graph': { s: '#2ecc71', f: 'rgba(46, 204, 113, 0.15)' }, 'sog-graph': { s: '#f39c12', f: 'rgba(243, 156, 18, 0.15)' }, 'depth-graph': { s: '#3498db', f: 'rgba(52, 152, 219, 0.15)' }, 'tws-graph': { s: '#f1c40f', f: 'rgba(241, 196, 15, 0.15)' } };
|
|
296
|
+
svg.innerHTML = `${gH}<path d="${aP}" fill="${clrs[id].f}" stroke="none" /><path d="${p}" fill="none" stroke="${clrs[id].s}" stroke-width="1.5" />`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function generateTicks() {
|
|
300
|
+
const c = document.getElementById('ticks');
|
|
301
|
+
for (let i = 0; i < 360; i += 10) {
|
|
302
|
+
const l = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
|
303
|
+
const m = i % 30 === 0; l.setAttribute("x1", "200"); l.setAttribute("y1", "40");
|
|
304
|
+
l.setAttribute("x2", "200"); l.setAttribute("y2", (m ? 60 : 50));
|
|
305
|
+
l.setAttribute("stroke", m ? "#fff" : "#666");
|
|
306
|
+
l.setAttribute("stroke-width", m ? "2" : "1");
|
|
307
|
+
l.setAttribute("transform", `rotate(${i}, 200, 200)`);
|
|
308
|
+
c.appendChild(l);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
generateTicks(); startDisplayLoop(); connect();
|
package/index.html
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="it">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
6
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
7
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
8
|
+
<link rel="manifest" href="manifest.json">
|
|
9
|
+
<title>Sailing Dashboard Pro - Final Details</title>
|
|
10
|
+
<link rel="stylesheet" href="style.css">
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
|
|
14
|
+
<!-- Etichetta di stato connessione SignalK -->
|
|
15
|
+
<div id="status" class="offline">OFFLINE</div>
|
|
16
|
+
|
|
17
|
+
<div class="main-container">
|
|
18
|
+
|
|
19
|
+
<!-- ======================================================= -->
|
|
20
|
+
<!-- COLONNA SINISTRA: Dati di navigazione e rotta -->
|
|
21
|
+
<!-- ======================================================= -->
|
|
22
|
+
<div class="side-panel">
|
|
23
|
+
|
|
24
|
+
<!-- STW con Grafico Dinamico (preserveAspectRatio="none" lo adatta in altezza) -->
|
|
25
|
+
<div class="data-box">
|
|
26
|
+
<div class="label-row"><span class="label">STW</span><span class="unit">kts</span></div>
|
|
27
|
+
<span class="value" id="stw">0.0</span>
|
|
28
|
+
<div class="graph-wrapper">
|
|
29
|
+
<svg class="sparkline" id="stw-graph" viewBox="0 0 200 40" preserveAspectRatio="none"></svg>
|
|
30
|
+
<div class="scale-labels right" id="stw-scale"></div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<!-- SOG con Grafico Dinamico -->
|
|
35
|
+
<div class="data-box">
|
|
36
|
+
<div class="label-row"><span class="label">SOG</span><span class="unit">kts</span></div>
|
|
37
|
+
<span class="value" id="sog">0.0</span>
|
|
38
|
+
<div class="graph-wrapper">
|
|
39
|
+
<svg class="sparkline" id="sog-graph" viewBox="0 0 200 40" preserveAspectRatio="none"></svg>
|
|
40
|
+
<div class="scale-labels right" id="sog-scale"></div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<!-- Dati Singoli (value-large gestisce font dinamico e allineamento in basso) -->
|
|
45
|
+
<div class="data-box">
|
|
46
|
+
<div class="label-row"><span class="label">HEADING MEAN</span></div>
|
|
47
|
+
<span class="value value-large" id="hdg">000°</span>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="data-box">
|
|
51
|
+
<div class="label-row"><span class="label">COG MEAN</span></div>
|
|
52
|
+
<span class="value value-large" id="cog">000°</span>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<!-- TACK: Previsione Prua e COG sulle mure opposte (Layout a doppia colonna) -->
|
|
56
|
+
<div class="data-box">
|
|
57
|
+
<div class="label-row"><span class="label">TACK</span></div>
|
|
58
|
+
<div class="dual-value-container">
|
|
59
|
+
<div class="dual-value-col">
|
|
60
|
+
<span class="dual-label">HDG</span>
|
|
61
|
+
<span class="value dual-val" id="tack-hdg">---°</span>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="dual-value-col right-col">
|
|
64
|
+
<span class="dual-label">COG</span>
|
|
65
|
+
<span class="value dual-val" id="tack-cog">---°</span>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<!-- ======================================================= -->
|
|
73
|
+
<!-- CENTRO: Strumento principale del vento e scarroccio -->
|
|
74
|
+
<!-- ======================================================= -->
|
|
75
|
+
<div class="center-panel">
|
|
76
|
+
<svg id="wind-gauge" viewBox="30 20 340 440" preserveAspectRatio="xMidYMid meet">
|
|
77
|
+
<defs>
|
|
78
|
+
<linearGradient id="axiom-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
79
|
+
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:1" />
|
|
80
|
+
<stop offset="100%" style="stop-color:#888888;stop-opacity:1" />
|
|
81
|
+
</linearGradient>
|
|
82
|
+
<linearGradient id="leeway-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
83
|
+
<stop offset="0%" style="stop-color:#ff0000;stop-opacity:1" />
|
|
84
|
+
<stop offset="25%" style="stop-color:#ff8800;stop-opacity:1" />
|
|
85
|
+
<stop offset="50%" style="stop-color:#00ff00;stop-opacity:1" />
|
|
86
|
+
<stop offset="75%" style="stop-color:#ff8800;stop-opacity:1" />
|
|
87
|
+
<stop offset="100%" style="stop-color:#ff0000;stop-opacity:1" />
|
|
88
|
+
</linearGradient>
|
|
89
|
+
<clipPath id="leeway-clip">
|
|
90
|
+
<rect id="leeway-mask-rect" x="125" y="0" width="0" height="12" rx="2" />
|
|
91
|
+
</clipPath>
|
|
92
|
+
</defs>
|
|
93
|
+
|
|
94
|
+
<!-- Sfondo Quadrante -->
|
|
95
|
+
<circle cx="200" cy="200" r="160" fill="#050505" />
|
|
96
|
+
<circle cx="200" cy="200" r="125" fill="#121212" />
|
|
97
|
+
|
|
98
|
+
<!-- Settori Colorati: Rosso (sx), Verde (dx), Arancio (poppa) -->
|
|
99
|
+
<path d="M 82.0 101.0 A 154 154 0 0 1 142.3 57.2" fill="none" stroke="#ff0000" stroke-width="12" opacity="1"/>
|
|
100
|
+
<path d="M 257.7 57.2 A 154 154 0 0 1 318.0 101.0" fill="none" stroke="#00ff00" stroke-width="12" opacity="1"/>
|
|
101
|
+
<path d="M 265.1 339.6 A 154 154 0 0 1 134.9 339.6" fill="none" stroke="#ff8800" stroke-width="12" opacity="1"/>
|
|
102
|
+
|
|
103
|
+
<!-- Tacche e Testi -->
|
|
104
|
+
<g id="ticks"></g>
|
|
105
|
+
<g id="tick-labels" fill="#bbb" text-anchor="middle" dominant-baseline="hanging" font-family="Arial" font-weight="bold">
|
|
106
|
+
<text font-size="16" transform="translate(200, 65)">0</text>
|
|
107
|
+
<text font-size="16" transform="translate(335, 200) rotate(90)">90</text>
|
|
108
|
+
<text font-size="16" transform="translate(65, 200) rotate(-90)">90</text>
|
|
109
|
+
<text font-size="16" transform="translate(200, 335) rotate(180)">180</text>
|
|
110
|
+
<text font-size="11" transform="translate(267.5, 83) rotate(30)">30</text>
|
|
111
|
+
<text font-size="11" transform="translate(317, 132.5) rotate(60)">60</text>
|
|
112
|
+
<text font-size="11" transform="translate(317, 267.5) rotate(120)">120</text>
|
|
113
|
+
<text font-size="11" transform="translate(267.5, 317) rotate(150)">150</text>
|
|
114
|
+
<text font-size="11" transform="translate(132.5, 83) rotate(-30)">30</text>
|
|
115
|
+
<text font-size="11" transform="translate(83, 132.5) rotate(-60)">60</text>
|
|
116
|
+
<text font-size="11" transform="translate(83, 267.5) rotate(-120)">120</text>
|
|
117
|
+
<text font-size="11" transform="translate(132.5, 317) rotate(-150)">150</text>
|
|
118
|
+
</g>
|
|
119
|
+
|
|
120
|
+
<!-- Puntatori Dinamici -->
|
|
121
|
+
<g id="track-pointer" transform="rotate(0, 200, 200)"><path d="M200,42 L194,58 L206,58 Z" fill="#007aff" stroke="#fff" stroke-width="0.5" /></g>
|
|
122
|
+
<path id="boat-icon" d="M200,150 Q165,185 170,250 Q165,190 200,173 Q235,190 230,250 Q235,185 200,150 Z" fill="url(#axiom-grad)" />
|
|
123
|
+
<g id="aws-display-group" transform="translate(200, 265)">
|
|
124
|
+
<text x="0" y="0" fill="#777" font-size="10" font-weight="bold" text-anchor="middle" text-transform="uppercase">Apparent Wind kts</text>
|
|
125
|
+
<text id="aws-val-svg" x="0" y="42" fill="#fff" font-size="52" font-weight="bold" text-anchor="middle">0.0</text>
|
|
126
|
+
</g>
|
|
127
|
+
<g id="awa-pointer" transform="rotate(0, 200, 200)" opacity="0.85">
|
|
128
|
+
<path d="M200,80 L211,95 L200,145 L189,95 Z" fill="#ff8c00" stroke="#000" stroke-width="1" />
|
|
129
|
+
<text x="200" y="102" fill="#000" font-size="11" font-weight="900" text-anchor="middle" font-family="Arial Black">A</text>
|
|
130
|
+
</g>
|
|
131
|
+
<g id="twa-pointer" transform="rotate(0, 200, 200)" opacity="0.85">
|
|
132
|
+
<path d="M200,90 L206,98 L200,125 L194,98 Z" fill="#ffff00" stroke="#000" stroke-width="0.8" />
|
|
133
|
+
<text x="200" y="104" fill="#000" font-size="8" font-weight="900" text-anchor="middle" font-family="Arial Black">T</text>
|
|
134
|
+
</g>
|
|
135
|
+
|
|
136
|
+
<!-- Barra del Leeway (Scarroccio) -->
|
|
137
|
+
<g transform="translate(75, 410)">
|
|
138
|
+
<text x="125" y="-12" id="leeway-val" fill="#aaa" font-size="11" text-anchor="middle" font-weight="bold">LEEWAY: 0.0°</text>
|
|
139
|
+
<rect x="0" y="0" width="250" height="12" fill="#222" rx="3" />
|
|
140
|
+
<rect x="0" y="0" width="250" height="12" fill="url(#leeway-grad)" clip-path="url(#leeway-clip)" rx="3" />
|
|
141
|
+
|
|
142
|
+
<g stroke="#555" stroke-width="1">
|
|
143
|
+
<line x1="0" y1="-2" x2="0" y2="14" />
|
|
144
|
+
<line x1="31.25" y1="2" x2="31.25" y2="10" />
|
|
145
|
+
<line x1="62.5" y1="2" x2="62.5" y2="10" />
|
|
146
|
+
<line x1="93.75" y1="3" x2="93.75" y2="9" />
|
|
147
|
+
<line x1="125" y1="-2" x2="125" y2="14" />
|
|
148
|
+
<line x1="156.25" y1="3" x2="156.25" y2="9" />
|
|
149
|
+
<line x1="187.5" y1="2" x2="187.5" y2="10" />
|
|
150
|
+
<line x1="218.75" y1="2" x2="218.75" y2="10" />
|
|
151
|
+
<line x1="250" y1="-2" x2="250" y2="14" />
|
|
152
|
+
</g>
|
|
153
|
+
<g fill="#555" font-size="8" text-anchor="middle" font-weight="bold">
|
|
154
|
+
<text x="0" y="24">-20°</text><text x="62.5" y="24">-10</text><text x="125" y="24">0°</text><text x="187.5" y="24">10</text><text x="250" y="24">20°</text>
|
|
155
|
+
</g>
|
|
156
|
+
</g>
|
|
157
|
+
</svg>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<!-- ======================================================= -->
|
|
161
|
+
<!-- COLONNA DESTRA: Profondità e Dati Vento -->
|
|
162
|
+
<!-- ======================================================= -->
|
|
163
|
+
<div class="side-panel right-panel">
|
|
164
|
+
|
|
165
|
+
<!-- Profondità con Grafico Dinamico -->
|
|
166
|
+
<div class="data-box">
|
|
167
|
+
<div class="label-row"><span class="unit">m</span><span class="label">DEPTH</span></div>
|
|
168
|
+
<span class="value" id="depth">--.-</span>
|
|
169
|
+
<div class="graph-wrapper">
|
|
170
|
+
<div class="scale-labels left" id="depth-scale"></div>
|
|
171
|
+
<svg class="sparkline" id="depth-graph" viewBox="0 0 200 40" preserveAspectRatio="none"></svg>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<!-- TWS con Grafico Dinamico -->
|
|
176
|
+
<div class="data-box">
|
|
177
|
+
<div class="label-row"><span class="unit">kts</span><span class="label">TWS</span></div>
|
|
178
|
+
<span class="value" id="tws">0.0</span>
|
|
179
|
+
<div class="graph-wrapper">
|
|
180
|
+
<div class="scale-labels left" id="tws-scale"></div>
|
|
181
|
+
<svg class="sparkline" id="tws-graph" viewBox="0 0 200 40" preserveAspectRatio="none"></svg>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<!-- Dati Vento Singoli (Font dinamico) -->
|
|
186
|
+
<div class="data-box">
|
|
187
|
+
<div class="label-row"><span class="label">TWA MEAN</span></div>
|
|
188
|
+
<span class="value value-large" id="twa-avg">---°</span>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<div class="data-box">
|
|
192
|
+
<div class="label-row"><span class="label">AWA MEAN</span></div>
|
|
193
|
+
<span class="value value-large" id="awa-avg">---°</span>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<!-- TWD Mean con Mini Bussola integrata -->
|
|
197
|
+
<div class="data-box">
|
|
198
|
+
<div class="label-row"><span class="label">TWD MEAN</span></div>
|
|
199
|
+
<div class="value-with-compass">
|
|
200
|
+
<svg class="mini-compass" viewBox="0 0 40 40">
|
|
201
|
+
<circle cx="20" cy="20" r="18" fill="none" stroke="#333" stroke-width="2"/>
|
|
202
|
+
<g stroke="#555" stroke-width="1">
|
|
203
|
+
<line x1="20" y1="2" x2="20" y2="6"/>
|
|
204
|
+
<line x1="38" y1="20" x2="34" y2="20"/>
|
|
205
|
+
<line x1="20" y1="38" x2="20" y2="34"/>
|
|
206
|
+
<line x1="2" y1="20" x2="6" y2="20"/>
|
|
207
|
+
</g>
|
|
208
|
+
<g id="twd-arrow" transform="rotate(0, 20, 20)">
|
|
209
|
+
<path d="M20,5 L17,12 L23,12 Z" fill="#ffff00" />
|
|
210
|
+
<line x1="20" y1="5" x2="20" y2="20" stroke="#ffff00" stroke-width="1" />
|
|
211
|
+
</g>
|
|
212
|
+
</svg>
|
|
213
|
+
<span class="value value-large" id="twd-avg">---°</span>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<script src="app.js"></script>
|
|
221
|
+
</body>
|
|
222
|
+
</html>
|
package/manifest.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"short_name": "WindDash",
|
|
3
|
+
"name": "Signal K Wind Dashboard",
|
|
4
|
+
"icons": [
|
|
5
|
+
{
|
|
6
|
+
"src": "https://cdn-icons-png.flaticon.com/512/959/959711.png",
|
|
7
|
+
"type": "image/png",
|
|
8
|
+
"sizes": "512x512"
|
|
9
|
+
}
|
|
10
|
+
],
|
|
11
|
+
"start_url": "./index.html",
|
|
12
|
+
"background_color": "#000000",
|
|
13
|
+
"display": "standalone",
|
|
14
|
+
"scope": "./",
|
|
15
|
+
"theme_color": "#000000",
|
|
16
|
+
"orientation": "landscape"
|
|
17
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sailingrotevista/rotevista-dash",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Dashboard pubblica per Bavaria 39 Rotevista",
|
|
5
|
+
"main": "index.html",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"signalk-webapp"
|
|
11
|
+
],
|
|
12
|
+
"author": "sailingrotevista",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"signalk": {
|
|
15
|
+
"displayName": "Rotevista Dash"
|
|
16
|
+
}
|
|
17
|
+
}
|
package/style.css
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/* ==========================================================================
|
|
2
|
+
1. BASE E STRUTTURA GENERALE
|
|
3
|
+
========================================================================== */
|
|
4
|
+
body {
|
|
5
|
+
background-color: #000;
|
|
6
|
+
color: #fff;
|
|
7
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
|
8
|
+
margin: 0;
|
|
9
|
+
padding: 0;
|
|
10
|
+
height: 100vh;
|
|
11
|
+
width: 100vw;
|
|
12
|
+
display: flex;
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.main-container {
|
|
17
|
+
display: flex;
|
|
18
|
+
width: 100%;
|
|
19
|
+
height: 100%;
|
|
20
|
+
padding: 5px;
|
|
21
|
+
box-sizing: border-box;
|
|
22
|
+
align-items: stretch; /* Estende le colonne per tutta l'altezza */
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* ==========================================================================
|
|
26
|
+
2. PANNELLI LATERALI E DATA-BOX (Riquadri)
|
|
27
|
+
========================================================================== */
|
|
28
|
+
.side-panel {
|
|
29
|
+
flex: 1;
|
|
30
|
+
display: flex;
|
|
31
|
+
flex-direction: column;
|
|
32
|
+
justify-content: flex-start;
|
|
33
|
+
background: rgba(255, 255, 255, 0.02);
|
|
34
|
+
border-radius: 10px;
|
|
35
|
+
z-index: 10;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.right-panel {
|
|
39
|
+
align-items: flex-end;
|
|
40
|
+
text-align: right;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.data-box {
|
|
44
|
+
position: relative; /* Necessario per posizionare gli elementi assoluti interni */
|
|
45
|
+
border-bottom: 1px solid #222;
|
|
46
|
+
padding: 8px 8px;
|
|
47
|
+
display: flex;
|
|
48
|
+
flex-direction: column;
|
|
49
|
+
min-height: 82px;
|
|
50
|
+
width: 100%;
|
|
51
|
+
box-sizing: border-box;
|
|
52
|
+
flex: 1; /* Distribuisce lo spazio equamente in altezza */
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ==========================================================================
|
|
56
|
+
3. TIPOGRAFIA ED ETICHETTE
|
|
57
|
+
========================================================================== */
|
|
58
|
+
.label-row {
|
|
59
|
+
display: flex;
|
|
60
|
+
justify-content: space-between;
|
|
61
|
+
align-items: baseline;
|
|
62
|
+
margin-bottom: 2px;
|
|
63
|
+
width: 100%;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.label {
|
|
67
|
+
color: #666;
|
|
68
|
+
font-size: 0.6rem;
|
|
69
|
+
font-weight: bold;
|
|
70
|
+
text-transform: uppercase;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.unit {
|
|
74
|
+
color: #888;
|
|
75
|
+
font-size: 0.55rem;
|
|
76
|
+
font-weight: bold;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Valore Numerico Base (usato per i riquadri con grafici) */
|
|
80
|
+
.value {
|
|
81
|
+
color: #fff;
|
|
82
|
+
font-size: 2.4rem;
|
|
83
|
+
font-weight: 600;
|
|
84
|
+
line-height: 0.9;
|
|
85
|
+
letter-spacing: -1px;
|
|
86
|
+
transition: color 0.3s ease;
|
|
87
|
+
padding-bottom: 5px;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* Valore Numerico Dinamico (usato per i riquadri SENZA grafici) */
|
|
91
|
+
.value-large {
|
|
92
|
+
margin-top: auto; /* Spinge il numero verso il basso */
|
|
93
|
+
/*
|
|
94
|
+
clamp(min, preferito, max)
|
|
95
|
+
Minimo: 2.4rem (come i grafici)
|
|
96
|
+
Preferito: 7.5vh (scala molto in base all'altezza schermo)
|
|
97
|
+
Massimo: 4.5rem (font molto grande ma sicuro per non sbordare)
|
|
98
|
+
*/
|
|
99
|
+
font-size: clamp(2.4rem, 7.5vh, 4.5rem);
|
|
100
|
+
line-height: 0.85; /* Evita che il font grande sposti il layout */
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* Etichetta periodo media (es. per future implementazioni) */
|
|
104
|
+
.period-label {
|
|
105
|
+
position: absolute;
|
|
106
|
+
bottom: 4px;
|
|
107
|
+
left: 8px;
|
|
108
|
+
font-size: 0.5rem;
|
|
109
|
+
color: #444;
|
|
110
|
+
font-weight: bold;
|
|
111
|
+
text-transform: uppercase;
|
|
112
|
+
}
|
|
113
|
+
.right-panel .period-label { left: auto; right: 8px; }
|
|
114
|
+
|
|
115
|
+
/* ==========================================================================
|
|
116
|
+
4. LAYOUT SPECIALI (TACK e TWD COMPASS)
|
|
117
|
+
========================================================================== */
|
|
118
|
+
/* Layout a doppia colonna per il riquadro TACK */
|
|
119
|
+
.dual-value-container {
|
|
120
|
+
display: flex;
|
|
121
|
+
justify-content: space-between;
|
|
122
|
+
align-items: flex-end;
|
|
123
|
+
width: 100%;
|
|
124
|
+
margin-top: auto;
|
|
125
|
+
padding-bottom: 5px;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.dual-value-col {
|
|
129
|
+
display: flex;
|
|
130
|
+
flex-direction: column;
|
|
131
|
+
justify-content: space-between; /* Spinge l'etichetta su e il numero giù */
|
|
132
|
+
width: 48%; /* Lascia un piccolo margine al centro */
|
|
133
|
+
height: 100%; /* Prende tutta l'altezza disponibile nel contenitore */
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.dual-value-col.right-col {
|
|
137
|
+
align-items: flex-end;
|
|
138
|
+
text-align: right;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.dual-label {
|
|
142
|
+
color: #666;
|
|
143
|
+
font-size: 0.55rem;
|
|
144
|
+
font-weight: bold;
|
|
145
|
+
text-transform: uppercase;
|
|
146
|
+
margin-bottom: auto; /* Mantiene l'etichetta schiacciata verso l'alto */
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.value.dual-val {
|
|
150
|
+
font-size: 1.8rem; /* Font leggermente più piccolo per farne stare due */
|
|
151
|
+
padding-bottom: 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* Layout Bussolina TWD */
|
|
155
|
+
.value-with-compass {
|
|
156
|
+
display: flex;
|
|
157
|
+
justify-content: space-between;
|
|
158
|
+
align-items: center;
|
|
159
|
+
width: 100%;
|
|
160
|
+
margin-top: auto;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.mini-compass {
|
|
164
|
+
width: 38px;
|
|
165
|
+
height: 38px;
|
|
166
|
+
background: rgba(0,0,0,0.2);
|
|
167
|
+
border-radius: 50%;
|
|
168
|
+
flex-shrink: 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.value-with-compass .value-large {
|
|
172
|
+
margin-top: 0; /* Annulla il margin-top perché gestito dal padre flex */
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* ==========================================================================
|
|
176
|
+
5. GRAFICI STORICI (SPARKLINES)
|
|
177
|
+
========================================================================== */
|
|
178
|
+
.graph-wrapper {
|
|
179
|
+
position: relative;
|
|
180
|
+
width: 100%;
|
|
181
|
+
flex-grow: 1; /* Il contenitore si espande in altezza */
|
|
182
|
+
min-height: 20px;
|
|
183
|
+
margin-top: 4px;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.sparkline {
|
|
187
|
+
width: 100%;
|
|
188
|
+
height: 100%;
|
|
189
|
+
background: rgba(255, 255, 255, 0.03);
|
|
190
|
+
border-radius: 4px;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.scale-labels {
|
|
194
|
+
position: absolute;
|
|
195
|
+
top: 0;
|
|
196
|
+
height: 100%;
|
|
197
|
+
display: flex;
|
|
198
|
+
flex-direction: column;
|
|
199
|
+
justify-content: space-between;
|
|
200
|
+
font-size: 8px;
|
|
201
|
+
color: #555;
|
|
202
|
+
padding: 1px 5px;
|
|
203
|
+
font-weight: bold;
|
|
204
|
+
}
|
|
205
|
+
.scale-labels.right { right: 0; text-align: right; }
|
|
206
|
+
.scale-labels.left { left: 0; text-align: left; }
|
|
207
|
+
|
|
208
|
+
/* Colori specifici delle linee dei grafici */
|
|
209
|
+
#stw-graph { stroke: #2ecc71; fill: rgba(46, 204, 113, 0.15); }
|
|
210
|
+
#sog-graph { stroke: #f39c12; fill: rgba(243, 156, 18, 0.15); }
|
|
211
|
+
#depth-graph { stroke: #3498db; fill: rgba(52, 152, 219, 0.15); }
|
|
212
|
+
#tws-graph { stroke: #f1c40f; fill: rgba(241, 196, 15, 0.15); }
|
|
213
|
+
|
|
214
|
+
/* ==========================================================================
|
|
215
|
+
6. PANNELLO CENTRALE (STRUMENTO VENTO SVG)
|
|
216
|
+
========================================================================== */
|
|
217
|
+
.center-panel {
|
|
218
|
+
flex: 3.5;
|
|
219
|
+
display: flex;
|
|
220
|
+
flex-direction: column;
|
|
221
|
+
justify-content: flex-start;
|
|
222
|
+
align-items: center;
|
|
223
|
+
height: 100%;
|
|
224
|
+
padding: 0;
|
|
225
|
+
margin: 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
#wind-gauge {
|
|
229
|
+
width: 100%;
|
|
230
|
+
height: auto;
|
|
231
|
+
max-height: 95vh;
|
|
232
|
+
object-fit: contain;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* ==========================================================================
|
|
236
|
+
7. STATI, ALLARMI E ANIMAZIONI
|
|
237
|
+
========================================================================== */
|
|
238
|
+
/* Stato Connessione */
|
|
239
|
+
#status {
|
|
240
|
+
position: absolute;
|
|
241
|
+
top: 5px;
|
|
242
|
+
right: 15px;
|
|
243
|
+
font-size: 0.5rem;
|
|
244
|
+
text-transform: uppercase;
|
|
245
|
+
z-index: 1000;
|
|
246
|
+
}
|
|
247
|
+
.online { color: #2ecc71; opacity: 0.5; }
|
|
248
|
+
.offline { color: #e74c3c; font-weight: bold; }
|
|
249
|
+
|
|
250
|
+
/* Animazione fluidità lancette SVG */
|
|
251
|
+
#awa-pointer, #twa-pointer, #track-pointer, #twd-arrow {
|
|
252
|
+
transition: all 0.6s cubic-bezier(0.1, 0.7, 0.1, 1);
|
|
253
|
+
}
|
|
254
|
+
#leeway-mask-rect { transition: none; } /* Deve essere istantaneo */
|
|
255
|
+
|
|
256
|
+
/* Allarmi Profondità */
|
|
257
|
+
.alarm-warning { color: #f1c40f !important; }
|
|
258
|
+
.alarm-danger { color: #e74c3c !important; font-weight: 900; animation: blink-unstable 1s infinite; }
|
|
259
|
+
|
|
260
|
+
/* Lampeggio dati instabili / Ricalcolo buffer */
|
|
261
|
+
@keyframes blink-unstable {
|
|
262
|
+
0% { opacity: 1; }
|
|
263
|
+
50% { opacity: 0.3; }
|
|
264
|
+
100% { opacity: 1; }
|
|
265
|
+
}
|
|
266
|
+
.unstable-data {
|
|
267
|
+
animation: blink-unstable 1.5s infinite ease-in-out;
|
|
268
|
+
color: #f39c12 !important; /* Arancione per evidenziare il ricalcolo */
|
|
269
|
+
}
|