signalk-race-control 0.12.4 → 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/app.js +28 -1
- package/public/index.html +4 -0
- 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>",
|
package/public/app.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
(function () {
|
|
2
2
|
const API = '/plugins/race-control';
|
|
3
3
|
const SK_API = '/signalk/v1/api';
|
|
4
|
+
const SK_API_V2 = '/signalk/v2/api';
|
|
4
5
|
|
|
5
6
|
let races = []; // race summaries from GET /races
|
|
6
7
|
let activeRaceId = null;
|
|
@@ -85,6 +86,8 @@
|
|
|
85
86
|
const addBoatRow = document.getElementById('addBoatRow');
|
|
86
87
|
const addBoatName = document.getElementById('addBoatName');
|
|
87
88
|
const addBoatClass = document.getElementById('addBoatClass');
|
|
89
|
+
const addBoatBuddyLabel = document.getElementById('addBoatBuddyLabel');
|
|
90
|
+
const addBoatBuddy = document.getElementById('addBoatBuddy');
|
|
88
91
|
const addBoatBtn = document.getElementById('addBoatBtn');
|
|
89
92
|
const classSection = document.getElementById('classSection');
|
|
90
93
|
const classToggleBtn = document.getElementById('classToggleBtn');
|
|
@@ -349,6 +352,7 @@
|
|
|
349
352
|
|
|
350
353
|
function selectSuggestion(item) {
|
|
351
354
|
addBoatName.value = item.name;
|
|
355
|
+
addBoatBuddyLabel.hidden = !findMmsiByName(item.name);
|
|
352
356
|
hideSuggestions();
|
|
353
357
|
addBoatName.focus();
|
|
354
358
|
}
|
|
@@ -812,6 +816,7 @@
|
|
|
812
816
|
// per boat afterward, in the table row.
|
|
813
817
|
const mmsi = findMmsiByName(name);
|
|
814
818
|
const classId = addBoatClass.value || null;
|
|
819
|
+
const addAsBuddy = addBoatBuddy.checked && !!mmsi;
|
|
815
820
|
try {
|
|
816
821
|
const boat = await fetchJSON(`${API}/races/${encodeURIComponent(activeRaceId)}/boats`, {
|
|
817
822
|
method: 'POST',
|
|
@@ -820,13 +825,32 @@
|
|
|
820
825
|
});
|
|
821
826
|
raceState.boats[boat.id] = boat;
|
|
822
827
|
addBoatName.value = '';
|
|
828
|
+
addBoatBuddyLabel.hidden = true;
|
|
823
829
|
addBoatName.focus();
|
|
824
830
|
render();
|
|
831
|
+
if (addAsBuddy) addBuddy(mmsi, name);
|
|
825
832
|
} catch (e) {
|
|
826
833
|
setStatus(e.message, true);
|
|
827
834
|
}
|
|
828
835
|
}
|
|
829
836
|
|
|
837
|
+
// Best-effort registration with the (optional, third-party)
|
|
838
|
+
// signalk-buddylist-plugin, so the boat's AIS target is labeled/highlighted
|
|
839
|
+
// for other Signal K clients too. Never blocks adding the boat itself —
|
|
840
|
+
// the plugin may not be installed at all, which shows up as a 404 here.
|
|
841
|
+
async function addBuddy(mmsi, name) {
|
|
842
|
+
try {
|
|
843
|
+
await fetchJSON(`${SK_API_V2}/resources/buddies`, {
|
|
844
|
+
method: 'POST',
|
|
845
|
+
headers: { 'Content-Type': 'application/json' },
|
|
846
|
+
body: JSON.stringify({ urn: `urn:mrn:imo:mmsi:${mmsi}`, name })
|
|
847
|
+
});
|
|
848
|
+
} catch (e) {
|
|
849
|
+
const reason = e.status === 404 ? 'buddy list plugin not installed' : e.message;
|
|
850
|
+
setStatus(`Boat added (buddy list: ${reason})`, true);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
830
854
|
async function addClass() {
|
|
831
855
|
if (!activeRaceId) return;
|
|
832
856
|
const name = addClassName.value.trim();
|
|
@@ -3227,7 +3251,10 @@
|
|
|
3227
3251
|
hideSuggestions();
|
|
3228
3252
|
addBoat();
|
|
3229
3253
|
});
|
|
3230
|
-
addBoatName.addEventListener('input', () =>
|
|
3254
|
+
addBoatName.addEventListener('input', () => {
|
|
3255
|
+
showSuggestionsFor(addBoatName.value);
|
|
3256
|
+
addBoatBuddyLabel.hidden = !findMmsiByName(addBoatName.value);
|
|
3257
|
+
});
|
|
3231
3258
|
addBoatName.addEventListener('focus', () => {
|
|
3232
3259
|
if (addBoatName.value.trim()) showSuggestionsFor(addBoatName.value);
|
|
3233
3260
|
});
|
package/public/index.html
CHANGED
|
@@ -195,6 +195,10 @@
|
|
|
195
195
|
<div id="addBoatSuggestions" class="suggestions" hidden></div>
|
|
196
196
|
</div>
|
|
197
197
|
<select id="addBoatClass" class="class-select" title="Class"></select>
|
|
198
|
+
<label id="addBoatBuddyLabel" class="multi-day-label" title="Also register this boat's MMSI in the Signal K buddy list (requires the signalk-buddylist-plugin)" hidden>
|
|
199
|
+
<input type="checkbox" id="addBoatBuddy" />
|
|
200
|
+
Buddy
|
|
201
|
+
</label>
|
|
198
202
|
<button id="addBoatBtn">Add Boat</button>
|
|
199
203
|
</div>
|
|
200
204
|
<div class="table-scroll">
|
|
@@ -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>
|