signalk-race-control 0.12.5 → 0.12.6
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/index.js +55 -0
- package/package.json +1 -1
- package/public/mfd-tile.html +350 -0
package/index.js
CHANGED
|
@@ -1603,6 +1603,12 @@ module.exports = function (app) {
|
|
|
1603
1603
|
title:
|
|
1604
1604
|
'How close (in meters) an AIS-tracked boat must come to a mark for it to count as rounded, for the estimated finish time and remaining-distance calculations',
|
|
1605
1605
|
default: 100
|
|
1606
|
+
},
|
|
1607
|
+
enableMfdTile: {
|
|
1608
|
+
type: 'boolean',
|
|
1609
|
+
title:
|
|
1610
|
+
'Publish a read-only summary of the current race (countdown, start-timer fields, leaderboard) to the Signal K data model at vessels.self.racecontrol, and enable the mfd-tile.html webapp that reads it — for embedding as a tile on a B&G/Navico MFD via signalk-mfd-plugin, which requires publicly-readable data rather than this plugin\'s own (admin-only) REST API. Off by default.',
|
|
1611
|
+
default: false
|
|
1606
1612
|
}
|
|
1607
1613
|
}
|
|
1608
1614
|
};
|
|
@@ -1912,6 +1918,46 @@ module.exports = function (app) {
|
|
|
1912
1918
|
});
|
|
1913
1919
|
}
|
|
1914
1920
|
|
|
1921
|
+
// The current race's status, trimmed to just what the read-only MFD tile
|
|
1922
|
+
// needs (no boat ids, course marks, or anything else it doesn't render) —
|
|
1923
|
+
// published into the Signal K data model as a single value (see
|
|
1924
|
+
// publishMfdData), rather than served from this plugin's own admin-only
|
|
1925
|
+
// REST API, so it's reachable without authentication.
|
|
1926
|
+
function currentMfdSnapshot() {
|
|
1927
|
+
const race = getRace(state.currentRaceId);
|
|
1928
|
+
if (!race) return { name: null, state: 'none', leaderboard: [] };
|
|
1929
|
+
const raceState = race.stopTime ? 'stopped' : race.startTime ? 'started' : race.scheduledStart ? 'scheduled' : 'not-started';
|
|
1930
|
+
const leaderboard = race.startTime
|
|
1931
|
+
? rankedBoatList(race).map((r, i) => ({
|
|
1932
|
+
rank: r.boat.dns ? 'DNS' : r.boat.dnf ? 'DNF' : r.rankMs != null ? i + 1 : null,
|
|
1933
|
+
name: r.boat.name,
|
|
1934
|
+
sailNumber: r.boat.sailNumber || null,
|
|
1935
|
+
elapsedMs: r.elapsedMs,
|
|
1936
|
+
correctedMs: r.correctedMs,
|
|
1937
|
+
finished: !!r.boat.finishTime
|
|
1938
|
+
}))
|
|
1939
|
+
: [];
|
|
1940
|
+
return {
|
|
1941
|
+
name: race.name,
|
|
1942
|
+
state: raceState,
|
|
1943
|
+
scheduledStart: race.scheduledStart || null,
|
|
1944
|
+
startTime: race.startTime || null,
|
|
1945
|
+
startLine: race.course && race.course.startLine ? race.course.startLine : null,
|
|
1946
|
+
leaderboard
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
function publishMfdData() {
|
|
1951
|
+
if (!plugin.options || !plugin.options.enableMfdTile) return;
|
|
1952
|
+
try {
|
|
1953
|
+
app.handleMessage(plugin.id, {
|
|
1954
|
+
updates: [{ source: { label: plugin.id }, values: [{ path: 'racecontrol', value: currentMfdSnapshot() }] }]
|
|
1955
|
+
});
|
|
1956
|
+
} catch (e) {
|
|
1957
|
+
app.debug('race-control: publishMfdData failed: ' + e.message);
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1915
1961
|
// SignalK resource ids must be UUIDs (the resources-provider rejects
|
|
1916
1962
|
// anything else) — this derives one deterministically from a stable seed
|
|
1917
1963
|
// string, so re-publishing the same course/mark updates the same
|
|
@@ -2076,6 +2122,7 @@ module.exports = function (app) {
|
|
|
2076
2122
|
// (armCallOffSchedule requires race.startTime) — arm it now instead of
|
|
2077
2123
|
// discarding it.
|
|
2078
2124
|
armCallOffSchedule(race);
|
|
2125
|
+
publishMfdData();
|
|
2079
2126
|
}
|
|
2080
2127
|
|
|
2081
2128
|
// Re-arms (or clears) the timer that auto-starts a race at its
|
|
@@ -2116,6 +2163,7 @@ module.exports = function (app) {
|
|
|
2116
2163
|
}
|
|
2117
2164
|
});
|
|
2118
2165
|
saveState();
|
|
2166
|
+
publishMfdData();
|
|
2119
2167
|
}
|
|
2120
2168
|
|
|
2121
2169
|
// A race with at least one boat where every boat has either finished or
|
|
@@ -2215,7 +2263,9 @@ module.exports = function (app) {
|
|
|
2215
2263
|
trackTimer = setInterval(() => {
|
|
2216
2264
|
recordTrackSample();
|
|
2217
2265
|
checkStartLineCrossings().catch((e) => app.debug('race-control: checkStartLineCrossings failed: ' + e.message));
|
|
2266
|
+
publishMfdData();
|
|
2218
2267
|
}, 15000);
|
|
2268
|
+
publishMfdData();
|
|
2219
2269
|
app.setPluginStatus('Race control ready');
|
|
2220
2270
|
};
|
|
2221
2271
|
|
|
@@ -2540,6 +2590,7 @@ module.exports = function (app) {
|
|
|
2540
2590
|
}
|
|
2541
2591
|
saveState();
|
|
2542
2592
|
armCallOffSchedule(race);
|
|
2593
|
+
publishMfdData();
|
|
2543
2594
|
res.json(raceWithEstimates(race));
|
|
2544
2595
|
});
|
|
2545
2596
|
|
|
@@ -2556,6 +2607,7 @@ module.exports = function (app) {
|
|
|
2556
2607
|
race.scheduledStart = t;
|
|
2557
2608
|
saveState();
|
|
2558
2609
|
armSchedule(race);
|
|
2610
|
+
publishMfdData();
|
|
2559
2611
|
res.json(raceWithEstimates(race));
|
|
2560
2612
|
});
|
|
2561
2613
|
|
|
@@ -2565,6 +2617,7 @@ module.exports = function (app) {
|
|
|
2565
2617
|
race.scheduledStart = null;
|
|
2566
2618
|
saveState();
|
|
2567
2619
|
armSchedule(race);
|
|
2620
|
+
publishMfdData();
|
|
2568
2621
|
res.json(raceWithEstimates(race));
|
|
2569
2622
|
});
|
|
2570
2623
|
|
|
@@ -2589,6 +2642,7 @@ module.exports = function (app) {
|
|
|
2589
2642
|
b.dnfPosition = null;
|
|
2590
2643
|
});
|
|
2591
2644
|
saveState();
|
|
2645
|
+
publishMfdData();
|
|
2592
2646
|
res.json(raceWithEstimates(race));
|
|
2593
2647
|
});
|
|
2594
2648
|
|
|
@@ -2622,6 +2676,7 @@ module.exports = function (app) {
|
|
|
2622
2676
|
}
|
|
2623
2677
|
});
|
|
2624
2678
|
saveState();
|
|
2679
|
+
publishMfdData();
|
|
2625
2680
|
res.json(raceWithEstimates(race));
|
|
2626
2681
|
});
|
|
2627
2682
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-race-control",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.6",
|
|
4
4
|
"description": "SignalK plugin and webapp for tracking elapsed and handicap-corrected (Time-on-Time) race time, with a per-boat editable TCF and boat names pulled from AIS when available.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"author": "Joachim Bakke <github@heiamoss.com>",
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Race Control</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #0f172a;
|
|
11
|
+
--panel: #1e293b;
|
|
12
|
+
--border: #334155;
|
|
13
|
+
--text: #e2e8f0;
|
|
14
|
+
--muted: #94a3b8;
|
|
15
|
+
--accent: #38bdf8;
|
|
16
|
+
--good: #22c55e;
|
|
17
|
+
--bad: #f87171;
|
|
18
|
+
}
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
html, body {
|
|
21
|
+
margin: 0;
|
|
22
|
+
height: 100%;
|
|
23
|
+
background: var(--bg);
|
|
24
|
+
color: var(--text);
|
|
25
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
26
|
+
/* This page is a read-only display embedded in an MFD's browser tile —
|
|
27
|
+
no inputs, no keyboard interaction, nothing to select or tap. */
|
|
28
|
+
user-select: none;
|
|
29
|
+
-webkit-user-select: none;
|
|
30
|
+
}
|
|
31
|
+
body {
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-direction: column;
|
|
34
|
+
padding: 0.6rem 0.8rem;
|
|
35
|
+
overflow: hidden;
|
|
36
|
+
}
|
|
37
|
+
h1 {
|
|
38
|
+
margin: 0 0 0.3rem;
|
|
39
|
+
font-size: 1rem;
|
|
40
|
+
font-weight: 600;
|
|
41
|
+
text-align: center;
|
|
42
|
+
color: var(--muted);
|
|
43
|
+
white-space: nowrap;
|
|
44
|
+
overflow: hidden;
|
|
45
|
+
text-overflow: ellipsis;
|
|
46
|
+
}
|
|
47
|
+
.empty {
|
|
48
|
+
flex: 1;
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
justify-content: center;
|
|
52
|
+
color: var(--muted);
|
|
53
|
+
font-size: 1.1rem;
|
|
54
|
+
text-align: center;
|
|
55
|
+
}
|
|
56
|
+
.countdown {
|
|
57
|
+
text-align: center;
|
|
58
|
+
font-size: clamp(2.2rem, 12vw, 4rem);
|
|
59
|
+
font-weight: 700;
|
|
60
|
+
font-variant-numeric: tabular-nums;
|
|
61
|
+
line-height: 1.1;
|
|
62
|
+
margin: 0.2rem 0;
|
|
63
|
+
}
|
|
64
|
+
.state-label {
|
|
65
|
+
text-align: center;
|
|
66
|
+
color: var(--muted);
|
|
67
|
+
font-size: 0.85rem;
|
|
68
|
+
margin-bottom: 0.4rem;
|
|
69
|
+
}
|
|
70
|
+
.start-timer-grid {
|
|
71
|
+
display: grid;
|
|
72
|
+
grid-template-columns: repeat(3, 1fr);
|
|
73
|
+
gap: 0.5rem;
|
|
74
|
+
margin-top: auto;
|
|
75
|
+
}
|
|
76
|
+
.start-timer-tile {
|
|
77
|
+
background: var(--panel);
|
|
78
|
+
border: 1px solid var(--border);
|
|
79
|
+
border-radius: 8px;
|
|
80
|
+
padding: 0.4rem;
|
|
81
|
+
text-align: center;
|
|
82
|
+
}
|
|
83
|
+
.start-timer-label {
|
|
84
|
+
color: var(--muted);
|
|
85
|
+
font-size: 0.7rem;
|
|
86
|
+
text-transform: uppercase;
|
|
87
|
+
letter-spacing: 0.03em;
|
|
88
|
+
}
|
|
89
|
+
.start-timer-value {
|
|
90
|
+
font-size: clamp(1rem, 4vw, 1.4rem);
|
|
91
|
+
font-weight: 600;
|
|
92
|
+
font-variant-numeric: tabular-nums;
|
|
93
|
+
}
|
|
94
|
+
.start-timer-value.early { color: var(--good); }
|
|
95
|
+
.start-timer-value.late { color: var(--bad); }
|
|
96
|
+
table {
|
|
97
|
+
width: 100%;
|
|
98
|
+
border-collapse: collapse;
|
|
99
|
+
font-size: clamp(0.85rem, 3vw, 1.05rem);
|
|
100
|
+
}
|
|
101
|
+
th, td {
|
|
102
|
+
padding: 0.3rem 0.4rem;
|
|
103
|
+
text-align: left;
|
|
104
|
+
border-bottom: 1px solid var(--border);
|
|
105
|
+
white-space: nowrap;
|
|
106
|
+
overflow: hidden;
|
|
107
|
+
text-overflow: ellipsis;
|
|
108
|
+
}
|
|
109
|
+
th {
|
|
110
|
+
color: var(--muted);
|
|
111
|
+
font-size: 0.75rem;
|
|
112
|
+
text-transform: uppercase;
|
|
113
|
+
letter-spacing: 0.03em;
|
|
114
|
+
}
|
|
115
|
+
td.rank { font-weight: 700; color: var(--accent); width: 2.5em; }
|
|
116
|
+
td.boat-name { max-width: 0; width: 100%; }
|
|
117
|
+
td.time { font-variant-numeric: tabular-nums; text-align: right; }
|
|
118
|
+
tbody tr:last-child td { border-bottom: none; }
|
|
119
|
+
.leaderboard-wrap {
|
|
120
|
+
flex: 1;
|
|
121
|
+
overflow-y: auto;
|
|
122
|
+
min-height: 0;
|
|
123
|
+
}
|
|
124
|
+
</style>
|
|
125
|
+
</head>
|
|
126
|
+
<body>
|
|
127
|
+
<h1 id="raceName">Race Control</h1>
|
|
128
|
+
<div id="stateLabel" class="state-label"></div>
|
|
129
|
+
<div id="emptyMsg" class="empty" hidden>No active race</div>
|
|
130
|
+
|
|
131
|
+
<div id="preStartView" hidden>
|
|
132
|
+
<div id="countdownValue" class="countdown">--:--:--</div>
|
|
133
|
+
<div class="start-timer-grid">
|
|
134
|
+
<div class="start-timer-tile">
|
|
135
|
+
<div class="start-timer-label">Distance</div>
|
|
136
|
+
<div id="dtlValue" class="start-timer-value">--</div>
|
|
137
|
+
</div>
|
|
138
|
+
<div class="start-timer-tile">
|
|
139
|
+
<div class="start-timer-label">ETA</div>
|
|
140
|
+
<div id="etaValue" class="start-timer-value">--:--:--</div>
|
|
141
|
+
</div>
|
|
142
|
+
<div class="start-timer-tile">
|
|
143
|
+
<div class="start-timer-label">Burn</div>
|
|
144
|
+
<div id="burnValue" class="start-timer-value">--</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<div id="leaderboardView" class="leaderboard-wrap" hidden>
|
|
150
|
+
<table>
|
|
151
|
+
<thead>
|
|
152
|
+
<tr><th>#</th><th>Boat</th><th>Corrected</th></tr>
|
|
153
|
+
</thead>
|
|
154
|
+
<tbody id="leaderboardBody"></tbody>
|
|
155
|
+
</table>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<script>
|
|
159
|
+
(function () {
|
|
160
|
+
const SK_API = '/signalk/v1/api';
|
|
161
|
+
const MS_TO_KNOTS = 1.9438444924574;
|
|
162
|
+
const DATA_POLL_MS = 2000;
|
|
163
|
+
|
|
164
|
+
const raceNameEl = document.getElementById('raceName');
|
|
165
|
+
const stateLabelEl = document.getElementById('stateLabel');
|
|
166
|
+
const emptyMsg = document.getElementById('emptyMsg');
|
|
167
|
+
const preStartView = document.getElementById('preStartView');
|
|
168
|
+
const leaderboardView = document.getElementById('leaderboardView');
|
|
169
|
+
const countdownValue = document.getElementById('countdownValue');
|
|
170
|
+
const dtlValue = document.getElementById('dtlValue');
|
|
171
|
+
const etaValue = document.getElementById('etaValue');
|
|
172
|
+
const burnValue = document.getElementById('burnValue');
|
|
173
|
+
const leaderboardBody = document.getElementById('leaderboardBody');
|
|
174
|
+
|
|
175
|
+
let race = null; // last-fetched vessels.self.racecontrol value
|
|
176
|
+
let selfNav = null; // { lat, lon, sogMs }
|
|
177
|
+
|
|
178
|
+
function unwrapValue(x) {
|
|
179
|
+
return x && typeof x === 'object' && 'value' in x ? x.value : x;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function fetchJSON(url) {
|
|
183
|
+
const res = await fetch(url);
|
|
184
|
+
if (!res.ok) throw new Error(res.statusText);
|
|
185
|
+
return res.json();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function fmtDuration(ms) {
|
|
189
|
+
if (ms == null || ms < 0 || !isFinite(ms)) return '--:--:--';
|
|
190
|
+
const totalSec = Math.floor(ms / 1000);
|
|
191
|
+
const h = Math.floor(totalSec / 3600);
|
|
192
|
+
const m = Math.floor((totalSec % 3600) / 60);
|
|
193
|
+
const s = totalSec % 60;
|
|
194
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
195
|
+
return `${pad(h)}:${pad(m)}:${pad(s)}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function fmtSignedDuration(ms) {
|
|
199
|
+
const sign = ms < 0 ? '-' : '+';
|
|
200
|
+
const s = Math.round(Math.abs(ms) / 1000);
|
|
201
|
+
const h = Math.floor(s / 3600);
|
|
202
|
+
const m = Math.floor((s % 3600) / 60);
|
|
203
|
+
const sec = s % 60;
|
|
204
|
+
return sign + (h ? `${h}:${String(m).padStart(2, '0')}` : m) + ':' + String(sec).padStart(2, '0');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Perpendicular distance from a point to the start-line segment (or to its
|
|
208
|
+
// nearest end) — same flat-earth-local approximation as the main webapp's
|
|
209
|
+
// start timer (public/app.js's distanceToSegmentNm), so the numbers here
|
|
210
|
+
// always match what's shown there.
|
|
211
|
+
function distanceToSegmentNm(p, a, b) {
|
|
212
|
+
const toRad = (deg) => (deg * Math.PI) / 180;
|
|
213
|
+
const meanLat = (a.lat + b.lat + p.lat) / 3;
|
|
214
|
+
const cosLat = Math.cos(toRad(meanLat)) || 1;
|
|
215
|
+
const NM_PER_DEG_LAT = 60;
|
|
216
|
+
const toXY = (pt) => ({ x: pt.lon * cosLat * NM_PER_DEG_LAT, y: pt.lat * NM_PER_DEG_LAT });
|
|
217
|
+
const P = toXY(p);
|
|
218
|
+
const A = toXY(a);
|
|
219
|
+
const B = toXY(b);
|
|
220
|
+
const abx = B.x - A.x;
|
|
221
|
+
const aby = B.y - A.y;
|
|
222
|
+
const lenSq = abx * abx + aby * aby;
|
|
223
|
+
let t = lenSq > 0 ? ((P.x - A.x) * abx + (P.y - A.y) * aby) / lenSq : 0;
|
|
224
|
+
t = Math.max(0, Math.min(1, t));
|
|
225
|
+
const cx = A.x + abx * t;
|
|
226
|
+
const cy = A.y + aby * t;
|
|
227
|
+
const dx = P.x - cx;
|
|
228
|
+
const dy = P.y - cy;
|
|
229
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function fetchRace() {
|
|
233
|
+
try {
|
|
234
|
+
const leaf = await fetchJSON(`${SK_API}/vessels/self/racecontrol`);
|
|
235
|
+
race = unwrapValue(leaf);
|
|
236
|
+
} catch (e) {
|
|
237
|
+
race = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function fetchSelfNav() {
|
|
242
|
+
try {
|
|
243
|
+
const [posLeaf, sogLeaf] = await Promise.all([
|
|
244
|
+
fetchJSON(`${SK_API}/vessels/self/navigation/position`).catch(() => null),
|
|
245
|
+
fetchJSON(`${SK_API}/vessels/self/navigation/speedOverGround`).catch(() => null)
|
|
246
|
+
]);
|
|
247
|
+
const v = posLeaf && posLeaf.value;
|
|
248
|
+
if (!v || typeof v.latitude !== 'number' || typeof v.longitude !== 'number') {
|
|
249
|
+
selfNav = null;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const sogMs = sogLeaf && typeof sogLeaf.value === 'number' ? sogLeaf.value : null;
|
|
253
|
+
selfNav = { lat: v.latitude, lon: v.longitude, sogMs };
|
|
254
|
+
} catch (e) {
|
|
255
|
+
selfNav = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function renderLeaderboard() {
|
|
260
|
+
leaderboardBody.innerHTML = '';
|
|
261
|
+
(race.leaderboard || []).forEach((r) => {
|
|
262
|
+
const tr = document.createElement('tr');
|
|
263
|
+
const tdRank = document.createElement('td');
|
|
264
|
+
tdRank.className = 'rank';
|
|
265
|
+
tdRank.textContent = r.rank == null ? '' : r.rank;
|
|
266
|
+
const tdName = document.createElement('td');
|
|
267
|
+
tdName.className = 'boat-name';
|
|
268
|
+
tdName.textContent = r.sailNumber ? `${r.name} (${r.sailNumber})` : r.name;
|
|
269
|
+
const tdTime = document.createElement('td');
|
|
270
|
+
tdTime.className = 'time';
|
|
271
|
+
tdTime.textContent = fmtDuration(r.correctedMs);
|
|
272
|
+
tr.append(tdRank, tdName, tdTime);
|
|
273
|
+
leaderboardBody.appendChild(tr);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function render() {
|
|
278
|
+
if (!race || race.state === 'none') {
|
|
279
|
+
raceNameEl.textContent = 'Race Control';
|
|
280
|
+
stateLabelEl.textContent = '';
|
|
281
|
+
emptyMsg.hidden = false;
|
|
282
|
+
preStartView.hidden = true;
|
|
283
|
+
leaderboardView.hidden = true;
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
emptyMsg.hidden = true;
|
|
287
|
+
raceNameEl.textContent = race.name || 'Race Control';
|
|
288
|
+
|
|
289
|
+
if (race.state === 'started') {
|
|
290
|
+
stateLabelEl.textContent = 'Racing';
|
|
291
|
+
preStartView.hidden = true;
|
|
292
|
+
leaderboardView.hidden = false;
|
|
293
|
+
renderLeaderboard();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
leaderboardView.hidden = true;
|
|
298
|
+
preStartView.hidden = false;
|
|
299
|
+
stateLabelEl.textContent = race.state === 'stopped' ? 'Stopped' : race.state === 'scheduled' ? 'Scheduled' : 'Not started';
|
|
300
|
+
|
|
301
|
+
if (!race.scheduledStart) {
|
|
302
|
+
countdownValue.textContent = '--:--:--';
|
|
303
|
+
dtlValue.textContent = '--';
|
|
304
|
+
etaValue.textContent = '--:--:--';
|
|
305
|
+
burnValue.textContent = '--';
|
|
306
|
+
burnValue.className = 'start-timer-value';
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const now = Date.now();
|
|
310
|
+
const toStartMs = race.scheduledStart - now;
|
|
311
|
+
countdownValue.textContent = fmtDuration(Math.max(0, toStartMs));
|
|
312
|
+
|
|
313
|
+
if (!race.startLine || !selfNav) {
|
|
314
|
+
dtlValue.textContent = '--';
|
|
315
|
+
etaValue.textContent = '--:--:--';
|
|
316
|
+
burnValue.textContent = '--';
|
|
317
|
+
burnValue.className = 'start-timer-value';
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const [a, b] = race.startLine;
|
|
321
|
+
const dtlNm = distanceToSegmentNm(selfNav, a, b);
|
|
322
|
+
dtlValue.textContent = `${dtlNm.toFixed(2)}nm`;
|
|
323
|
+
|
|
324
|
+
if (selfNav.sogMs == null || selfNav.sogMs < 0.25) {
|
|
325
|
+
etaValue.textContent = '--:--:--';
|
|
326
|
+
burnValue.textContent = '--';
|
|
327
|
+
burnValue.className = 'start-timer-value';
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const sogKn = selfNav.sogMs * MS_TO_KNOTS;
|
|
331
|
+
const etaMs = (dtlNm / sogKn) * 3600 * 1000;
|
|
332
|
+
etaValue.textContent = new Date(now + etaMs).toLocaleTimeString();
|
|
333
|
+
|
|
334
|
+
const burnMs = toStartMs - etaMs;
|
|
335
|
+
burnValue.textContent = fmtSignedDuration(burnMs);
|
|
336
|
+
burnValue.className = 'start-timer-value ' + (burnMs >= 0 ? 'early' : 'late');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function poll() {
|
|
340
|
+
await Promise.all([fetchRace(), fetchSelfNav()]);
|
|
341
|
+
render();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
poll();
|
|
345
|
+
setInterval(poll, DATA_POLL_MS);
|
|
346
|
+
setInterval(render, 1000);
|
|
347
|
+
})();
|
|
348
|
+
</script>
|
|
349
|
+
</body>
|
|
350
|
+
</html>
|