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 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.4",
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', () => showSuggestionsFor(addBoatName.value));
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>