@sailingrotevista/rotevista-dash 7.0.45 → 8.0.1
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.
Potentially problematic release.
This version of @sailingrotevista/rotevista-dash might be problematic. Click here for more details.
- package/README.md +28 -70
- package/bun.lock +157 -0
- package/index.html +18 -260
- package/index.js +14 -0
- package/metadata.json +7 -0
- package/package.json +18 -1
- package/public/assets/dash-DmyxO1aO.png +0 -0
- package/public/assets/index-C4co33rh.css +1 -0
- package/public/assets/index-DvzZHuhg.js +9 -0
- package/public/assets/manifest-Ea_0WKQ8.json +17 -0
- package/public/icons/dash.png +0 -0
- package/public/index.html +23 -0
- package/public/manifest.json +17 -0
- package/src/App.tsx +346 -0
- package/src/components/AnalogGauge.tsx +243 -0
- package/src/components/DataBox.tsx +116 -0
- package/src/components/MiniCompass.tsx +120 -0
- package/src/components/Sparkline.tsx +343 -0
- package/src/components/WeatherRadar.tsx +280 -0
- package/src/hooks/useDashboard.ts +1161 -0
- package/src/main.tsx +10 -0
- package/{style.css → src/style.css} +189 -124
- package/src/types.ts +88 -0
- package/src/utils/math.ts +153 -0
- package/src/utils/radarMath.ts +89 -0
- package/src/vite-env.d.ts +1 -0
- package/static/icons/dash.png +0 -0
- package/static/manifest.json +17 -0
- package/tsconfig.json +20 -0
- package/vite.config.ts +22 -0
- package/app.js +0 -1505
- package/charts.js +0 -305
- package/debug.html +0 -1037
- package/gauge.js +0 -90
- package/utils.js +0 -148
- package/weather-radar.js +0 -305
- package/weather_debug.html +0 -181
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"short_name": "WindDash",
|
|
3
|
+
"name": "Signal K Wind Dashboard",
|
|
4
|
+
"icons": [
|
|
5
|
+
{
|
|
6
|
+
"src": "./icons/dash.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
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
<meta name="description" content="Tactical marine wind dashboard with navigation and course aids, analog wind gauge, historical weather radar, and depth alerts." />
|
|
9
|
+
<meta property="og:title" content="Sailing Dashboard Pro" />
|
|
10
|
+
<meta property="og:description" content="Tactical marine wind dashboard with navigation and course aids, analog wind gauge, historical weather radar, and depth alerts." />
|
|
11
|
+
<link rel="manifest" href="./assets/manifest-Ea_0WKQ8.json" />
|
|
12
|
+
|
|
13
|
+
<!-- Favicon & Home screen bookmark icons -->
|
|
14
|
+
<link rel="icon" type="image/png" href="./assets/dash-DmyxO1aO.png" />
|
|
15
|
+
<link rel="apple-touch-icon" href="./assets/dash-DmyxO1aO.png" />
|
|
16
|
+
<title>Sailing Dashboard Pro</title>
|
|
17
|
+
<script type="module" crossorigin src="./assets/index-DvzZHuhg.js"></script>
|
|
18
|
+
<link rel="stylesheet" crossorigin href="./assets/index-C4co33rh.css">
|
|
19
|
+
</head>
|
|
20
|
+
<body>
|
|
21
|
+
<div id="root"></div>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"short_name": "WindDash",
|
|
3
|
+
"name": "Signal K Wind Dashboard",
|
|
4
|
+
"icons": [
|
|
5
|
+
{
|
|
6
|
+
"src": "./icons/dash.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/src/App.tsx
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import React, { useCallback } from 'react';
|
|
2
|
+
import { useDashboard } from './hooks/useDashboard';
|
|
3
|
+
import { DataBox } from './components/DataBox';
|
|
4
|
+
import { Sparkline } from './components/Sparkline';
|
|
5
|
+
import { AnalogGauge } from './components/AnalogGauge';
|
|
6
|
+
import { WeatherRadar } from './components/WeatherRadar';
|
|
7
|
+
import { MiniCompass } from './components/MiniCompass';
|
|
8
|
+
import { BoxType } from './types';
|
|
9
|
+
|
|
10
|
+
export const App: React.FC = () => {
|
|
11
|
+
const {
|
|
12
|
+
config,
|
|
13
|
+
displayModeSog,
|
|
14
|
+
setDisplayModeSog,
|
|
15
|
+
displayModeTws,
|
|
16
|
+
setDisplayModeTws,
|
|
17
|
+
activeInstrument,
|
|
18
|
+
setActiveInstrument,
|
|
19
|
+
isNightMode,
|
|
20
|
+
setIsNightMode,
|
|
21
|
+
isFocusActive,
|
|
22
|
+
focusedBox,
|
|
23
|
+
graphModes,
|
|
24
|
+
connectionStatus,
|
|
25
|
+
displayValues,
|
|
26
|
+
histories,
|
|
27
|
+
windRadarSlots,
|
|
28
|
+
futureForecast,
|
|
29
|
+
toggleFocusMode,
|
|
30
|
+
toggleGraphMode,
|
|
31
|
+
} = useDashboard();
|
|
32
|
+
|
|
33
|
+
// Fullscreen toggle on center hotspot tap
|
|
34
|
+
const handleToggleFullscreen = useCallback(() => {
|
|
35
|
+
if (!document.fullscreenElement) {
|
|
36
|
+
document.documentElement.requestFullscreen().catch(() => {});
|
|
37
|
+
} else {
|
|
38
|
+
document.exitFullscreen().catch(() => {});
|
|
39
|
+
}
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
// Night Mode toggle on 1s press of center hotspot
|
|
43
|
+
const handleToggleNightMode = useCallback(() => {
|
|
44
|
+
setIsNightMode(prev => {
|
|
45
|
+
const next = !prev;
|
|
46
|
+
try {
|
|
47
|
+
const saved = localStorage.getItem('rotevista_dash_state');
|
|
48
|
+
const state = saved ? JSON.parse(saved) : {};
|
|
49
|
+
state.isNightMode = next;
|
|
50
|
+
localStorage.setItem('rotevista_dash_state', JSON.stringify(state));
|
|
51
|
+
} catch (e) {}
|
|
52
|
+
return next;
|
|
53
|
+
});
|
|
54
|
+
}, [setIsNightMode]);
|
|
55
|
+
|
|
56
|
+
// Handle Box TWD Long Press -> Switch to Weather Radar
|
|
57
|
+
const handleTwdLongPress = useCallback(() => {
|
|
58
|
+
setActiveInstrument('radar');
|
|
59
|
+
}, [setActiveInstrument]);
|
|
60
|
+
|
|
61
|
+
// Handle Box TWD Single Tap in Radar Mode -> Switch back to Gauge
|
|
62
|
+
const handleTwdSingleTap = useCallback(() => {
|
|
63
|
+
if (activeInstrument === 'radar') {
|
|
64
|
+
setActiveInstrument('gauge');
|
|
65
|
+
}
|
|
66
|
+
}, [activeInstrument, setActiveInstrument]);
|
|
67
|
+
|
|
68
|
+
const isLeftFocused = focusedBox && ['stw', 'sog', 'hdg', 'cog', 'tack'].includes(focusedBox);
|
|
69
|
+
const containerClasses = [
|
|
70
|
+
'main-container',
|
|
71
|
+
isFocusActive ? 'focus-active' : '',
|
|
72
|
+
isFocusActive && isLeftFocused ? 'focus-side-left' : '',
|
|
73
|
+
isFocusActive && !isLeftFocused ? 'focus-side-right' : '',
|
|
74
|
+
]
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
.join(' ');
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className={containerClasses}>
|
|
80
|
+
{/* ================= COLONNA SINISTRA ================= */}
|
|
81
|
+
{/* 1. STW */}
|
|
82
|
+
<DataBox
|
|
83
|
+
type="stw"
|
|
84
|
+
label="STW"
|
|
85
|
+
unit="kts"
|
|
86
|
+
value={displayValues.stw}
|
|
87
|
+
isFocused={focusedBox === 'stw'}
|
|
88
|
+
isHercules={graphModes.stw === 'hercules'}
|
|
89
|
+
onLongPress={() => toggleFocusMode('stw')}
|
|
90
|
+
onDoubleTap={() => toggleGraphMode('stw')}
|
|
91
|
+
>
|
|
92
|
+
<div className="graph-wrapper">
|
|
93
|
+
<Sparkline
|
|
94
|
+
id="stw-graph"
|
|
95
|
+
type="stw"
|
|
96
|
+
data={histories.stw}
|
|
97
|
+
config={config}
|
|
98
|
+
mode={graphModes.stw}
|
|
99
|
+
isNavigating={displayValues.isNavigating}
|
|
100
|
+
displayMode="STW"
|
|
101
|
+
isFocused={focusedBox === 'stw'}
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
</DataBox>
|
|
105
|
+
|
|
106
|
+
{/* 2. SOG / VMG */}
|
|
107
|
+
<DataBox
|
|
108
|
+
type="sog"
|
|
109
|
+
label={displayModeSog}
|
|
110
|
+
unit="kts"
|
|
111
|
+
value={displayValues.sog}
|
|
112
|
+
valueColor={displayValues.sogColor}
|
|
113
|
+
isFocused={focusedBox === 'sog'}
|
|
114
|
+
isHercules={graphModes.sog === 'hercules'}
|
|
115
|
+
onLongPress={() => toggleFocusMode('sog')}
|
|
116
|
+
onDoubleTap={() => toggleGraphMode('sog')}
|
|
117
|
+
onSingleTap={() => {
|
|
118
|
+
if (!isFocusActive) {
|
|
119
|
+
setDisplayModeSog(prev => (prev === 'SOG' ? 'VMG' : 'SOG'));
|
|
120
|
+
} else {
|
|
121
|
+
toggleFocusMode('sog');
|
|
122
|
+
}
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
<div className="graph-wrapper">
|
|
126
|
+
<Sparkline
|
|
127
|
+
id="sog-graph"
|
|
128
|
+
type="sog"
|
|
129
|
+
data={displayModeSog === 'VMG' ? histories.vmg : histories.sog}
|
|
130
|
+
config={config}
|
|
131
|
+
mode={graphModes.sog}
|
|
132
|
+
isNavigating={displayValues.isNavigating}
|
|
133
|
+
displayMode={displayModeSog}
|
|
134
|
+
isFocused={focusedBox === 'sog'}
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
</DataBox>
|
|
138
|
+
|
|
139
|
+
{/* 3. HEADING (MEAN) */}
|
|
140
|
+
<DataBox
|
|
141
|
+
type="hdg"
|
|
142
|
+
label="HEADING (MEAN)"
|
|
143
|
+
value={displayValues.hdg}
|
|
144
|
+
isLargeValue
|
|
145
|
+
unstable={displayValues.hdgUnstable}
|
|
146
|
+
isFocused={focusedBox === 'hdg'}
|
|
147
|
+
onLongPress={() => toggleFocusMode('hdg')}
|
|
148
|
+
onSingleTap={() => {
|
|
149
|
+
if (isFocusActive) toggleFocusMode('hdg');
|
|
150
|
+
}}
|
|
151
|
+
/>
|
|
152
|
+
|
|
153
|
+
{/* 4. COG (MEAN) */}
|
|
154
|
+
<DataBox
|
|
155
|
+
type="cog"
|
|
156
|
+
label="COG (MEAN)"
|
|
157
|
+
value={displayValues.cog}
|
|
158
|
+
isLargeValue
|
|
159
|
+
unstable={displayValues.cogUnstable}
|
|
160
|
+
isFocused={focusedBox === 'cog'}
|
|
161
|
+
onLongPress={() => toggleFocusMode('cog')}
|
|
162
|
+
onSingleTap={() => {
|
|
163
|
+
if (isFocusActive) toggleFocusMode('cog');
|
|
164
|
+
}}
|
|
165
|
+
/>
|
|
166
|
+
|
|
167
|
+
{/* 5. TACK / GYBE OPPOSITE COURSE */}
|
|
168
|
+
<DataBox
|
|
169
|
+
type="tack"
|
|
170
|
+
label={displayValues.tackLabel}
|
|
171
|
+
isFocused={focusedBox === 'tack'}
|
|
172
|
+
onLongPress={() => toggleFocusMode('tack')}
|
|
173
|
+
onSingleTap={() => {
|
|
174
|
+
if (isFocusActive) toggleFocusMode('tack');
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
<div className="dual-value-container">
|
|
178
|
+
<div className="dual-value-col tack-hdg-col">
|
|
179
|
+
<span className="dual-label">HDG</span>
|
|
180
|
+
<span
|
|
181
|
+
className={`value dual-val ${displayValues.tackHdgUnstable ? 'unstable-data' : ''}`}
|
|
182
|
+
id="tack-hdg"
|
|
183
|
+
>
|
|
184
|
+
{displayValues.tackHdg}
|
|
185
|
+
</span>
|
|
186
|
+
</div>
|
|
187
|
+
<div className="dual-value-col tack-cog-col">
|
|
188
|
+
<span className="dual-label">COG</span>
|
|
189
|
+
<span
|
|
190
|
+
className={`value dual-val ${displayValues.tackCogUnstable ? 'unstable-data' : ''}`}
|
|
191
|
+
id="tack-cog"
|
|
192
|
+
>
|
|
193
|
+
{displayValues.tackCog}
|
|
194
|
+
</span>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
</DataBox>
|
|
198
|
+
|
|
199
|
+
{/* ================= BUSSOLA CENTRALE ================= */}
|
|
200
|
+
<div className="box-gauge">
|
|
201
|
+
{/* STATO CONNESSIONE */}
|
|
202
|
+
<div id="status" className={connectionStatus.className}>
|
|
203
|
+
{connectionStatus.text}
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
{activeInstrument === 'gauge' ? (
|
|
207
|
+
<AnalogGauge
|
|
208
|
+
awsVal={parseFloat(displayValues.awsSvg) || 0}
|
|
209
|
+
awaDeg={displayValues.awaPointerDeg}
|
|
210
|
+
twaDeg={displayValues.twaPointerDeg}
|
|
211
|
+
leewayDeg={displayValues.leewayDeg}
|
|
212
|
+
trackDeg={displayValues.trackRotDeg}
|
|
213
|
+
tacticCwDot={displayValues.tacticCwDot}
|
|
214
|
+
tacticCcwDot={displayValues.tacticCcwDot}
|
|
215
|
+
onCenterHotspotLongPress={handleToggleNightMode}
|
|
216
|
+
onCenterHotspotClick={handleToggleFullscreen}
|
|
217
|
+
/>
|
|
218
|
+
) : (
|
|
219
|
+
<WeatherRadar
|
|
220
|
+
twdHistory={histories.twd}
|
|
221
|
+
twsHistory={histories.tws}
|
|
222
|
+
windRadarSlots={windRadarSlots}
|
|
223
|
+
futureForecast={futureForecast}
|
|
224
|
+
headingTrueDeg={displayValues.boatCompassRotDeg}
|
|
225
|
+
config={config}
|
|
226
|
+
meteoLed={displayValues.meteoLed}
|
|
227
|
+
onSwitchToGauge={() => setActiveInstrument('gauge')}
|
|
228
|
+
/>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{/* ================= COLONNA DESTRA ================= */}
|
|
233
|
+
{/* 6. DEPTH */}
|
|
234
|
+
<DataBox
|
|
235
|
+
type="depth"
|
|
236
|
+
label="DEPTH"
|
|
237
|
+
unit="m"
|
|
238
|
+
value={displayValues.depth}
|
|
239
|
+
alarmClass={displayValues.depthAlarmClass}
|
|
240
|
+
isFocused={focusedBox === 'depth'}
|
|
241
|
+
isHercules={graphModes.depth === 'hercules'}
|
|
242
|
+
onLongPress={() => toggleFocusMode('depth')}
|
|
243
|
+
onDoubleTap={() => toggleGraphMode('depth')}
|
|
244
|
+
>
|
|
245
|
+
<div className="graph-wrapper">
|
|
246
|
+
<Sparkline
|
|
247
|
+
id="depth-graph"
|
|
248
|
+
type="depth"
|
|
249
|
+
data={histories.depth}
|
|
250
|
+
config={config}
|
|
251
|
+
mode={graphModes.depth}
|
|
252
|
+
isNavigating={displayValues.isNavigating}
|
|
253
|
+
displayMode="DEPTH"
|
|
254
|
+
isFocused={focusedBox === 'depth'}
|
|
255
|
+
/>
|
|
256
|
+
</div>
|
|
257
|
+
</DataBox>
|
|
258
|
+
|
|
259
|
+
{/* 7. TWS / AWS */}
|
|
260
|
+
<DataBox
|
|
261
|
+
type="tws"
|
|
262
|
+
label={displayModeTws}
|
|
263
|
+
unit="kts"
|
|
264
|
+
value={displayValues.tws}
|
|
265
|
+
valueColor={displayValues.twsColor}
|
|
266
|
+
isFocused={focusedBox === 'tws'}
|
|
267
|
+
isHercules={graphModes.tws === 'hercules'}
|
|
268
|
+
onLongPress={() => toggleFocusMode('tws')}
|
|
269
|
+
onDoubleTap={() => toggleGraphMode('tws')}
|
|
270
|
+
onSingleTap={() => {
|
|
271
|
+
if (!isFocusActive) {
|
|
272
|
+
setDisplayModeTws(prev => (prev === 'TWS' ? 'AWS' : 'TWS'));
|
|
273
|
+
} else {
|
|
274
|
+
toggleFocusMode('tws');
|
|
275
|
+
}
|
|
276
|
+
}}
|
|
277
|
+
>
|
|
278
|
+
<div className="graph-wrapper">
|
|
279
|
+
<Sparkline
|
|
280
|
+
id="tws-graph"
|
|
281
|
+
type="tws"
|
|
282
|
+
data={displayModeTws === 'AWS' ? histories.aws : histories.tws}
|
|
283
|
+
config={config}
|
|
284
|
+
mode={graphModes.tws}
|
|
285
|
+
isNavigating={displayValues.isNavigating}
|
|
286
|
+
displayMode={displayModeTws}
|
|
287
|
+
isFocused={focusedBox === 'tws'}
|
|
288
|
+
/>
|
|
289
|
+
</div>
|
|
290
|
+
</DataBox>
|
|
291
|
+
|
|
292
|
+
{/* 8. TWA (MEAN) */}
|
|
293
|
+
<DataBox
|
|
294
|
+
type="twa"
|
|
295
|
+
label="TWA (MEAN)"
|
|
296
|
+
value={displayValues.twaAvg}
|
|
297
|
+
isLargeValue
|
|
298
|
+
unstable={displayValues.twaUnstable}
|
|
299
|
+
isFocused={focusedBox === 'twa'}
|
|
300
|
+
onLongPress={() => toggleFocusMode('twa')}
|
|
301
|
+
onSingleTap={() => {
|
|
302
|
+
if (isFocusActive) toggleFocusMode('twa');
|
|
303
|
+
}}
|
|
304
|
+
/>
|
|
305
|
+
|
|
306
|
+
{/* 9. AWA (MEAN) */}
|
|
307
|
+
<DataBox
|
|
308
|
+
type="awa"
|
|
309
|
+
label="AWA (MEAN)"
|
|
310
|
+
value={displayValues.awaAvg}
|
|
311
|
+
isLargeValue
|
|
312
|
+
unstable={displayValues.awaUnstable}
|
|
313
|
+
isFocused={focusedBox === 'awa'}
|
|
314
|
+
onLongPress={() => toggleFocusMode('awa')}
|
|
315
|
+
onSingleTap={() => {
|
|
316
|
+
if (isFocusActive) toggleFocusMode('awa');
|
|
317
|
+
}}
|
|
318
|
+
/>
|
|
319
|
+
|
|
320
|
+
{/* 10. TWD (MEAN) WITH MINI COMPASS */}
|
|
321
|
+
<DataBox
|
|
322
|
+
type="twd"
|
|
323
|
+
label="TWD (MEAN)"
|
|
324
|
+
isFocused={focusedBox === 'twd'}
|
|
325
|
+
onLongPress={handleTwdLongPress}
|
|
326
|
+
onSingleTap={handleTwdSingleTap}
|
|
327
|
+
>
|
|
328
|
+
<div className="value-with-compass">
|
|
329
|
+
<MiniCompass
|
|
330
|
+
boatRotDeg={displayValues.boatCompassRotDeg}
|
|
331
|
+
windRotDeg={displayValues.windCompassRotDeg}
|
|
332
|
+
compassCwDot={displayValues.compassCwDot}
|
|
333
|
+
compassCcwDot={displayValues.compassCcwDot}
|
|
334
|
+
/>
|
|
335
|
+
<span
|
|
336
|
+
className={`value-large ${displayValues.twdUnstable ? 'unstable-data' : ''}`}
|
|
337
|
+
id="twd-avg"
|
|
338
|
+
>
|
|
339
|
+
{displayValues.twdAvg}
|
|
340
|
+
</span>
|
|
341
|
+
</div>
|
|
342
|
+
</DataBox>
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
};
|
|
346
|
+
export default App;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import React, { useMemo, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
interface AnalogGaugeProps {
|
|
4
|
+
awsVal: number;
|
|
5
|
+
awaDeg: number;
|
|
6
|
+
twaDeg: number;
|
|
7
|
+
leewayDeg: number;
|
|
8
|
+
trackDeg: number;
|
|
9
|
+
tacticCwDot: { active: boolean; color: string; gybing?: boolean };
|
|
10
|
+
tacticCcwDot: { active: boolean; color: string; gybing?: boolean };
|
|
11
|
+
onCenterHotspotLongPress: () => void;
|
|
12
|
+
onCenterHotspotClick: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const AnalogGauge: React.FC<AnalogGaugeProps> = ({
|
|
16
|
+
awsVal,
|
|
17
|
+
awaDeg,
|
|
18
|
+
twaDeg,
|
|
19
|
+
leewayDeg,
|
|
20
|
+
trackDeg,
|
|
21
|
+
tacticCwDot,
|
|
22
|
+
tacticCcwDot,
|
|
23
|
+
onCenterHotspotLongPress,
|
|
24
|
+
onCenterHotspotClick,
|
|
25
|
+
}) => {
|
|
26
|
+
const pressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
27
|
+
const isLongPressRef = useRef(false);
|
|
28
|
+
|
|
29
|
+
// Generate compass ticks
|
|
30
|
+
const ticks = useMemo(() => {
|
|
31
|
+
const list: React.ReactNode[] = [];
|
|
32
|
+
for (let i = 0; i < 360; i += 10) {
|
|
33
|
+
const isMajor = i % 30 === 0;
|
|
34
|
+
list.push(
|
|
35
|
+
<line
|
|
36
|
+
key={`tick-${i}`}
|
|
37
|
+
x1="200"
|
|
38
|
+
y1="40"
|
|
39
|
+
x2="200"
|
|
40
|
+
y2={isMajor ? 60 : 50}
|
|
41
|
+
stroke={isMajor ? '#000' : '#bbb'}
|
|
42
|
+
strokeWidth={isMajor ? 2 : 1}
|
|
43
|
+
transform={`rotate(${i}, 200, 200)`}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return list;
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const clampedLeeway = Math.max(-20, Math.min(20, leewayDeg));
|
|
51
|
+
const c = 125;
|
|
52
|
+
const px = 125 / 20;
|
|
53
|
+
const leewayWidth = Math.min(Math.abs(clampedLeeway) * px, 125);
|
|
54
|
+
const leewayX = clampedLeeway >= 0 ? c : c - leewayWidth;
|
|
55
|
+
|
|
56
|
+
const handlePointerDown = () => {
|
|
57
|
+
isLongPressRef.current = false;
|
|
58
|
+
pressTimerRef.current = setTimeout(() => {
|
|
59
|
+
isLongPressRef.current = true;
|
|
60
|
+
onCenterHotspotLongPress();
|
|
61
|
+
}, 1000);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const handlePointerUp = () => {
|
|
65
|
+
if (pressTimerRef.current) {
|
|
66
|
+
clearTimeout(pressTimerRef.current);
|
|
67
|
+
pressTimerRef.current = null;
|
|
68
|
+
}
|
|
69
|
+
if (!isLongPressRef.current) {
|
|
70
|
+
onCenterHotspotClick();
|
|
71
|
+
}
|
|
72
|
+
isLongPressRef.current = false;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const handlePointerLeave = () => {
|
|
76
|
+
if (pressTimerRef.current) {
|
|
77
|
+
clearTimeout(pressTimerRef.current);
|
|
78
|
+
pressTimerRef.current = null;
|
|
79
|
+
}
|
|
80
|
+
isLongPressRef.current = false;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<svg id="wind-gauge" viewBox="35 38 330 395" preserveAspectRatio="xMidYMid meet">
|
|
85
|
+
<defs>
|
|
86
|
+
<clipPath id="boat-clip">
|
|
87
|
+
<circle cx="200" cy="200" r="50" />
|
|
88
|
+
</clipPath>
|
|
89
|
+
<linearGradient id="axiom-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
90
|
+
<stop offset="0%" stopColor="#ffffff" stopOpacity={1} />
|
|
91
|
+
<stop offset="100%" stopColor="#888888" stopOpacity={1} />
|
|
92
|
+
</linearGradient>
|
|
93
|
+
<linearGradient id="leeway-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
94
|
+
<stop offset="0%" stopColor="#ff0000" stopOpacity={1} />
|
|
95
|
+
<stop offset="25%" stopColor="#ff8800" stopOpacity={1} />
|
|
96
|
+
<stop offset="50%" stopColor="#00ff00" stopOpacity={1} />
|
|
97
|
+
<stop offset="75%" stopColor="#ff8800" stopOpacity={1} />
|
|
98
|
+
<stop offset="100%" stopColor="#ff0000" stopOpacity={1} />
|
|
99
|
+
</linearGradient>
|
|
100
|
+
<linearGradient id="leeway-night-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
|
101
|
+
<stop offset="0%" stopColor="#ff0000" stopOpacity={1} />
|
|
102
|
+
<stop offset="50%" stopColor="#330000" stopOpacity={1} />
|
|
103
|
+
<stop offset="100%" stopColor="#ff0000" stopOpacity={1} />
|
|
104
|
+
</linearGradient>
|
|
105
|
+
<clipPath id="leeway-clip">
|
|
106
|
+
<rect id="leeway-mask-rect" x={leewayX} y="0" width={leewayWidth} height="12" rx="2" />
|
|
107
|
+
</clipPath>
|
|
108
|
+
<filter id="center-glow" x="-20%" y="-20%" width="140%" height="140%">
|
|
109
|
+
<feDropShadow dx="0" dy="0" stdDeviation="8" floodColor="#aaaaaa" floodOpacity={0.5} />
|
|
110
|
+
</filter>
|
|
111
|
+
</defs>
|
|
112
|
+
|
|
113
|
+
{/* Dials */}
|
|
114
|
+
<circle cx="200" cy="200" r="160" fill="#050505" />
|
|
115
|
+
<circle cx="200" cy="200" r="125" fill="#121212" />
|
|
116
|
+
|
|
117
|
+
{/* Beating & Running Arcs */}
|
|
118
|
+
<path d="M 82.0 101.0 A 154 154 0 0 1 142.3 57.2" fill="none" stroke="#ff0000" strokeWidth="12" />
|
|
119
|
+
<path d="M 257.7 57.2 A 154 154 0 0 1 318.0 101.0" fill="none" stroke="#00ff00" strokeWidth="12" />
|
|
120
|
+
<path d="M 265.1 339.6 A 154 154 0 0 1 134.9 339.6" fill="none" stroke="#ff8800" strokeWidth="12" />
|
|
121
|
+
|
|
122
|
+
{/* Ticks */}
|
|
123
|
+
<g id="ticks">{ticks}</g>
|
|
124
|
+
|
|
125
|
+
{/* Tick Labels */}
|
|
126
|
+
<g id="tick-labels" fill="#bbb" textAnchor="middle" dominantBaseline="middle" fontFamily="Arial" fontWeight="bold">
|
|
127
|
+
<text fontSize="16" transform="translate(200, 74)">0</text>
|
|
128
|
+
<text fontSize="16" transform="translate(326, 200) rotate(90)">90</text>
|
|
129
|
+
<text fontSize="16" transform="translate(74, 200) rotate(-90)">90</text>
|
|
130
|
+
<text fontSize="16" transform="translate(200, 326) rotate(180)">180</text>
|
|
131
|
+
<text fontSize="11" transform="translate(262.5, 91.7) rotate(30)">30</text>
|
|
132
|
+
<text fontSize="11" transform="translate(308.3, 137.5) rotate(60)">60</text>
|
|
133
|
+
<text fontSize="11" transform="translate(308.3, 262.5) rotate(120)">120</text>
|
|
134
|
+
<text fontSize="11" transform="translate(262.5, 308.3) rotate(150)">150</text>
|
|
135
|
+
<text fontSize="11" transform="translate(137.5, 91.7) rotate(-30)">30</text>
|
|
136
|
+
<text fontSize="11" transform="translate(91.7, 137.5) rotate(-60)">60</text>
|
|
137
|
+
<text fontSize="11" transform="translate(91.7, 262.5) rotate(-120)">120</text>
|
|
138
|
+
<text fontSize="11" transform="translate(137.5, 308.3) rotate(-150)">150</text>
|
|
139
|
+
</g>
|
|
140
|
+
|
|
141
|
+
{/* Track / Leeway Drift Pointer */}
|
|
142
|
+
<g id="track-pointer" transform={`rotate(${trackDeg}, 200, 200)`}>
|
|
143
|
+
<path d="M200,42 L194,58 L206,58 Z" fill="#007aff" stroke="#fff" strokeWidth="0.5" />
|
|
144
|
+
</g>
|
|
145
|
+
|
|
146
|
+
{/* Center Interactive Hotspot */}
|
|
147
|
+
<circle
|
|
148
|
+
id="fullscreen-hotspot"
|
|
149
|
+
cx="200"
|
|
150
|
+
cy="200"
|
|
151
|
+
r="55"
|
|
152
|
+
fill="#181818"
|
|
153
|
+
stroke="#333"
|
|
154
|
+
strokeWidth="1"
|
|
155
|
+
filter="url(#center-glow)"
|
|
156
|
+
cursor="pointer"
|
|
157
|
+
onPointerDown={handlePointerDown}
|
|
158
|
+
onPointerUp={handlePointerUp}
|
|
159
|
+
onPointerLeave={handlePointerLeave}
|
|
160
|
+
/>
|
|
161
|
+
|
|
162
|
+
{/* Boat Silhouette */}
|
|
163
|
+
<path
|
|
164
|
+
id="boat-icon"
|
|
165
|
+
d="M200,150 Q165,185 170,250 Q165,190 200,173 Q235,190 230,250 Q235,185 200,150 Z"
|
|
166
|
+
fill="url(#axiom-grad)"
|
|
167
|
+
transform="translate(0, 5)"
|
|
168
|
+
clipPath="url(#boat-clip)"
|
|
169
|
+
style={{ pointerEvents: 'none' }}
|
|
170
|
+
/>
|
|
171
|
+
|
|
172
|
+
{/* AWS Center Display */}
|
|
173
|
+
<g id="aws-display-group" transform="translate(200, 265)">
|
|
174
|
+
<text x="0" y="0" fill="#777" fontSize="10" fontWeight="bold" textAnchor="middle" style={{ textTransform: 'uppercase' }}>
|
|
175
|
+
Apparent Wind kts
|
|
176
|
+
</text>
|
|
177
|
+
<text id="aws-val-svg" x="0" y="42" fill="#fff" fontSize="52" fontWeight="bold" textAnchor="middle">
|
|
178
|
+
{awsVal.toFixed(1)}
|
|
179
|
+
</text>
|
|
180
|
+
</g>
|
|
181
|
+
|
|
182
|
+
{/* AWA Pointer (Orange with 'A') */}
|
|
183
|
+
<g id="awa-pointer" transform={`rotate(${awaDeg}, 200, 200)`} opacity={0.9}>
|
|
184
|
+
<path d="M 200,70 L 213,95 L 200,145 L 187,95 Z" fill="#ff8c00" stroke="#000" strokeWidth="1" />
|
|
185
|
+
<text x="200" y="90" fill="#000" fontSize="10" fontWeight="900" textAnchor="middle" fontFamily="Arial Black">
|
|
186
|
+
A
|
|
187
|
+
</text>
|
|
188
|
+
</g>
|
|
189
|
+
|
|
190
|
+
{/* TWA Pointer (Yellow with 'T') and Tactical Trend Dots */}
|
|
191
|
+
<g id="twa-pointer" transform={`rotate(${twaDeg}, 200, 200)`} opacity={0.9}>
|
|
192
|
+
<path
|
|
193
|
+
d="M 200,92 A 8,8 0 0 1 208,100 C 208,108 200,128 200,128 C 200,128 192,108 192,100 A 8,8 0 0 1 200,92 Z"
|
|
194
|
+
fill="#ffff00"
|
|
195
|
+
stroke="#000"
|
|
196
|
+
strokeWidth="0.8"
|
|
197
|
+
/>
|
|
198
|
+
<text x="200" y="106" fill="#000" fontSize="9" fontWeight="900" textAnchor="middle" fontFamily="Arial Black">
|
|
199
|
+
T
|
|
200
|
+
</text>
|
|
201
|
+
<circle
|
|
202
|
+
id="trend-gauge-cw"
|
|
203
|
+
cx="215"
|
|
204
|
+
cy="110"
|
|
205
|
+
r="4"
|
|
206
|
+
fill={tacticCwDot.color}
|
|
207
|
+
className={`${tacticCwDot.active ? 'is-trending' : ''} ${tacticCwDot.gybing ? 'is-gybing' : ''}`}
|
|
208
|
+
/>
|
|
209
|
+
<circle
|
|
210
|
+
id="trend-gauge-ccw"
|
|
211
|
+
cx="185"
|
|
212
|
+
cy="110"
|
|
213
|
+
r="4"
|
|
214
|
+
fill={tacticCcwDot.color}
|
|
215
|
+
className={`${tacticCcwDot.active ? 'is-trending' : ''} ${tacticCcwDot.gybing ? 'is-gybing' : ''}`}
|
|
216
|
+
/>
|
|
217
|
+
</g>
|
|
218
|
+
|
|
219
|
+
{/* Leeway Bar Indicator */}
|
|
220
|
+
<g transform="translate(75, 395)">
|
|
221
|
+
<text x="125" y="-12" id="leeway-val" fill="#aaa" fontSize="11" textAnchor="middle" fontWeight="bold">
|
|
222
|
+
{`LEEWAY: ${clampedLeeway.toFixed(1)}°`}
|
|
223
|
+
</text>
|
|
224
|
+
<rect x="0" y="0" width="250" height="12" fill="#222" rx="3" />
|
|
225
|
+
<rect x="0" y="0" width="250" height="12" fill="url(#leeway-grad)" clipPath="url(#leeway-clip)" rx="3" />
|
|
226
|
+
<g stroke="#555" strokeWidth="1">
|
|
227
|
+
<line x1="0" y1="-2" x2="0" y2="14" />
|
|
228
|
+
<line x1="62.5" y1="2" x2="62.5" y2="10" />
|
|
229
|
+
<line x1="125" y1="-2" x2="125" y2="14" />
|
|
230
|
+
<line x1="187.5" y1="2" x2="187.5" y2="10" />
|
|
231
|
+
<line x1="250" y1="-2" x2="250" y2="14" />
|
|
232
|
+
</g>
|
|
233
|
+
<g fill="#555" fontSize="8" textAnchor="middle" fontWeight="bold">
|
|
234
|
+
<text x="0" y="24">-20°</text>
|
|
235
|
+
<text x="62.5" y="24">-10</text>
|
|
236
|
+
<text x="125" y="24">0°</text>
|
|
237
|
+
<text x="187.5" y="24">10</text>
|
|
238
|
+
<text x="250" y="24">20°</text>
|
|
239
|
+
</g>
|
|
240
|
+
</g>
|
|
241
|
+
</svg>
|
|
242
|
+
);
|
|
243
|
+
};
|